generated from coulomb/repo-seed
feat(WP-0003b): parser, workflow wiring, triggers, webhooks
T44: ActivityDefinition markdown file parser (definition_parser.py)
- Scans activity-definitions/*.md and ACTIVITY_DEFINITION_DIRS paths
- Parses YAML frontmatter + fenced rule/instruction blocks
- Raises ParseError on any malformed file — never silently skips
T45: ActivityDefinition sync command
- Migration 0006: adds rules_json/instructions_json JSONB columns
- sync_activity_definitions.py + make sync-activity-definitions
- Called at worker startup before schedule sync
T46: Rule/instruction pipeline wired into RunActivityWorkflow
- New evaluate_rules and emit_tasks Temporal activities
- Workflow passes event_envelope_json to enable rule evaluation
- EventRouter now passes full envelope JSON as 4th workflow arg
- IssueSink.emit() writes task_spawn_log rows per task
T47: ScheduledTriggerConfig model (one-off future datetime trigger)
T48: One-off Temporal Schedule support
- Fixed timezone_name → time_zone_name (was causing all schedule tests to fail)
- Added ScheduleCalendarSpec-based one-off schedule with remaining_actions=1
- cancel_scheduled() for admin cancellation
- Fixed backfill() call to use *args unpacking (not list wrapper)
- Fixed ScheduleAlreadyRunningError catch in upsert_schedule
- sync_schedules now handles ScheduledTriggerConfig definitions
T49: Webhook receiver
- POST /webhooks/gitea — HMAC-SHA256 via X-Gitea-Signature-256
- POST /webhooks/github — HMAC-SHA256 via X-Hub-Signature-256
- Normalisers: repo.created, push, issue.closed → EventEnvelope
- Publishes to NATS activity.{type} subject after registry validation
- Mounted in api.py at /webhooks prefix
T50: Gitea event type definitions
- gitea.repo.created.md, gitea.push.md, gitea.issue.closed.md
- Each includes normaliser field mapping in Consumer Notes
Tests: 18 passed, 1 skipped (integration). Fixed embedded Temporal
server visibility latency in test_upsert_schedule_creates_schedule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,12 +19,13 @@ from temporalio.common import RetryPolicy, SearchAttributeKey, TypedSearchAttrib
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from activity_core.activities import (
|
||||
emit_tasks,
|
||||
evaluate_rules,
|
||||
load_activity_definition,
|
||||
log_run,
|
||||
persist_task_instance,
|
||||
resolve_context,
|
||||
)
|
||||
from activity_core.template_engine import evaluate_templates
|
||||
from activity_core.schedule_manager import SCHEDULED_TRIGGER_KEY
|
||||
|
||||
# T32: Custom search attributes for Temporal visibility (must be registered in Temporal first).
|
||||
@@ -50,9 +51,9 @@ class RunActivityWorkflow:
|
||||
Sequence:
|
||||
1. load_activity_definition(activity_id) → defn dict
|
||||
2. resolve_context(defn.context_sources) → context snapshot
|
||||
3. evaluate_templates(templates, context) → task specs (pure, no activity)
|
||||
4. log_run(...) → run_id
|
||||
5. start_child_workflow per task spec (fire-and-forget, detached)
|
||||
3. evaluate_rules(rules, event, context) → matching rules → TaskSpec dicts
|
||||
4. emit_tasks(task_specs) → TaskRef list via IssueSink
|
||||
5. log_run(...) → activity_runs row
|
||||
"""
|
||||
|
||||
@workflow.run
|
||||
@@ -61,13 +62,14 @@ class RunActivityWorkflow:
|
||||
activity_id: str,
|
||||
trigger_key: str,
|
||||
scheduled_for: str | None = None,
|
||||
event_envelope_json: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
activity_id: UUID of the ActivityDefinition row.
|
||||
trigger_key: ISO-8601 datetime (cron) or event_id (event trigger).
|
||||
Used as the idempotency key component.
|
||||
scheduled_for: ISO-8601 string of the nominal scheduled time (cron only).
|
||||
activity_id: UUID of the ActivityDefinition row.
|
||||
trigger_key: event_id (event trigger) or "scheduled" (cron).
|
||||
scheduled_for: ISO-8601 nominal scheduled time (cron only).
|
||||
event_envelope_json: JSON-serialised EventEnvelope (event trigger only).
|
||||
|
||||
Returns:
|
||||
{"run_id": str, "tasks_spawned": int}
|
||||
@@ -98,21 +100,63 @@ class RunActivityWorkflow:
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 3. Evaluate templates (pure — no activity) ────────────────────────
|
||||
task_specs: list[dict] = evaluate_templates(
|
||||
defn["task_templates"], context_snapshot
|
||||
# ── 3. Evaluate rules ─────────────────────────────────────────────────
|
||||
import json as _json
|
||||
event_attrs: dict = {}
|
||||
if event_envelope_json:
|
||||
try:
|
||||
event_attrs = _json.loads(event_envelope_json).get("attributes", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
matched_rules: list[dict] = await workflow.execute_activity(
|
||||
evaluate_rules,
|
||||
{
|
||||
"rules": defn.get("rules", []),
|
||||
"event": event_attrs,
|
||||
"context": context_snapshot,
|
||||
},
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 4. Log the run ────────────────────────────────────────────────────
|
||||
# run_id is derived deterministically so log_run retries are idempotent.
|
||||
# For schedule-fired runs the trigger_key is the sentinel "scheduled";
|
||||
# each fire has a unique workflow_id (embeds ${firstScheduledTime}), so
|
||||
# we use the workflow_id as the dedup key instead.
|
||||
# Convert matched rules to TaskSpec dicts for emission.
|
||||
task_spec_dicts: list[dict] = []
|
||||
for rule in matched_rules:
|
||||
action = rule.get("action", {})
|
||||
task_spec_dicts.append({
|
||||
"title": action.get("task_template", rule.get("id", "")),
|
||||
"description": "",
|
||||
"target_repo": action.get("target_repo"),
|
||||
"priority": action.get("priority", "medium"),
|
||||
"labels": action.get("labels", []),
|
||||
"due_in_days": action.get("due_in_days"),
|
||||
"source_type": "rule",
|
||||
"source_id": rule.get("id", ""),
|
||||
"condition": rule.get("condition", ""),
|
||||
})
|
||||
|
||||
# ── 4. Emit tasks via IssueSink ───────────────────────────────────────
|
||||
if trigger_key == SCHEDULED_TRIGGER_KEY:
|
||||
dedup_source = workflow.info().workflow_id
|
||||
else:
|
||||
dedup_source = f"{activity_id}:{trigger_key}"
|
||||
run_id = str(uuid.uuid5(uuid.NAMESPACE_URL, dedup_source))
|
||||
|
||||
if task_spec_dicts:
|
||||
await workflow.execute_activity(
|
||||
emit_tasks,
|
||||
{
|
||||
"task_specs": task_spec_dicts,
|
||||
"activity_id": activity_id,
|
||||
"triggering_event_id": trigger_key,
|
||||
"run_id": run_id,
|
||||
},
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 5. Log the run ────────────────────────────────────────────────────
|
||||
await workflow.execute_activity(
|
||||
log_run,
|
||||
{
|
||||
@@ -120,25 +164,14 @@ class RunActivityWorkflow:
|
||||
"activity_id": activity_id,
|
||||
"scheduled_for": scheduled_for,
|
||||
"context_snapshot": context_snapshot,
|
||||
"tasks_spawned": len(task_specs),
|
||||
"tasks_spawned": len(task_spec_dicts),
|
||||
"version_used": defn["version"],
|
||||
},
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
# ── 5. Spawn task executor children (fire-and-forget) ─────────────────
|
||||
for index, spec in enumerate(task_specs):
|
||||
child_id = f"task-{run_id}:{spec['task_type']}:{index}"
|
||||
await workflow.start_child_workflow(
|
||||
TaskExecutorWorkflow,
|
||||
args=[run_id, spec["task_type"], spec["params"]],
|
||||
id=child_id,
|
||||
task_queue=_TASK_QUEUE,
|
||||
parent_close_policy=workflow.ParentClosePolicy.ABANDON,
|
||||
)
|
||||
|
||||
return {"run_id": run_id, "tasks_spawned": len(task_specs)}
|
||||
return {"run_id": run_id, "tasks_spawned": len(task_spec_dicts)}
|
||||
|
||||
|
||||
@workflow.defn
|
||||
|
||||
Reference in New Issue
Block a user