"""Temporal activity definitions for activity-core. Activities run inside a Worker bound to 'orchestrator-tq'. Each function is decorated with @activity.defn and executed by RunActivityWorkflow via workflow.execute_activity(). DB access pattern: worker.py calls init_session_factory(url) once before starting workers, which sets the module-level _session_factory used by activities that need DB access. """ from __future__ import annotations import json import uuid from datetime import datetime, timezone from typing import Any from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from temporalio import activity from temporalio.exceptions import ApplicationError from activity_core.db import make_engine from activity_core.issue_sink import get_issue_sink from activity_core.orm import ActivityDefinition as ActivityDefinitionRow from activity_core.orm import ActivityRun, TaskInstance, TaskSpawnLog from activity_core.ops_run_queue import create_ops_run_from_spec from activity_core.llm_client import get_llm_client from activity_core.models import InstructionDef from activity_core.ops_evidence_sinks import persist_ops_inventory_evidence from activity_core.report_sinks import persist_reports from activity_core.rules.actions import expand_rule_actions from activity_core.rules.executor import execute_instruction_with_audit _session_factory: async_sessionmaker[AsyncSession] | None = None def init_session_factory(url: str) -> None: """Initialise the shared DB session factory. Must be called once from worker.py before workers are started. """ global _session_factory _session_factory = async_sessionmaker(make_engine(url), expire_on_commit=False) def _get_session_factory() -> async_sessionmaker[AsyncSession]: if _session_factory is None: raise RuntimeError( "DB session factory not initialised — call init_session_factory() first" ) return _session_factory def _bind_resolver_result(bind_key: str, result: Any) -> Any: """Unwrap single-key resolver payloads when the key matches bind_key. Resolvers such as ``discover_kaizen_projects`` return ``{"projects": [...]}`` while definitions bind to ``context.projects`` and iterate ``for_each: context.projects``. Multi-key summaries (e.g. repo SBOM bulk) stay intact. """ if isinstance(result, dict) and len(result) == 1 and bind_key in result: return result[bind_key] return result def _parse_event_envelope(event_envelope_json: str | None) -> dict[str, Any] | None: """Parse an event envelope JSON string for context resolvers.""" if not event_envelope_json: return None try: payload = json.loads(event_envelope_json) except (TypeError, json.JSONDecodeError) as exc: activity.logger.warning("Invalid event envelope JSON - %s", exc) return None if not isinstance(payload, dict): activity.logger.warning( "Invalid event envelope JSON - expected object, got %s", type(payload).__name__, ) return None return payload # ── Activities ───────────────────────────────────────────────────────────────── @activity.defn async def load_activity_definition(activity_id: str) -> dict: """Load an ActivityDefinition row from Postgres by ID. Returns a JSON-serialisable dict suitable for passing between Temporal workflow steps. Raises: ApplicationError (non-retryable): if no row exists for activity_id. """ Session = _get_session_factory() async with Session() as session: row = await session.scalar( select(ActivityDefinitionRow).where( ActivityDefinitionRow.id == uuid.UUID(activity_id) ) ) if row is None: raise ApplicationError( f"ActivityDefinition {activity_id!r} not found", non_retryable=True, ) return { "id": str(row.id), "name": row.name, "enabled": row.enabled, "trigger_type": row.trigger_type, "trigger_config": row.trigger_config, "context_sources": row.context_sources, "task_templates": row.task_templates, "rules": row.rules_json, "instructions": row.instructions_json, "dedupe_key_strategy": row.dedupe_key_strategy, "version": row.version, } @activity.defn async def resolve_context( context_sources: list[dict], event_envelope_json: str | None = None, ) -> dict: """Resolve each context source and merge into a snapshot dict. Returns: {bind_key: resolved_value, ...} Source types are dispatched via CONTEXT_RESOLVER_REGISTRY. A resolver that raises logs a warning and binds {} unless the context source is marked required, in which case the activity fails visibly. The 'static' type is handled inline without a registry entry. """ import activity_core.context_resolvers # noqa: F401 — registers all adapters from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY snapshot: dict = {} event_envelope = _parse_event_envelope(event_envelope_json) for source in context_sources: source_type = source.get("type", "") query = source.get("query", "") params = source.get("params") or {} required = bool(source.get("required") or params.get("required", False)) resolver_params = dict(params) resolver_params["required"] = required raw_bind = source.get("bind_to") or source.get("name") or source_type # Strip the 'context.' namespace prefix so evaluator can find the key. bind_key = raw_bind.removeprefix("context.") if raw_bind.startswith("context.") else raw_bind if source_type == "static": value = source.get("config", {}).get("value") if isinstance(value, str) and ( value.startswith("custodian://") or value.startswith("activity-core://") ): from activity_core.runtime_paths import resolve_runtime_path value = str(resolve_runtime_path(value)) snapshot[bind_key] = value continue resolver_cls = CONTEXT_RESOLVER_REGISTRY.get(source_type) if resolver_cls is None: if required: raise ApplicationError( f"Required context source type {source_type!r} is not registered", non_retryable=True, ) activity.logger.warning( "Unknown context source type %r — binding {}", source_type, ) snapshot[bind_key] = {} continue try: resolved = resolver_cls().resolve(query, event_envelope, resolver_params) snapshot[bind_key] = _bind_resolver_result(bind_key, resolved) except Exception as exc: if required: raise ApplicationError( f"Required context resolver {source_type!r}/{query!r} failed: {exc}" ) from exc activity.logger.warning( "Context resolver %r failed — %s; binding {}", source_type, exc, ) snapshot[bind_key] = {} return snapshot def _sbom_heartbeat_state(run_id: str) -> dict[str, Any]: try: details = activity.info().heartbeat_details except RuntimeError: return {"run_id": run_id, "outcomes_by_bind": {}} if not details or not isinstance(details[0], dict): return {"run_id": run_id, "outcomes_by_bind": {}} state = dict(details[0]) if state.get("run_id") != run_id: return {"run_id": run_id, "outcomes_by_bind": {}} if not isinstance(state.get("outcomes_by_bind"), dict): state["outcomes_by_bind"] = {} return state def _heartbeat_sbom_state(state: dict[str, Any]) -> None: try: activity.heartbeat(state) except RuntimeError: # Direct unit invocation has no Temporal activity context. pass @activity.defn async def apply_sbom_catchup(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: """Apply declared SBOM writes to the fixed selection in workflow history.""" from activity_core.context_resolvers.sbom_nexus import apply_bounded_ingest run_id = str(payload["run_id"]) context_sources = payload.get("context_sources") or [] context = payload.get("context") or {} heartbeat_state = _sbom_heartbeat_state(run_id) outcomes_by_bind = heartbeat_state["outcomes_by_bind"] patches: dict[str, dict[str, Any]] = {} for source in context_sources: if not isinstance(source, dict): continue params = source.get("params") or {} if not ( source.get("type") == "sbom-nexus" and source.get("query") == "catch_up" and params.get("apply") is True ): continue raw_bind = source.get("bind_to") or source.get("name") or "sbom-nexus" bind_key = str(raw_bind).removeprefix("context.") selection = context.get(bind_key) if not isinstance(selection, dict): continue repos = selection.get("repos") if not isinstance(repos, list): continue try: limit = int(selection.get("limit", params.get("limit", 3))) except (TypeError, ValueError): limit = 3 limit = max(1, min(25, limit)) fixed_repos = [repo for repo in repos[:limit] if isinstance(repo, dict)] def record_progress(outcomes: list[dict[str, Any]]) -> None: outcomes_by_bind[bind_key] = outcomes _heartbeat_sbom_state(heartbeat_state) patches[bind_key] = apply_bounded_ingest( fixed_repos, operation_id=run_id, completed=outcomes_by_bind.get(bind_key), on_progress=record_progress, ) return patches @activity.defn async def log_run(run_payload: dict) -> str: """Persist an ActivityRun record to Postgres and return its run_id. Idempotent: uses INSERT … ON CONFLICT (run_id) DO NOTHING so Temporal activity retries do not produce duplicate rows. Expected keys in run_payload: run_id (str UUID — computed deterministically in workflow) activity_id (str UUID) scheduled_for (ISO-8601 str or None) context_snapshot (dict) tasks_spawned (int) version_used (int) Returns: run_id as a str UUID. """ Session = _get_session_factory() run_id = uuid.UUID(run_payload["run_id"]) scheduled_for: datetime | None = None if run_payload.get("scheduled_for"): scheduled_for = datetime.fromisoformat(run_payload["scheduled_for"]) stmt = ( pg_insert(ActivityRun) .values( run_id=run_id, activity_id=uuid.UUID(run_payload["activity_id"]), scheduled_for=scheduled_for, fired_at=datetime.now(tz=timezone.utc), context_snapshot=run_payload["context_snapshot"], tasks_spawned=run_payload["tasks_spawned"], version_used=run_payload["version_used"], ) .on_conflict_do_nothing(index_elements=["run_id"]) ) async with Session() as session: async with session.begin(): await session.execute(stmt) return str(run_id) @activity.defn async def persist_task_instance(task_payload: dict) -> str: """Write a TaskInstance row and return its id. Idempotent: uses INSERT … ON CONFLICT (id) DO NOTHING. Expected keys in task_payload: id (str UUID — deterministic, computed in TaskExecutorWorkflow) run_id (str UUID) type (str) params (dict) status (str, default "done" for stub) Returns: task instance id as a str UUID. """ Session = _get_session_factory() task_id = uuid.UUID(task_payload["id"]) stmt = ( pg_insert(TaskInstance) .values( id=task_id, run_id=uuid.UUID(task_payload["run_id"]), type=task_payload["type"], params=task_payload.get("params", {}), status=task_payload.get("status", "done"), ) .on_conflict_do_nothing(index_elements=["id"]) ) async with Session() as session: async with session.begin(): await session.execute(stmt) return str(task_id) @activity.defn async def evaluate_rules(payload: dict) -> list[dict]: """Evaluate rules and render matching actions as task specs. Rules that raise UnsafeExpression or any other error are skipped and logged. Expected keys in payload: rules list[dict] — RuleDef serialised dicts event dict — EventEnvelope attributes (or empty for cron) context dict — context snapshot from resolve_context """ from activity_core.rules.evaluator import UnsafeExpression rules = payload.get("rules", []) event_attrs = payload.get("event", {}) context = payload.get("context", {}) # Build a simple object whose attributes mirror event fields for the evaluator. class _Env: def __init__(self, attrs: dict) -> None: self.attributes = _DictObj(attrs) class _DictObj: def __init__(self, d: dict) -> None: self.__dict__.update(d) event_obj = _Env(event_attrs) task_specs: list[dict] = [] for rule in rules: try: task_specs.extend(expand_rule_actions([rule], event_obj, context)) except UnsafeExpression as exc: activity.logger.warning("rule %r unsafe expression — skipping: %s", rule.get("id"), exc) except Exception as exc: activity.logger.warning("rule %r eval error — skipping: %s", rule.get("id"), exc) return task_specs @activity.defn async def evaluate_instructions(payload: dict) -> dict: """Evaluate instruction blocks and return task specs/reports with audit fields. Expected keys in payload: instructions list[dict] — InstructionDef serialised dicts event dict — EventEnvelope attributes (or empty for cron) context dict — context snapshot from resolve_context """ instructions = payload.get("instructions", []) event_attrs = payload.get("event", {}) context = payload.get("context", {}) llm_client = get_llm_client() class _Env: def __init__(self, attrs: dict) -> None: self.attributes = _DictObj(attrs) class _DictObj: def __init__(self, d: dict) -> None: self.__dict__.update(d) event_obj = _Env(event_attrs) task_specs: list[dict] = [] reports: list[dict] = [] for raw_instruction in instructions: try: instruction_data = dict(raw_instruction) output_schema = instruction_data.get("output_schema") if isinstance(output_schema, str) and output_schema.startswith( ("custodian://", "activity-core://") ): # Resolve deployment-neutral URIs at the workflow/activity # boundary. The pure rules package only accepts filesystem # paths and must not import runtime integration modules. from activity_core.runtime_paths import resolve_runtime_path instruction_data["output_schema"] = str( resolve_runtime_path(output_schema) ) instruction = InstructionDef.model_validate(instruction_data) except Exception as exc: activity.logger.warning("instruction definition invalid — %s", exc) continue result = execute_instruction_with_audit( instruction, event_obj, context, llm_client, ) report = result.report output_validated = result.output_validated review_required = result.review_required validation_error = result.validation_error # ACTIVITY-WP-0021-T05: when LLM produces nothing but a curated digest # is present and the instruction has report sinks, still emit a # deterministic digest-only report so operators are not silent-blind. if report is None and instruction.report_sinks: digest = context.get("daily_triage_digest") if isinstance(digest, str) and digest.strip(): report = { "summary": ( f"Deterministic daily triage digest only " f"(instruction {instruction.id} produced no LLM report)." ), "status": "candidate_digest_only", "deterministic": True, "digest_preview": digest[:4000], } output_validated = False review_required = True validation_error = ( validation_error or "no_llm_report; posted deterministic digest" ) if report is not None: reports.append({ "instruction_id": instruction.id, "report": report, "sinks": instruction.report_sinks, "condition": result.condition_matched, "prompt_hash": result.prompt_hash, "model": result.model, "output_validated": output_validated, "review_required": review_required, "validation_error": validation_error, "llm_response_metadata": result.llm_response_metadata, }) for spec in result.tasks: task_specs.append({ "title": spec.title, "description": spec.description, "target_repo": spec.target_repo, "priority": spec.priority, "labels": spec.labels, "due_in_days": spec.due_in_days, "source_type": "instruction", "source_id": instruction.id, "condition": result.condition_matched, "prompt_hash": result.prompt_hash, "model": result.model, "output_validated": result.output_validated, "review_required": result.review_required, "approach_hint": instruction.approach_hint, "harness_profile_ref": instruction.harness_profile_ref, "execution_refs": instruction.execution_refs, }) return {"task_specs": task_specs, "reports": reports} @activity.defn async def persist_instruction_reports(payload: dict) -> list[dict]: """Persist report payloads to deterministic configured sinks.""" return persist_reports(payload) @activity.defn async def persist_ops_evidence(payload: dict) -> list[dict]: """Persist compact deterministic ops inventory evidence.""" return persist_ops_inventory_evidence(payload) @activity.defn async def emit_tasks(payload: dict) -> list[str]: """Emit TaskSpecs to IssueSink and write task_spawn_log rows. Returns list of external task ref IDs. Expected keys in payload: task_specs list[dict] — from evaluate_rules matched actions activity_id str — UUID of the ActivityDefinition triggering_event_id str — event ID or workflow ID for cron run_id str — UUID of the ActivityRun """ from activity_core.rules.models import TaskSpec task_specs_raw = payload.get("task_specs", []) activity_id = payload.get("activity_id", "") triggering_event_id = payload.get("triggering_event_id", "") # Profile errors are policy failures, not best-effort queue failures. Check # the whole batch before opening a DB transaction or touching IssueSink so a # later malformed item cannot leave an earlier item partially emitted. from activity_core.glas_profile import ( ProfileRefError, normalise_execution_refs, resolve_execution_selector, ) validated_specs: list[dict] = [] for index, raw_spec in enumerate(task_specs_raw): if not isinstance(raw_spec, dict): raise ApplicationError( f"task spec {index} is not a mapping", non_retryable=True, ) spec_dict = dict(raw_spec) try: profile_ref, approach_hint = resolve_execution_selector( spec_dict.get("harness_profile_ref"), spec_dict.get("approach_hint"), ) except ProfileRefError as exc: source = f"{spec_dict.get('source_type', 'rule')}:{spec_dict.get('source_id', '')}" raise ApplicationError( f"execution profile refused for {source}: {exc}", non_retryable=True, ) from exc spec_dict["harness_profile_ref"] = profile_ref spec_dict["approach_hint"] = approach_hint spec_dict["execution_refs"] = normalise_execution_refs( spec_dict.get("execution_refs") ) validated_specs.append(spec_dict) sink = get_issue_sink() Session = _get_session_factory() refs: list[str] = [] errors: list[str] = [] async with Session() as session: async with session.begin(): for spec_dict in validated_specs: spec = TaskSpec( title=spec_dict.get("title", ""), description=spec_dict.get("description", ""), target_repo=spec_dict.get("target_repo"), priority=spec_dict.get("priority", "medium"), labels=spec_dict.get("labels", []), due_in_days=spec_dict.get("due_in_days"), source_type=spec_dict.get("source_type", "rule"), source_id=spec_dict.get("source_id", ""), triggering_event_id=triggering_event_id, activity_definition_id=activity_id, ) try: # ACTIVITY-WP-0026: claimable ops_run (primary for harness) try: from activity_core.ops_run_queue import ( build_idempotency_key, ops_run_queue_enabled, ) ops_id = await create_ops_run_from_spec( session, spec, approach_hint=spec_dict.get("approach_hint"), harness_profile_ref=spec_dict.get("harness_profile_ref"), execution_refs=spec_dict.get("execution_refs"), ) if ops_id is not None: activity.logger.info( "emit_tasks: ops_run created id=%s key=%s", ops_id, build_idempotency_key(spec), ) elif ops_run_queue_enabled(): # Unique constraint hit: redelivery of same fire (ok) # OR a non-unique trigger key (bug — see emit_triggering_event_id). activity.logger.info( "emit_tasks: ops_run not inserted (duplicate or skip) key=%s", build_idempotency_key(spec), ) except Exception as ops_exc: activity.logger.warning( "emit_tasks: ops_run insert failed — %s", ops_exc ) ref = sink.emit(spec) refs.append(ref.external_id) log_row = TaskSpawnLog( activity_def_id=uuid.UUID(activity_id), source_type=spec.source_type, source_id=spec.source_id, source_version="1", triggering_event_id=triggering_event_id, task_ref=ref.external_id, condition_matched=spec_dict.get("condition"), prompt_hash=spec_dict.get("prompt_hash"), model=spec_dict.get("model"), output_validated=spec_dict.get("output_validated"), review_required=spec_dict.get("review_required"), ) session.add(log_row) except Exception as exc: message = f"{spec.source_type}:{spec.source_id}: {exc}" errors.append(message) activity.logger.warning("emit_tasks: sink.emit failed — %s", exc) if errors: raise RuntimeError(f"task emission sink failure: {errors!r}") return refs