generated from coulomb/repo-seed
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.
This commit is contained in:
91
SCOPE.md
91
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).
|
||||
|
||||
@@ -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.*`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
153
src/activity_core/rules/actions.py
Normal file
153
src/activity_core/rules/actions.py
Normal file
@@ -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
|
||||
@@ -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(
|
||||
|
||||
117
tests/rules/test_actions.py
Normal file
117
tests/rules/test_actions.py
Normal file
@@ -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"}]})
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
40
tests/test_rule_evaluation_activity.py
Normal file
40
tests/test_rule_evaluation_activity.py
Normal file
@@ -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"
|
||||
167
workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md
Normal file
167
workplans/ACTIVITY-WP-0006-post-triage-operational-hardening.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user