Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""Temporal workflow definitions for activity-core.
|
|
|
|
RunActivityWorkflow is registered on ``orchestrator-tq``. Workflow IDs follow
|
|
``activity-{activity_id}:{trigger_key}``; see docs/conventions.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
from temporalio import workflow
|
|
from temporalio.common import RetryPolicy, SearchAttributeKey, TypedSearchAttributes, SearchAttributePair
|
|
|
|
with workflow.unsafe.imports_passed_through():
|
|
from activity_core.activities import (
|
|
apply_sbom_catchup,
|
|
emit_tasks,
|
|
execute_bounded_operation,
|
|
evaluate_rules,
|
|
evaluate_instructions,
|
|
load_activity_definition,
|
|
log_run,
|
|
persist_instruction_reports,
|
|
persist_ops_evidence,
|
|
resolve_context,
|
|
)
|
|
from activity_core.bounded_operations import operation_sources
|
|
from activity_core.ops_run_queue import emit_triggering_event_id
|
|
from activity_core.schedule_manager import SCHEDULED_TRIGGER_KEY
|
|
|
|
# T32: Custom search attributes for Temporal visibility (must be registered in Temporal first).
|
|
# Registration: temporal operator search-attribute create --name ActivityId --type Keyword
|
|
_ACTIVITY_ID_KEY = SearchAttributeKey.for_keyword("ActivityId")
|
|
_ACTIVITY_NAME_KEY = SearchAttributeKey.for_keyword("ActivityName")
|
|
|
|
_RETRY_POLICY = RetryPolicy(
|
|
initial_interval=timedelta(seconds=1),
|
|
backoff_coefficient=2.0,
|
|
maximum_interval=timedelta(minutes=5),
|
|
maximum_attempts=10,
|
|
)
|
|
|
|
_ACTIVITY_TIMEOUT = timedelta(
|
|
seconds=int(os.environ.get("ACTIVITY_TIMEOUT_SECONDS", "900"))
|
|
)
|
|
_NO_RETRY_POLICY = RetryPolicy(maximum_attempts=1)
|
|
|
|
|
|
@workflow.defn
|
|
class RunActivityWorkflow:
|
|
"""Durable orchestration workflow.
|
|
|
|
Sequence:
|
|
1. load_activity_definition(activity_id) → defn dict
|
|
2. resolve_context(defn.context_sources) → read-only context snapshot
|
|
3. execute admitted bounded operations → bounded outcome patches
|
|
4. persist operation evidence
|
|
5. evaluate rules/instructions → TaskSpec dicts and reports
|
|
6. log run, then emit tasks
|
|
"""
|
|
|
|
@workflow.run
|
|
async def run(
|
|
self,
|
|
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: 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}
|
|
"""
|
|
# ── 1. Load definition ────────────────────────────────────────────────
|
|
defn: dict = await workflow.execute_activity(
|
|
load_activity_definition,
|
|
activity_id,
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
# T32: Tag this workflow execution with activity metadata so runs are
|
|
# filterable in the Temporal UI (requires ActivityId + ActivityName to be
|
|
# registered as custom search attributes — see docs/runbook.md).
|
|
workflow.upsert_search_attributes(
|
|
TypedSearchAttributes([
|
|
SearchAttributePair(_ACTIVITY_ID_KEY, activity_id),
|
|
SearchAttributePair(_ACTIVITY_NAME_KEY, defn.get("name", "")),
|
|
])
|
|
)
|
|
|
|
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))
|
|
|
|
# ── 2. Resolve context (read-only) ────────────────────────────────────
|
|
context_snapshot: dict = await workflow.execute_activity(
|
|
resolve_context,
|
|
args=[defn["context_sources"], event_envelope_json],
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
# ── 3. Execute admitted bounded operations ──────────────────────────
|
|
# SBOM retains its dedicated heartbeat/retry activity because it has a
|
|
# stable remote idempotency key. Shell-backed operations are one-shot:
|
|
# their platform tools do not expose a receipt activity-core can use to
|
|
# prove an ambiguous mutation safe to repeat.
|
|
if any(
|
|
isinstance(source, dict)
|
|
and source.get("type") == "sbom-nexus"
|
|
and source.get("query") == "catch_up"
|
|
and (source.get("params") or {}).get("apply") is True
|
|
for source in defn.get("context_sources", [])
|
|
) and workflow.patched("activity-wp-0033-sbom-retry-boundary"):
|
|
context_patches: dict = await workflow.execute_activity(
|
|
apply_sbom_catchup,
|
|
{
|
|
"context_sources": defn.get("context_sources", []),
|
|
"context": context_snapshot,
|
|
"run_id": run_id,
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
heartbeat_timeout=timedelta(seconds=60),
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
for bind_key, patch in context_patches.items():
|
|
current = context_snapshot.get(bind_key)
|
|
if isinstance(current, dict) and isinstance(patch, dict):
|
|
current.update(patch)
|
|
|
|
for source, operation_spec in operation_sources(
|
|
defn.get("context_sources", [])
|
|
):
|
|
if operation_spec.operation_id == "sbom_nexus_ingest":
|
|
continue
|
|
operation_patch: dict = await workflow.execute_activity(
|
|
execute_bounded_operation,
|
|
{"source": source, "run_id": run_id},
|
|
start_to_close_timeout=timedelta(
|
|
seconds=operation_spec.max_timeout_seconds
|
|
),
|
|
retry_policy=_NO_RETRY_POLICY,
|
|
)
|
|
context_snapshot.update(operation_patch)
|
|
|
|
# ── 4. Persist bounded operation/read evidence ───────────────────────
|
|
await workflow.execute_activity(
|
|
persist_ops_evidence,
|
|
{
|
|
"context_sources": defn.get("context_sources", []),
|
|
"context": context_snapshot,
|
|
"activity_id": activity_id,
|
|
"run_id": run_id,
|
|
"scheduled_for": scheduled_for,
|
|
"version_used": defn["version"],
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
# ── 5. 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
|
|
|
|
task_spec_dicts: 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,
|
|
)
|
|
|
|
report_dicts: list[dict] = []
|
|
if defn.get("instructions"):
|
|
instruction_result: dict = await workflow.execute_activity(
|
|
evaluate_instructions,
|
|
{
|
|
"instructions": defn.get("instructions", []),
|
|
"event": event_attrs,
|
|
"context": context_snapshot,
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
task_spec_dicts.extend(instruction_result.get("task_specs", []))
|
|
report_dicts.extend(instruction_result.get("reports", []))
|
|
|
|
# ── 6. Persist reports ────────────────────────────────────────────────
|
|
if report_dicts:
|
|
await workflow.execute_activity(
|
|
persist_instruction_reports,
|
|
{
|
|
"reports": report_dicts,
|
|
"activity_id": activity_id,
|
|
"run_id": run_id,
|
|
"scheduled_for": scheduled_for,
|
|
"version_used": defn["version"],
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
# ── 7. Log the run BEFORE emit ────────────────────────────────────────
|
|
# ACTIVITY-WP-0021: emit_tasks sink failures used to abort the workflow
|
|
# before log_run, so failed Binky/SBOM fires left no activity_runs row
|
|
# and automation-status could not observe them. Always record the run;
|
|
# then emit and re-raise so Temporal still surfaces sink failures.
|
|
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_spec_dicts),
|
|
"version_used": defn["version"],
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
# ── 8. Emit tasks (may fail independently of run audit) ───────────────
|
|
if task_spec_dicts:
|
|
# Cron schedules pass trigger_key="scheduled" for *every* fire.
|
|
# ops_run idempotency is {def}:{source}:{triggering_event_id}, so
|
|
# a bare "scheduled" key collides after the first day and silently
|
|
# skips create (claim loop starves; dual-clock timers paper over it).
|
|
# run_id is uuid5(workflow_id) for schedules and is unique per fire
|
|
# while stable on Temporal redelivery of the same fire.
|
|
emit_trigger_id = emit_triggering_event_id(
|
|
trigger_key, run_id, scheduled_for=scheduled_for
|
|
)
|
|
await workflow.execute_activity(
|
|
emit_tasks,
|
|
{
|
|
"task_specs": task_spec_dicts,
|
|
"activity_id": activity_id,
|
|
"triggering_event_id": emit_trigger_id,
|
|
"run_id": run_id,
|
|
"version_used": defn["version"],
|
|
},
|
|
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
|
retry_policy=_RETRY_POLICY,
|
|
)
|
|
|
|
return {"run_id": run_id, "tasks_spawned": len(task_spec_dicts)}
|