feat(workflows): implement RunActivityWorkflow — T18

workflows.py — RunActivityWorkflow:
  1. load_activity_definition(activity_id)
  2. resolve_context(context_sources)
  3. evaluate_templates (pure, called in-workflow)
  4. log_run({run_id, ...}) — run_id = uuid5(NAMESPACE_URL, activity_id:trigger_key)
  5. start_child_workflow(TaskExecutorWorkflow, ...) per task spec
     ABANDON parent-close policy (fire-and-forget)
  Returns {"run_id": str, "tasks_spawned": int}

activities.py — log_run updated:
  - now accepts run_id in run_payload (deterministic, passed from workflow)
  - uses pg INSERT ... ON CONFLICT (run_id) DO NOTHING for idempotency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 22:25:19 +00:00
parent 068780224e
commit da7de6ea3b
3 changed files with 109 additions and 23 deletions

View File

@@ -7,16 +7,15 @@ Two workflows are registered here:
Workflow IDs follow the conventions in docs/conventions.md:
RunActivityWorkflow: activity-{activity_id}:{trigger_key}
TaskExecutorWorkflow: task-{run_id}:{task_type}:{index}
Implementations are added in T18T19; stubs here let the worker
register and the type system resolve references in T14T17.
"""
from __future__ import annotations
import uuid
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from activity_core.activities import (
@@ -24,32 +23,107 @@ with workflow.unsafe.imports_passed_through():
log_run,
resolve_context,
)
from activity_core.template_engine import evaluate_templates
_DEFAULT_TIMEOUT = timedelta(minutes=5)
_RETRY_POLICY = RetryPolicy(
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_interval=timedelta(minutes=5),
maximum_attempts=10,
)
_ACTIVITY_TIMEOUT = timedelta(minutes=5)
_TASK_QUEUE = "task-execution-tq"
@workflow.defn
class RunActivityWorkflow:
"""Durable orchestration workflow.
Sequence (T18):
1. load_activity_definition(activity_id)
2. resolve_context(context_sources)
3. evaluate_templates(task_templates, context) ← pure function, no activity
4. spawn TaskExecutorWorkflow child per template result
5. log_run(...)
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)
"""
@workflow.run
async def run(self, activity_id: str, trigger_key: str) -> dict:
raise NotImplementedError("T18")
async def run(
self,
activity_id: str,
trigger_key: str,
scheduled_for: 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).
Returns:
{"run_id": str, "tasks_spawned": int}
"""
# ── 1. Load definition ────────────────────────────────────────────────
defn: dict = await workflow.execute_activity(
load_activity_definition,
activity_id,
start_to_close_timeout=_ACTIVITY_TIMEOUT,
retry_policy=_RETRY_POLICY,
)
# ── 2. Resolve context ────────────────────────────────────────────────
context_snapshot: dict = await workflow.execute_activity(
resolve_context,
defn["context_sources"],
start_to_close_timeout=_ACTIVITY_TIMEOUT,
retry_policy=_RETRY_POLICY,
)
# ── 3. Evaluate templates (pure — no activity) ────────────────────────
task_specs: list[dict] = evaluate_templates(
defn["task_templates"], context_snapshot
)
# ── 4. Log the run ────────────────────────────────────────────────────
# run_id is derived deterministically so log_run retries are idempotent.
run_id = str(
uuid.uuid5(uuid.NAMESPACE_URL, f"{activity_id}:{trigger_key}")
)
await workflow.execute_activity(
log_run,
{
"run_id": run_id,
"activity_id": activity_id,
"scheduled_for": scheduled_for,
"context_snapshot": context_snapshot,
"tasks_spawned": len(task_specs),
"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)}
@workflow.defn
class TaskExecutorWorkflow:
"""Child workflow that executes one concrete task instance.
Stub implementation in T19.
Stub implementation T19.
"""
@workflow.run