From 30598fd1ad990dd5aeeb868c3b911812c77e8780 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 3 Jun 2026 11:58:24 +0200 Subject: [PATCH] Expand rule actions for per-repo tasks Add safe action interpolation and for_each binding for rule fan-out, update the weekly SBOM definition, cover the new evaluation path, and reconcile activity-core scope/workplans for the State Hub sync. --- SCOPE.md | 91 +++++++--- activity-definitions/weekly-sbom-staleness.md | 22 +-- src/activity_core/activities.py | 13 +- src/activity_core/models.py | 8 + src/activity_core/rules/actions.py | 153 ++++++++++++++++ src/activity_core/workflows.py | 18 +- tests/rules/test_actions.py | 117 ++++++++++++ tests/test_integration_event_bridge.py | 29 ++- tests/test_rule_evaluation_activity.py | 40 +++++ ...-0006-post-triage-operational-hardening.md | 167 ++++++++++++++++++ workplans/ADHOC-2026-06-01.md | 32 +++- .../260603-WP-0002-next-steps.md} | 10 +- 12 files changed, 619 insertions(+), 81 deletions(-) create mode 100644 src/activity_core/rules/actions.py create mode 100644 tests/rules/test_actions.py create mode 100644 tests/test_rule_evaluation_activity.py create mode 100644 workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md rename workplans/{WP-0002-next-steps.md => archived/260603-WP-0002-next-steps.md} (93%) diff --git a/SCOPE.md b/SCOPE.md index c6743eb..7f838f9 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1,7 +1,7 @@ --- domain: capabilities repo: activity-core -updated: "2026-05-14" +updated: "2026-06-03" --- # SCOPE @@ -52,11 +52,17 @@ The two evaluation modes: - **Context resolution adapters**: repo-scoping (repository capability queries), state hub (domain and workstream state), extensible for other sources. - **Rule evaluator**: sandboxed AST walker for Python-like boolean expressions - over event attributes and resolved context. No `exec()`. + over event attributes and resolved context. Rule actions support safe + `context.*` / `event.*` interpolation and explicit `for_each` per-item + binding. No `exec()`. - **Instruction executor**: trusted-field prompt rendering, LLM call via - llm-connect, structured output validation, optional curator review queue. + llm-connect, structured output validation, optional curator review queue, + and deterministic report sinks. - **Task emission adapter**: abstraction over issue-core; current transport is REST; designed to migrate to NATS subscription without code changes. +- **Report sinks**: instruction report outputs can be persisted to bounded + local working memory and posted as State Hub progress events. These are + reporting outputs, not task lifecycle ownership. - **Spawn audit log**: every task emission recorded with rule/instruction id, triggering event id, model and prompt hash (instructions), issue-core task ref. - **Webhook receiver**: HTTP endpoint normalising inbound Gitea/GitHub webhook @@ -111,16 +117,57 @@ The two evaluation modes: ## Current State -- **Status**: active — WP-0001 (Foundation) and WP-0002 (Triggers & Ops) complete. -- **Implementation**: core is functional. `RunActivityWorkflow`, `TaskExecutorWorkflow` - (stub), PostgreSQL schema (activity_definitions, activity_runs, task_instances), - Temporal Schedules (cron), NATS Event Router, FastAPI admin API, Prometheus - metrics, and operational runbook are all implemented. -- **Next**: WP-0003 — event type registry, rule/instruction model, task emission - adapter, webhook receiver, one-off `scheduled` trigger type, INTENT.md and - SCOPE.md rewrite (this file). Architecture established in ACT-ADR-001/002/003. -- **Stability**: core workflow is stable; the rule/instruction layer and registry - are not yet implemented. +- **Status**: active production-backed service. Foundation, triggers/ops, + event bridge, Railiance deployment, and the production service workplans are + complete. The stale March WP-0002 handoff note has been reconciled and + archived. +- **Implementation**: core is functional. `RunActivityWorkflow`, + `TaskExecutorWorkflow` (stub), PostgreSQL schema, Temporal Schedules, NATS + Event Router, FastAPI admin API, Prometheus metrics, event type registry, + markdown ActivityDefinition parser/sync, rule evaluator, instruction + executor, context resolvers, issue sink, report sinks, Kubernetes deployment, + and operational runbook are all implemented. +- **Operational proof**: the daily State Hub WSJF triage cutover has completed + far enough that activity-core is now the trusted scheduled substrate for the + routine report. Recent hardening fixed the State Hub SBOM resolver contract, + made slow LLM activity timeouts configurable, and added safe rule action + interpolation plus explicit `for_each` binding for per-repo SBOM staleness + tasks. +- **Stability**: construction risk has shifted to operational hardening risk. + The full test suite passed on 2026-06-03 (`125 passed, 1 skipped`). The + remaining work is mostly observability, status-canon adaptation, contract + documentation, and broader production adoption rather than first + implementation. +- **Next**: `ACTIVITY-WP-0006` — post-triage operational hardening and scope + alignment. + +--- + +## Assessment Against Intent + +activity-core now matches the core intent: it answers **when** coordination +work should happen, **what** work should be created from current org context, +and **where** each work item should land. The daily WSJF triage is the clearest +judgement-oriented proof point; weekly SBOM staleness is the clearest +deterministic-rule proof point. + +The governing boundary still matters. activity-core should keep owning trigger +durability, context resolution, rule/instruction evaluation, report/task +emission, and spawn/report audit. It should not become the task lifecycle +database, the project planner, or a general execution worker. The local +`TaskExecutorWorkflow` remains a stub and should stay that way unless a future +workplan explicitly rehomes execution responsibility. + +One boundary nuance is now explicit: activity-core may post State Hub progress +events as a configured report sink. That is acceptable because it records the +result of an activity-core activation; it is not ownership of State Hub state, +task lifecycle, or workstream planning. + +The main drift risk is convenience creep: adding direct task tracking, +project-phase state, or bespoke operational scripts because the Temporal +substrate is already nearby. Future work should prefer declarative +ActivityDefinitions, bounded context resolvers, and outbound adapters over +new one-off control paths. --- @@ -130,20 +177,19 @@ The two evaluation modes: [NATS JetStream] ← publishers: state hub, Gitea webhooks, Temporal signals, cron ↓ [activity-core] ← event type registry, rule evaluator, instruction executor - ↓ -[issue-core] ← task lifecycle, assignment, tracking (Gitea / SQLite / GitHub) - ↓ -[repos/services] ← execution: actual code changes, scans, operations +[activity-core] → [issue-core] → [repos/services] +[activity-core] → [report sinks] ``` - **Upstream**: NATS (event bus), Temporal (durable workflow engine), PostgreSQL (definitions and audit log), repo-scoping (context adapter), state hub (context adapter and event publisher). -- **Downstream**: issue-core (task management). Agents and humans pick up tasks - from issue-core and do the actual work. +- **Downstream**: issue-core (task management) and configured report sinks. + Agents and humans pick up tasks from issue-core and do the actual work. - **Coordinates with**: the state hub delegates maintenance automations to - activity-core by publishing lifecycle events; activity-core never writes to - the state hub directly. + activity-core by publishing lifecycle events or by being resolved as context. + activity-core may post progress events as report outputs, but it does not own + State Hub task/workstream state. --- @@ -203,8 +249,7 @@ The two evaluation modes: `src/activity_core/event_router.py` (NATS → Temporal), `src/activity_core/schedule_manager.py` (Temporal Schedules), `src/activity_core/api.py` (FastAPI admin). -- Definition files (WP-0003): `event-types/` and `activity-definitions/` - (not yet created — coming in WP-0003). +- Definition files: `event-types/`, `activity-definitions/`, and `tasks/`. - Dev environment: `docker-compose.dev.yml` (Temporal + PostgreSQL + NATS). - Entry points: `uv run python -m activity_core.worker` (Temporal worker), `uv run uvicorn activity_core.api:app --port 8010` (admin API). diff --git a/activity-definitions/weekly-sbom-staleness.md b/activity-definitions/weekly-sbom-staleness.md index c3a5a46..292543d 100644 --- a/activity-definitions/weekly-sbom-staleness.md +++ b/activity-definitions/weekly-sbom-staleness.md @@ -28,21 +28,17 @@ SBOM staleness and flags any repository whose SBOM is older than 30 days. ```rule id: flag-stale-sbom -condition: 'context.repos.sbom_age_days > 30' +for_each: context.repos.repos +bind_as: repo +condition: 'context.repo.sbom_age_days > 30' action: - task_template: tasks/sbom-rescan.md - target_repo: context.repos.repo_slug + task_template: Run SBOM rescan for {context.repo.repo_slug} + target_repo: context.repo.repo_slug priority: medium labels: ["sbom", "security", "automated"] ``` -NOTE: in the production bulk-mode resolver path the condition matches against -the **worst** repo's age (the resolver hoists the worst entry's -`sbom_age_days`, `repo_slug`, `last_sbom_at`, `has_sbom` to the top of -`context.repos` alongside the per-repo list and summary counts). The rule -therefore fires at most once per workflow run, not once per stale repo. The -aspirational per-stale-repo task fan-out is exercised by the integration -tests' simulated pipeline but is not delivered by the current workflow — -landing it requires (a) per-iteration context binding in the workflow and -(b) `context.*` interpolation in rule action fields. Both are tracked as -`ADHOC-2026-06-01-T02`. +The bulk resolver exposes the per-repo entries under `context.repos.repos`. +The rule uses explicit `for_each` binding so the workflow evaluates the +condition once per repository and emits one task per stale repo. Action fields +may reference the bound item with `context.repo.*`. diff --git a/src/activity_core/activities.py b/src/activity_core/activities.py index 3812904..98a65fb 100644 --- a/src/activity_core/activities.py +++ b/src/activity_core/activities.py @@ -24,10 +24,10 @@ 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.rules import evaluate_condition from activity_core.llm_client import get_llm_client from activity_core.models import InstructionDef 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 @@ -241,9 +241,8 @@ async def persist_task_instance(task_payload: dict) -> str: @activity.defn async def evaluate_rules(payload: dict) -> list[dict]: - """Evaluate each rule condition against the event and context. + """Evaluate rules and render matching actions as task specs. - Returns the list of matching rule dicts (those whose condition is True). Rules that raise UnsafeExpression or any other error are skipped and logged. Expected keys in payload: @@ -268,18 +267,16 @@ async def evaluate_rules(payload: dict) -> list[dict]: event_obj = _Env(event_attrs) - matched: list[dict] = [] + task_specs: list[dict] = [] for rule in rules: - condition = rule.get("condition", "") try: - if evaluate_condition(condition, event_obj, context): - matched.append(rule) + 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 matched + return task_specs @activity.defn diff --git a/src/activity_core/models.py b/src/activity_core/models.py index fad6c96..72e9b85 100644 --- a/src/activity_core/models.py +++ b/src/activity_core/models.py @@ -92,6 +92,14 @@ class ActionDef(BaseModel): class RuleDef(BaseModel): id: str + for_each: str | None = Field( + default=None, + description="Optional event/context path to a list for per-item rule expansion.", + ) + bind_as: str = Field( + default="item", + description="Context key used for each item when for_each is set.", + ) condition: str = Field( default="", description="Rule DSL expression; empty string means always true.", diff --git a/src/activity_core/rules/actions.py b/src/activity_core/rules/actions.py new file mode 100644 index 0000000..d1c8a5b --- /dev/null +++ b/src/activity_core/rules/actions.py @@ -0,0 +1,153 @@ +"""Rule action expansion into concrete task specs. + +Boundary: no imports from temporalio, sqlalchemy, fastapi, or any +activity_core.* module outside rules/. +""" + +from __future__ import annotations + +import re +from dataclasses import asdict +from typing import Any + +from activity_core.rules.evaluator import UnsafeExpression, evaluate_condition +from activity_core.rules.models import TaskSpec + +_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}") +_PATH_RE = re.compile(r"^(event|context)(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$") + + +def expand_rule_actions(rules: list[dict], event: Any, context: dict) -> list[dict]: + """Evaluate rule conditions and render matching actions as TaskSpec dicts. + + A rule can opt into per-item expansion with ``for_each``: + + for_each: context.repos.repos + bind_as: repo + + Each list item is then available as ``context.repo`` while rendering the + condition and action fields. Without ``for_each``, a rule is evaluated once + against the original context. + """ + task_specs: list[dict] = [] + for rule in rules: + for bound_context in _iteration_contexts(rule, event, context): + if not _condition_matches(rule, event, bound_context): + continue + task_specs.append(_task_spec_for_rule(rule, event, bound_context)) + return task_specs + + +def _iteration_contexts(rule: dict, event: Any, context: dict) -> list[dict]: + for_each = rule.get("for_each") + if not for_each: + return [context] + if not isinstance(for_each, str) or not _PATH_RE.fullmatch(for_each): + raise UnsafeExpression(f"invalid for_each path: {for_each!r}") + + values = _resolve_field(for_each, event, context) + if values is None: + return [] + if not isinstance(values, list): + raise UnsafeExpression(f"for_each path does not resolve to a list: {for_each!r}") + + bind_as = rule.get("bind_as", "item") + if not isinstance(bind_as, str) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", bind_as): + raise UnsafeExpression(f"invalid bind_as name: {bind_as!r}") + + contexts: list[dict] = [] + for value in values: + bound = dict(context) + bound[bind_as] = value + contexts.append(bound) + return contexts + + +def _condition_matches(rule: dict, event: Any, context: dict) -> bool: + return evaluate_condition(rule.get("condition", ""), event, context) + + +def _task_spec_for_rule(rule: dict, event: Any, context: dict) -> dict: + action = rule.get("action", {}) + spec = TaskSpec( + title=str(_render_value(action.get("task_template", rule.get("id", "")), event, context) or ""), + description=str(_render_value(action.get("description", ""), event, context) or ""), + target_repo=_string_or_none(_render_value(action.get("target_repo"), event, context)), + priority=str(_render_value(action.get("priority", "medium"), event, context) or "medium"), + labels=_render_labels(action.get("labels", []), event, context), + due_in_days=_int_or_none(_render_value(action.get("due_in_days"), event, context)), + source_type="rule", + source_id=rule.get("id", ""), + ) + result = asdict(spec) + result["condition"] = rule.get("condition", "") + return result + + +def _render_labels(value: Any, event: Any, context: dict) -> list[str]: + if not isinstance(value, list): + return [] + rendered = [] + for item in value: + rendered_item = _render_value(item, event, context) + if rendered_item is not None: + rendered.append(str(rendered_item)) + return rendered + + +def _render_value(value: Any, event: Any, context: dict) -> Any: + if isinstance(value, str): + if _PATH_RE.fullmatch(value): + return _resolve_field(value, event, context) + if "{" in value and "}" in value: + return _PLACEHOLDER_RE.sub( + lambda match: _string_or_empty( + _resolve_field(match.group(1), event, context) + ), + value, + ) + return value + + +def _resolve_field(field_path: str, event: Any, context: dict) -> Any: + if not _PATH_RE.fullmatch(field_path): + raise UnsafeExpression(f"invalid field path: {field_path!r}") + root, tail = field_path.split(".", 1) + if root == "event": + return _resolve_path(event, tail) + return _resolve_path(context, tail) + + +def _resolve_path(obj: Any, path: str) -> Any: + current = obj + for part in path.split("."): + if current is None: + return None + if isinstance(current, dict): + current = current.get(part) + else: + current = getattr(current, part, None) + return current + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def _string_or_empty(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + raise UnsafeExpression("template placeholder resolved to a non-scalar value") + return str(value) + + +def _int_or_none(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError) as exc: + raise UnsafeExpression(f"field cannot be converted to int: {value!r}") from exc diff --git a/src/activity_core/workflows.py b/src/activity_core/workflows.py index afc84ae..ee60bc8 100644 --- a/src/activity_core/workflows.py +++ b/src/activity_core/workflows.py @@ -114,7 +114,7 @@ class RunActivityWorkflow: except Exception: pass - matched_rules: list[dict] = await workflow.execute_activity( + task_spec_dicts: list[dict] = await workflow.execute_activity( evaluate_rules, { "rules": defn.get("rules", []), @@ -125,22 +125,6 @@ class RunActivityWorkflow: retry_policy=_RETRY_POLICY, ) - # 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", ""), - }) - report_dicts: list[dict] = [] if defn.get("instructions"): instruction_result: dict = await workflow.execute_activity( diff --git a/tests/rules/test_actions.py b/tests/rules/test_actions.py new file mode 100644 index 0000000..c4b48e0 --- /dev/null +++ b/tests/rules/test_actions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import pytest + +from activity_core.rules.actions import expand_rule_actions +from activity_core.rules.evaluator import UnsafeExpression + + +class _Attrs: + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + + +class _Event: + def __init__(self, **attrs): + self.attributes = _Attrs(**attrs) + + +def test_action_field_path_interpolation_resolves_context_value() -> None: + rules = [ + { + "id": "flag-stale-sbom", + "condition": "context.repos.sbom_age_days > 30", + "action": { + "task_template": "Run SBOM rescan for {context.repos.repo_slug}", + "target_repo": "context.repos.repo_slug", + "priority": "medium", + "labels": ["sbom", "{context.repos.repo_slug}"], + }, + } + ] + + specs = expand_rule_actions( + rules, + _Event(), + {"repos": {"repo_slug": "activity-core", "sbom_age_days": 45}}, + ) + + assert specs == [ + { + "title": "Run SBOM rescan for activity-core", + "description": "", + "target_repo": "activity-core", + "priority": "medium", + "labels": ["sbom", "activity-core"], + "due_in_days": None, + "source_type": "rule", + "source_id": "flag-stale-sbom", + "triggering_event_id": "", + "activity_definition_id": "", + "condition": "context.repos.sbom_age_days > 30", + } + ] + + +def test_for_each_binds_each_list_item_before_condition_and_action_rendering() -> None: + rules = [ + { + "id": "flag-stale-sbom", + "for_each": "context.repos.repos", + "bind_as": "repo", + "condition": "context.repo.sbom_age_days > 30", + "action": { + "task_template": "Run SBOM rescan for {context.repo.repo_slug}", + "target_repo": "context.repo.repo_slug", + "priority": "medium", + "labels": ["sbom", "security", "automated"], + }, + } + ] + context = { + "repos": { + "repos": [ + {"repo_slug": "repo-a", "sbom_age_days": 60}, + {"repo_slug": "repo-b", "sbom_age_days": 10}, + {"repo_slug": "repo-c", "sbom_age_days": 45}, + ] + } + } + + specs = expand_rule_actions(rules, _Event(), context) + + assert [spec["target_repo"] for spec in specs] == ["repo-a", "repo-c"] + assert [spec["title"] for spec in specs] == [ + "Run SBOM rescan for repo-a", + "Run SBOM rescan for repo-c", + ] + + +def test_for_each_rejects_non_path_expression() -> None: + rules = [ + { + "id": "bad", + "for_each": "__import__('os')", + "condition": "", + "action": {"task_template": "bad"}, + } + ] + + with pytest.raises(UnsafeExpression): + expand_rule_actions(rules, _Event(), {}) + + +def test_template_placeholder_rejects_non_scalar_values() -> None: + rules = [ + { + "id": "bad", + "condition": "", + "action": { + "task_template": "Run {context.repos}", + }, + } + ] + + with pytest.raises(UnsafeExpression): + expand_rule_actions(rules, _Event(), {"repos": [{"repo_slug": "repo-a"}]}) diff --git a/tests/test_integration_event_bridge.py b/tests/test_integration_event_bridge.py index e44d57c..19bcbb8 100644 --- a/tests/test_integration_event_bridge.py +++ b/tests/test_integration_event_bridge.py @@ -20,7 +20,7 @@ import pytest from activity_core.definition_parser import parse_file from activity_core.issue_sink import NullSink from activity_core.models import EventEnvelope -from activity_core.rules.evaluator import evaluate_condition +from activity_core.rules.actions import expand_rule_actions from activity_core.rules.models import TaskRef, TaskSpec _DEFINITIONS_DIR = Path(__file__).parent.parent / "activity-definitions" @@ -59,27 +59,24 @@ def _run_rule_pipeline( spawn_log: list[dict] = [] triggering_event_id = str(uuid.uuid4()) - for repo in repos: - context = {"repos": repo} - if not evaluate_condition(rule["condition"], event, context): - continue - - action = rule.get("action", {}) + context = {"repos": {"repos": repos}} + for spec_dict in expand_rule_actions([rule], event, context): spec = TaskSpec( - title=f"Run SBOM rescan — {repo['repo_slug']}", - description="SBOM rescan needed — age threshold exceeded.", - target_repo=repo["repo_slug"], - priority=action.get("priority", "medium"), - labels=action.get("labels", []), + title=spec_dict["title"], + description=spec_dict["description"], + target_repo=spec_dict["target_repo"], + priority=spec_dict["priority"], + labels=spec_dict["labels"], + due_in_days=spec_dict["due_in_days"], source_type="rule", - source_id=rule["id"], + source_id=spec_dict["source_id"], triggering_event_id=triggering_event_id, ) ref = sink.emit(spec) task_refs.append(ref) spawn_log.append({ - "source_id": rule["id"], - "condition_matched": rule["condition"], + "source_id": spec_dict["source_id"], + "condition_matched": spec_dict["condition"], "triggering_event_id": triggering_event_id, "task_ref": ref.external_id, }) @@ -121,7 +118,7 @@ def test_pipeline_emits_one_task_for_stale_repo_only(): assert len(spawn_log) == 1 entry = spawn_log[0] assert entry["source_id"] == "flag-stale-sbom" - assert entry["condition_matched"] == "context.repos.sbom_age_days > 30" + assert entry["condition_matched"] == "context.repo.sbom_age_days > 30" assert entry["triggering_event_id"] == spawn_log[0]["triggering_event_id"] diff --git a/tests/test_rule_evaluation_activity.py b/tests/test_rule_evaluation_activity.py new file mode 100644 index 0000000..ca047b9 --- /dev/null +++ b/tests/test_rule_evaluation_activity.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest + +from activity_core import activities + + +@pytest.mark.asyncio +async def test_evaluate_rules_returns_interpolated_task_specs() -> None: + result = await activities.evaluate_rules({ + "rules": [ + { + "id": "flag-stale-sbom", + "for_each": "context.repos.repos", + "bind_as": "repo", + "condition": "context.repo.sbom_age_days > 30", + "action": { + "task_template": "Run SBOM rescan for {context.repo.repo_slug}", + "target_repo": "context.repo.repo_slug", + "priority": "medium", + "labels": ["sbom", "{context.repo.repo_slug}"], + }, + } + ], + "event": {}, + "context": { + "repos": { + "repos": [ + {"repo_slug": "fresh-repo", "sbom_age_days": 5}, + {"repo_slug": "stale-repo", "sbom_age_days": 40}, + ] + } + }, + }) + + assert len(result) == 1 + assert result[0]["title"] == "Run SBOM rescan for stale-repo" + assert result[0]["target_repo"] == "stale-repo" + assert result[0]["labels"] == ["sbom", "stale-repo"] + assert result[0]["condition"] == "context.repo.sbom_age_days > 30" diff --git a/workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md b/workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md new file mode 100644 index 0000000..6e0db49 --- /dev/null +++ b/workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md @@ -0,0 +1,167 @@ +--- +id: ACTIVITY-WP-0006 +type: workplan +title: "Post-triage operational hardening" +domain: custodian +repo: activity-core +status: ready +owner: codex +topic_slug: custodian +created: "2026-06-03" +updated: "2026-06-03" +--- + +# ACTIVITY-WP-0006 — Post-triage operational hardening + +## Context + +activity-core has crossed the main construction threshold: Temporal-backed +schedules, context resolution, deterministic rules, LLM instructions, report +sinks, and the Railiance production service are implemented. The daily State +Hub WSJF triage cutover is now trusted enough that activity-core can be treated +as the standing scheduled substrate rather than an experiment. + +The next work should keep that substrate dependable and aligned with +`INTENT.md`: activity-core owns when coordination work runs, what task/report +outputs are produced, and where they are emitted. It must not grow into the +task lifecycle database, a project planner, or an execution worker. + +## Task Status Canon Adaptation + +```task +id: ACTIVITY-WP-0006-T01 +status: todo +priority: high +``` + +Adapt activity-core to State Hub's task status canon: +`wait`, `todo`, `progress`, `done`, `cancel`. + +Scope: +- update `AGENTS.md` task-status examples and progression text +- update State Hub context resolver task-status filters and digest counters +- keep workstream/workplan lifecycle status separate; `blocked` remains valid + for workstreams/workplans where State Hub still uses it +- update tests that fixture or assert `in_progress` / task-level `blocked` +- resolve the State Hub interface-change notice only after the repo is adapted + +Done when the full test suite passes and activity-core no longer depends on +legacy task-status aliases for State Hub API clients or tests. + +## Daily Triage Observability Runbook + +```task +id: ACTIVITY-WP-0006-T02 +status: todo +priority: high +``` + +Document and, where cheap, automate how to answer "did today's daily triage +run happen?" + +The operator should be able to check: +- Temporal schedule state and latest workflow history +- `activity_runs` row for the daily triage ActivityDefinition +- State Hub `daily_triage` progress event +- working-memory report note +- expected missed-run behavior (`skip`, not catch-up) +- the configured LLM and Temporal timeout relationship + +Done when `docs/runbook.md` has a concise daily-triage verification section +and any helper command/script is covered by tests or a dry-run path. + +## Three-Run Calibration Feedback + +```task +id: ACTIVITY-WP-0006-T03 +status: todo +priority: medium +``` + +Collect three consecutive scheduled activity-core daily triage runs and feed +the result back into the Custodian WSJF calibration loop. + +Assess: +- whether the top recommendations matched actual useful follow-up work +- report length and density +- loose-end detection sensitivity +- stale-but-intentionally-parked work handling +- whether model settings or prompt/schema constraints need adjustment + +Done when the calibration result is recorded in State Hub and the related +`CUST-WP-0044` / `CUST-WP-0045` tasks can close based on activity-core runs, +not Codex app fallback runs. + +## Rule Action Contract Documentation + +```task +id: ACTIVITY-WP-0006-T04 +status: todo +priority: medium +``` + +Document the rule action contract introduced by the ADHOC-2026-06-01 work: +whole-field `context.*` / `event.*` paths, scalar `{context.foo}` placeholders, +and explicit `for_each` / `bind_as` per-item expansion. + +Also decide and document the naming/semantics mismatch around +`action.task_template`: today it is the emitted task title field, while +`tasks/*.md` contains template files with their own title templates. + +Done when ADR-003 or a focused follow-up doc contains examples, unsafe cases, +and the weekly SBOM staleness definition is cited as the canonical pattern. + +## Production Alerting And Failure Modes + +```task +id: ACTIVITY-WP-0006-T05 +status: todo +priority: medium +``` + +Turn the current confidence in the daily triage schedule into routine +operational visibility. + +Cover: +- Kubernetes/Temporal worker health expectations +- schedule paused/missing detection +- report sink failure behavior +- LLM timeout and retry behavior +- what should page, what should only leave a progress note, and what should be + handled in the next operator session + +Done when the runbook and metrics/health surface make ordinary failures visible +without inspecting a Codex Desktop session. + +## Issue-Core Emission Boundary Verification + +```task +id: ACTIVITY-WP-0006-T06 +status: todo +priority: medium +``` + +Verify the downstream task emission boundary now that rule fan-out is real. + +Questions to close: +- which issue-core endpoint is authoritative for task creation in the current + environment +- whether `IssueCoreRestSink` should keep using REST or move to the intended + NATS subscription path +- whether emitted rule tasks carry enough title, description, labels, + source id, condition, and target repo data for issue-core and operators +- whether weekly SBOM staleness can be safely enabled against the real sink + +Done when there is a tested or dry-run-verified path from a rule match to a +downstream task reference, and activity-core still owns only the spawn audit +trail, not task lifecycle state. + +## Completion Criteria + +- State Hub task-status canon adaptation is complete. +- Daily triage has an operator-grade verification path and three-run + calibration evidence. +- Rule action semantics are documented and no longer surprising. +- Production failure modes are observable enough for routine operation. +- Downstream task emission has been verified without expanding activity-core's + ownership boundary. diff --git a/workplans/ADHOC-2026-06-01.md b/workplans/ADHOC-2026-06-01.md index 1c2c716..70cf6fc 100644 --- a/workplans/ADHOC-2026-06-01.md +++ b/workplans/ADHOC-2026-06-01.md @@ -4,11 +4,11 @@ type: workplan title: "Ad hoc — activity-core opportunistic fixes 2026-06-01" domain: custodian repo: activity-core -status: ready +status: finished owner: custodian topic_slug: custodian created: "2026-06-01" -updated: "2026-06-01" +updated: "2026-06-03" state_hub_workstream_id: "36162ff0-9b47-47c4-8602-56767f9b7a1c" --- @@ -112,7 +112,7 @@ worst-repo fields to the top of `context.repos`). ```task id: ADHOC-2026-06-01-T02 -status: todo +status: done priority: low state_hub_task_id: "6b3a185e-cbea-454c-82fb-8b4c16cefef0" ``` @@ -168,6 +168,32 @@ expressions and a stale-repo workflow run emits a TaskSpec with the actual repo slug, or (b) a recorded decision explicitly defers/declines the change with reasoning. +**Completion — 2026-06-03:** + +Implemented explicit rule action expansion in `activity_core.rules.actions`. +`evaluate_rules` now returns concrete TaskSpec dictionaries directly, and +`RunActivityWorkflow` no longer lifts raw YAML action fields itself. + +Action fields support two safe interpolation forms: +- whole-field paths such as `target_repo: context.repo.repo_slug` +- scalar placeholders such as `task_template: Run SBOM rescan for {context.repo.repo_slug}` + +Rules may opt into per-item binding with: + +```yaml +for_each: context.repos.repos +bind_as: repo +condition: 'context.repo.sbom_age_days > 30' +``` + +`activity-definitions/weekly-sbom-staleness.md` now uses that explicit +contract, so bulk SBOM staleness evaluation emits one task per stale repo +instead of one task for the hoisted worst repo. Tests cover direct action +interpolation, `for_each` binding, activity-level rule evaluation, and the +weekly SBOM integration path. + +Tests: `PYTHONPATH=src .venv/bin/python -m pytest -q` -> 125 passed, 1 skipped. + ### T03 - Make activity-core's Temporal activity timeout env-configurable ```task diff --git a/workplans/WP-0002-next-steps.md b/workplans/archived/260603-WP-0002-next-steps.md similarity index 93% rename from workplans/WP-0002-next-steps.md rename to workplans/archived/260603-WP-0002-next-steps.md index 794a2f0..c78cd45 100644 --- a/workplans/WP-0002-next-steps.md +++ b/workplans/archived/260603-WP-0002-next-steps.md @@ -1,11 +1,19 @@ --- type: session-note created: "2026-03-28" -status: handoff +updated: "2026-06-03" +status: archived --- # WP-0002 Handoff Note — Continue on CoulombCore +## Archive note — 2026-06-03 + +This handoff note has been reconciled and archived. Its remaining build order +is superseded by `custodian-WP-0002-triggers-ops.md`, which is marked done, and +by later completed workplans for the event bridge, Railiance operations, and +production service. It is no longer an active source of next steps. + ## Context Implementing custodian-WP-0002 (Triggers & Ops). Work interrupted on workstation