diff --git a/activity-definitions/repo-manager-bounded-freshness.md b/activity-definitions/repo-manager-bounded-freshness.md new file mode 100644 index 0000000..277b3a9 --- /dev/null +++ b/activity-definitions/repo-manager-bounded-freshness.md @@ -0,0 +1,63 @@ +--- +id: repo-manager-bounded-freshness +name: Repo Manager Bounded Freshness +type: activity-definition +version: "0.1" +enabled: false +owner: repo-manager +governance: custodian +status: proposed +trigger: + type: cron + cron_expression: "40 */2 * * *" + timezone: UTC + misfire_policy: skip +context_sources: + - type: repo-manager + query: freshness_run + operation: repo_manager_freshness + required: true + params: + limit: 6 + max_seconds: 240 + apply: true + bind_to: context.freshness +--- + +# Repo Manager Bounded Freshness + +Draft from RMGR-WP-0020-T03, tracked in ACTIVITY-WP-0044. **Not activated.** +`enabled: false` prevents any Temporal schedule; the founder flips it only after +the RMGR-WP-0020-T04 activation steps and T05 proof. + +Each fire runs one bounded operation `repo_manager_freshness` +(ACT-ADR-007 registry), which invokes the Repo Manager CLI with a code-fixed argv: + +```text +rmgr freshness run --limit 6 --max-seconds 240 +``` + +(`--dry-run` is appended when `apply: false`.) Repo Manager owns repository +selection, Forge HEAD resolution, reconciliation from immutable Forge commits +and the durable `repo-manager.freshness-receipt.v1` receipts. Activity Core +persists only a counted projection (receipt count per outcome, exit code). + +- Every 2 h at :40 UTC; 141 repositories / 6 per fire x 12 fires/day is about a + two-day rotation. Target SLA once enabled: every admissible repository has a + receipt younger than 48 h. +- Single Temporal attempt, timeout 300 s (240 s CLI budget + grace); misfires + are skipped, never replayed. +- No workstation checkout reads (ADR-002), no legacy `--all --remote` sweep, no + `for_each` task spawning; the rule below never emits tasks. + +Rollback: set `enabled: false` (or `status: paused`). + +```rule +id: repo-manager-bounded-freshness-no-tasks +condition: "False" +action: + task_template: Repo Manager freshness (operation-only; never rule-emitted) + description: The bounded operation does the work; this rule never emits tasks. + priority: low + labels: ["automated", "freshness"] +``` diff --git a/docs/platform-recovery-scheduling-plan.md b/docs/platform-recovery-scheduling-plan.md index cbabb26..a5ce0a3 100644 --- a/docs/platform-recovery-scheduling-plan.md +++ b/docs/platform-recovery-scheduling-plan.md @@ -25,7 +25,8 @@ schedule, retention action, or production rollout follows from role acceptance. Scaleway physical recovery or Forgejo archive recovery evidence. Its old host kubeconfig names and RPO wording are not proof of current cluster routing or an accepted guarantee; review those before reusing the wiring. -- `src/activity_core/bounded_operations.py` permits exactly three operations. +- `src/activity_core/bounded_operations.py` permits exactly four operations + (the fourth, `repo_manager_freshness`, backs a disabled draft; ACTIVITY-WP-0044). CNPG backup has one Temporal attempt because no durable remote idempotency receipt is available. New restore dispatch must not inherit retry defaults. - The stale-token blocker was resolved under RPF-WP-0045. Read-only inspection diff --git a/src/activity_core/activities.py b/src/activity_core/activities.py index 0db2127..536ce43 100644 --- a/src/activity_core/activities.py +++ b/src/activity_core/activities.py @@ -254,6 +254,10 @@ async def execute_bounded_operation(payload: dict[str, Any]) -> dict[str, Any]: from activity_core.context_resolvers.cnpg_backup import cnpg_option_a_backup result = cnpg_option_a_backup(params) + elif spec.operation_id == "repo_manager_freshness": + from activity_core.context_resolvers.repo_manager import repo_manager_freshness + + result = repo_manager_freshness(params) else: raise ApplicationError( f"bounded operation {spec.operation_id!r} uses a dedicated activity", diff --git a/src/activity_core/bounded_operations.py b/src/activity_core/bounded_operations.py index 32a97b2..63d5a8b 100644 --- a/src/activity_core/bounded_operations.py +++ b/src/activity_core/bounded_operations.py @@ -60,6 +60,19 @@ _SPECS = ( credential_route="railiance-cnpg-option-a-backup", evidence_mode="context-evidence-sink", ), + # RMGR-WP-0020-T03 / ACTIVITY-WP-0044: Repo Manager owns the freshness + # engine; Activity Core only invokes its fixed CLI with bounded arguments. + BoundedOperationSpec( + operation_id="repo_manager_freshness", + source_type="repo-manager", + query="freshness_run", + resolve_before_execute=False, + max_timeout_seconds=300, + temporal_max_attempts=1, + idempotency="single Temporal attempt; per-commit projections are idempotent (RMGR-WP-0017)", + credential_route="repo-manager-freshness-runner", + evidence_mode="repo-manager-freshness-receipt", + ), ) BOUNDED_OPERATION_REGISTRY = {spec.operation_id: spec for spec in _SPECS} @@ -80,6 +93,12 @@ _CANONICAL_SCRIPT_PATHS = { "forgejo_package_prune": "/opt/railiance-platform/tools/cmd/forgejo-package-prune", "cnpg_option_a_backup": "/opt/railiance-platform/tools/cmd/cnpg-option-a-backup", } +# repo-manager.freshness-receipt.v1 outcomes (RMGR-WP-0020-T01). +REPO_MANAGER_FRESHNESS_OUTCOMES = ( + "applied", "noop", "queued", "failed", "refused", "skipped", +) +REPO_MANAGER_FRESHNESS_MAX_LIMIT = 12 +REPO_MANAGER_FRESHNESS_MAX_SECONDS = 270 _TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$") @@ -136,6 +155,26 @@ def normalize_operation_result(operation_id: str, raw: Any) -> dict[str, Any]: "failed": _safe_count(result.get("failed")), "script_exit_code": exit_code, } + if operation_id == "repo_manager_freshness": + receipts = result.get("receipts") + outcomes = {name: 0 for name in REPO_MANAGER_FRESHNESS_OUTCOMES} + if isinstance(receipts, list): + for receipt in receipts: + outcome = receipt.get("outcome") if isinstance(receipt, dict) else None + if outcome in outcomes: + outcomes[outcome] += 1 + exit_code = result.get("exit_code", 0) + if isinstance(exit_code, bool) or not isinstance(exit_code, int): + exit_code = 1 + return { + "kind": operation_id, + "apply": bool(result.get("apply")), + "limit": _safe_count(result.get("limit")), + "max_seconds": _safe_count(result.get("max_seconds")), + "receipt_count": len(receipts) if isinstance(receipts, list) else 0, + "outcomes": outcomes, + "exit_code": exit_code, + } raise ValueError(f"no result projection for bounded operation {operation_id!r}") @@ -192,6 +231,8 @@ def validate_bounded_operations( _validate_prune(params) elif spec.operation_id == "cnpg_option_a_backup": _validate_backup(params) + elif spec.operation_id == "repo_manager_freshness": + _validate_repo_manager_freshness(params) def _validate_timeout(spec: BoundedOperationSpec, params: dict[str, Any]) -> None: @@ -259,6 +300,36 @@ def _validate_backup(params: dict[str, Any]) -> None: _require_context_evidence(params, "cnpg_option_a_backup") +def _validate_repo_manager_freshness(params: dict[str, Any]) -> None: + _explicit_bool(params, "apply", "repo_manager_freshness") + limit = params.get("limit") + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= REPO_MANAGER_FRESHNESS_MAX_LIMIT + ): + raise ValueError( + "bounded operation repo_manager_freshness limit must be in " + f"1..{REPO_MANAGER_FRESHNESS_MAX_LIMIT}" + ) + max_seconds = params.get("max_seconds") + if ( + isinstance(max_seconds, bool) + or not isinstance(max_seconds, int) + or not 1 <= max_seconds <= REPO_MANAGER_FRESHNESS_MAX_SECONDS + ): + raise ValueError( + "bounded operation repo_manager_freshness max_seconds must be in " + f"1..{REPO_MANAGER_FRESHNESS_MAX_SECONDS}" + ) + for forbidden in ("command", "argv", "rmgr_bin", "script"): + if forbidden in params: + raise ValueError( + f"bounded operation repo_manager_freshness must not declare {forbidden}; " + "the CLI invocation is code-fixed" + ) + + def _validate_canonical_script( params: dict[str, Any], field: str, operation_id: str ) -> None: diff --git a/src/activity_core/context_resolvers/repo_manager.py b/src/activity_core/context_resolvers/repo_manager.py new file mode 100644 index 0000000..4c5620a --- /dev/null +++ b/src/activity_core/context_resolvers/repo_manager.py @@ -0,0 +1,63 @@ +"""Repo Manager bounded freshness invocation (RMGR-WP-0020-T03, ACTIVITY-WP-0044). + +Invokes the Repo Manager CLI ``rmgr freshness run --limit N --max-seconds S +[--dry-run]`` (RMGR-WP-0020-T02 interface). The argv is code-fixed: a +definition supplies only the validated ``limit``, ``max_seconds`` and ``apply`` +values. The executable is ``rmgr`` on PATH unless the worker sets +``ACTIVITY_CORE_RMGR_BIN``; definitions cannot choose it. Stdout is expected to +be a JSON object carrying ``receipts`` (``repo-manager.freshness-receipt.v1``); +only the counted projection in ``bounded_operations`` is persisted. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from typing import Any + +# Grace on top of the CLI's own --max-seconds budget for start-up/teardown. +_TIMEOUT_GRACE_SECONDS = 30 + + +def build_freshness_argv(params: dict[str, Any]) -> list[str]: + limit = int(params["limit"]) + max_seconds = int(params["max_seconds"]) + argv = [ + os.environ.get("ACTIVITY_CORE_RMGR_BIN", "rmgr"), + "freshness", + "run", + "--limit", + str(limit), + "--max-seconds", + str(max_seconds), + ] + if params.get("apply") is not True: + argv.append("--dry-run") + return argv + + +def repo_manager_freshness(params: dict[str, Any]) -> dict[str, Any]: + """Run one bounded freshness pass and return its JSON summary.""" + argv = build_freshness_argv(params) + timeout = int(params["max_seconds"]) + _TIMEOUT_GRACE_SECONDS + completed = subprocess.run( + argv, capture_output=True, text=True, timeout=timeout, check=False + ) + stdout = (completed.stdout or "").strip() + if completed.returncode != 0 and not stdout: + raise RuntimeError( + f"repo_manager_freshness failed (exit {completed.returncode}): " + f"{(completed.stderr or '').strip()[:300]}" + ) + try: + summary = json.loads(stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"repo_manager_freshness returned invalid JSON: {exc}") from exc + if not isinstance(summary, dict): + raise RuntimeError("repo_manager_freshness output must be a JSON object") + summary.setdefault("apply", params.get("apply") is True) + summary.setdefault("limit", int(params["limit"])) + summary.setdefault("max_seconds", int(params["max_seconds"])) + summary["exit_code"] = completed.returncode + return summary diff --git a/src/activity_core/ops_console.py b/src/activity_core/ops_console.py index 7a3418d..f42a08f 100644 --- a/src/activity_core/ops_console.py +++ b/src/activity_core/ops_console.py @@ -40,6 +40,7 @@ _AUDIT_BUFFER: deque[dict[str, Any]] = deque(maxlen=100) SIDE_EFFECT_MARKERS = ( "forgejo_package_prune", "cnpg_option_a_backup", + "repo_manager_freshness", "apply: true", '"apply": true', "'apply': true", diff --git a/tests/test_bounded_operations.py b/tests/test_bounded_operations.py index bbdf003..b09ce87 100644 --- a/tests/test_bounded_operations.py +++ b/tests/test_bounded_operations.py @@ -14,11 +14,13 @@ def _evidence_sinks(event_type: str) -> list[dict[str, str]]: return [{"type": "state-hub-progress", "event_type": event_type}] -def test_registry_names_only_the_three_accepted_operations() -> None: +def test_registry_names_only_the_accepted_operations() -> None: assert set(BOUNDED_OPERATION_REGISTRY) == { "sbom_nexus_ingest", "forgejo_package_prune", "cnpg_option_a_backup", + # ACTIVITY-WP-0044 / RMGR-WP-0020-T03 (draft definition, disabled) + "repo_manager_freshness", } diff --git a/tests/test_repo_manager_freshness_definition.py b/tests/test_repo_manager_freshness_definition.py new file mode 100644 index 0000000..65551fd --- /dev/null +++ b/tests/test_repo_manager_freshness_definition.py @@ -0,0 +1,170 @@ +"""Draft repo-manager-bounded-freshness definition (ACTIVITY-WP-0044 / RMGR-WP-0020-T03).""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.dialects import postgresql + +from activity_core import activities, sync_activity_definitions, sync_schedules +from activity_core.bounded_operations import ( + BOUNDED_OPERATION_REGISTRY, + normalize_operation_result, + operation_sources, + validate_bounded_operations, +) +from activity_core.context_resolvers import repo_manager +from activity_core.definition_parser import parse_file + +DEF_PATH = ( + Path(__file__).parent.parent / "activity-definitions" / "repo-manager-bounded-freshness.md" +) + + +def _source(**params): + base = {"limit": 6, "max_seconds": 240, "apply": True} + base.update(params) + return { + "type": "repo-manager", + "query": "freshness_run", + "operation": "repo_manager_freshness", + "required": True, + "params": base, + "bind_to": "context.freshness", + } + + +def test_definition_parses_disabled_with_reviewed_shape(): + d = parse_file(DEF_PATH) + assert d.id == "repo-manager-bounded-freshness" + assert d.enabled is False + tc = d.trigger_config + assert tc["trigger_type"] == "cron" + assert tc["cron_expression"] == "40 */2 * * *" + assert tc["timezone"] == "UTC" + assert tc["misfire_policy"] == "skip" + [(source, spec)] = operation_sources(d.context_sources) + assert spec.operation_id == "repo_manager_freshness" + assert source["params"] == {"limit": 6, "max_seconds": 240, "apply": True} + assert all(rule["condition"] == "False" for rule in d.rules) + + +def test_operation_is_registered_with_single_attempt_and_timeout(): + spec = BOUNDED_OPERATION_REGISTRY["repo_manager_freshness"] + assert spec.temporal_max_attempts == 1 + assert spec.resolve_before_execute is False + assert spec.max_timeout_seconds >= 240 + repo_manager._TIMEOUT_GRACE_SECONDS + + +@pytest.mark.parametrize( + "params,error", + [ + ({"limit": 0}, "limit"), + ({"limit": 13}, "limit"), + ({"limit": True}, "limit"), + ({"max_seconds": 271}, "max_seconds"), + ({"max_seconds": "240"}, "max_seconds"), + ({"apply": "yes"}, "apply"), + ({"command": "rm -rf /"}, "code-fixed"), + ({"rmgr_bin": "/tmp/x"}, "code-fixed"), + ], +) +def test_admission_fails_closed(params, error): + with pytest.raises(ValueError, match=error): + validate_bounded_operations([_source(**params)], []) + + +def test_argv_is_code_fixed(monkeypatch): + monkeypatch.delenv("ACTIVITY_CORE_RMGR_BIN", raising=False) + assert repo_manager.build_freshness_argv({"limit": 6, "max_seconds": 240, "apply": True}) == [ + "rmgr", "freshness", "run", "--limit", "6", "--max-seconds", "240", + ] + assert repo_manager.build_freshness_argv( + {"limit": 3, "max_seconds": 60, "apply": False} + )[-1] == "--dry-run" + + +def test_invocation_projects_counts_only(monkeypatch): + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs["timeout"])) + out = {"receipts": [ + {"repo": "a", "outcome": "applied", "detail": "must not persist"}, + {"repo": "b", "outcome": "noop"}, + {"repo": "c", "outcome": "refused"}, + {"repo": "d", "outcome": "bogus"}, + ]} + return subprocess.CompletedProcess(argv, 0, json.dumps(out), "") + + monkeypatch.setattr(repo_manager.subprocess, "run", fake_run) + raw = repo_manager.repo_manager_freshness({"limit": 6, "max_seconds": 240, "apply": True}) + projected = normalize_operation_result("repo_manager_freshness", raw) + assert calls[0][1] == 240 + repo_manager._TIMEOUT_GRACE_SECONDS + assert projected["receipt_count"] == 4 + assert projected["outcomes"]["applied"] == 1 + assert projected["outcomes"]["noop"] == 1 + assert projected["outcomes"]["refused"] == 1 + assert projected["apply"] is True and projected["exit_code"] == 0 + assert "must not persist" not in json.dumps(projected) + + +def test_invocation_fails_on_non_json(monkeypatch): + monkeypatch.setattr( + repo_manager.subprocess, "run", + lambda argv, **kw: subprocess.CompletedProcess(argv, 2, "", "boom"), + ) + with pytest.raises(RuntimeError, match="exit 2"): + repo_manager.repo_manager_freshness({"limit": 1, "max_seconds": 10, "apply": False}) + + +@pytest.mark.asyncio +async def test_execute_bounded_operation_dispatches_to_repo_manager(monkeypatch): + monkeypatch.setattr( + repo_manager, "repo_manager_freshness", + lambda params: {"receipts": [], "apply": False, "limit": 1, "max_seconds": 10}, + ) + patch = await activities.execute_bounded_operation({"source": _source(apply=False)}) + assert patch["freshness"]["kind"] == "repo_manager_freshness" + + +@pytest.mark.asyncio +async def test_sync_upserts_disabled_row_and_paused_schedule(monkeypatch): + d = parse_file(DEF_PATH) + monkeypatch.setattr(sync_activity_definitions, "scan_and_parse", lambda: [d]) + statements = [] + session = MagicMock() + session.execute = AsyncMock(side_effect=lambda stmt: statements.append(stmt)) + begin = MagicMock() + begin.__aenter__ = AsyncMock(return_value=None) + begin.__aexit__ = AsyncMock(return_value=False) + session.begin = MagicMock(return_value=begin) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=False) + + assert await sync_activity_definitions.sync(lambda: ctx) == 1 + params = statements[0].compile(dialect=postgresql.dialect()).params + assert params["enabled"] is False + assert params["id"] == sync_activity_definitions._definition_uuid(d.id) + + upserted = [] + + async def fake_upsert(client, defn): + upserted.append(defn) + + monkeypatch.setattr(sync_schedules, "upsert_schedule", fake_upsert) + monkeypatch.setattr(sync_schedules, "list_schedules", AsyncMock(return_value=[])) + row = SimpleNamespace( + id=params["id"], name=d.name, enabled=params["enabled"], + trigger_config=d.trigger_config, context_sources=d.context_sources, + task_templates=[], dedupe_key_strategy="skip", version=1, + ) + result = await sync_schedules.sync_schedule_rows(object(), [row]) + assert result.upserted == 0 and result.paused == 1 + assert upserted[0].enabled is False diff --git a/workplans/ACTIVITY-WP-0044-repo-manager-bounded-freshness-definition.md b/workplans/ACTIVITY-WP-0044-repo-manager-bounded-freshness-definition.md new file mode 100644 index 0000000..07a8908 --- /dev/null +++ b/workplans/ACTIVITY-WP-0044-repo-manager-bounded-freshness-definition.md @@ -0,0 +1,72 @@ +--- +id: ACTIVITY-WP-0044 +type: workplan +title: "Repo Manager bounded freshness ActivityDefinition (RMGR-WP-0020-T03)" +domain: infotech +repo: activity-core +status: active +owner: claude-activity-core +topic_slug: activity-core +created: "2026-10-04" +updated: "2026-10-04" +flavor: implementation +related: [RMGR-WP-0020, STATE-WP-0094] +--- + +Origin: Repo Manager RMGR-WP-0020-T03 hands Activity Core a draft definition +for the bounded Forge-sourced freshness runner. Repo Manager owns the engine +(`rmgr freshness run`, RMGR-WP-0020-T02) and receipts (T01); Activity Core owns +the schedule. Source level only: no schedule enabled, no deployment, no +credential handling, no run dispatched. + +## Draft definition and bounded operation (disabled) + +```task +id: ACTIVITY-WP-0044-T01 +status: done +priority: medium +``` + +- `activity-definitions/repo-manager-bounded-freshness.md`: `enabled: false`, + cron `40 */2 * * *` UTC, `misfire_policy: skip`, `limit: 6`, + `max_seconds: 240`, `apply: true`; rule condition `False` (no task spawning). +- ACT-ADR-007 registry entry `repo_manager_freshness` (source `repo-manager` / + `freshness_run`): single Temporal attempt, timeout 300 s, admission requires + explicit boolean `apply`, `limit` 1..12, `max_seconds` 1..270, and refuses + any definition-supplied command/binary. +- `context_resolvers/repo_manager.py`: code-fixed argv + `rmgr freshness run --limit N --max-seconds S [--dry-run]` (executable from + worker env `ACTIVITY_CORE_RMGR_BIN`, default `rmgr`); only a counted + projection (receipt count per outcome, exit code) is persisted. +- Tests: `tests/test_repo_manager_freshness_definition.py` (parse, admission, + argv, projection, dispatch, DB sync stays disabled, schedule sync paused). + +## Interface confirmation against the Repo Manager CLI + +```task +id: ACTIVITY-WP-0044-T02 +status: wait +priority: medium +needs_human: false +blocking_reason: "rmgr freshness run is being built in RMGR-WP-0020-T02; JSON output (receipts[].outcome) assumed from the T01 receipt contract" +depends_on: [RMGR-WP-0020-T01, RMGR-WP-0020-T02] +``` + +When RMGR-WP-0020-T01/T02 land, confirm the CLI flags and that stdout is one +JSON object with `receipts[]` carrying `repo-manager.freshness-receipt.v1` +outcomes; adjust `normalize_operation_result` if the shape differs. Decide the +worker image/packaging of `rmgr` (no deployment in this workplan). + +## Founder-reviewed activation + +```task +id: ACTIVITY-WP-0044-T03 +status: wait +priority: medium +needs_human: true +blocking_reason: "Activation follows RMGR-WP-0020-T04 and is a founder action" +depends_on: [RMGR-WP-0020-T04] +``` + +Flip `enabled: true` only after the RMGR-WP-0020-T04 manual dry-run/apply fires. +Rollback: `enabled: false` (or `status: paused`).