ACTIVITY-WP-0044: disabled repo-manager-bounded-freshness draft (RMGR-WP-0020-T03)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m56s

Register bounded operation repo_manager_freshness (single attempt, 300 s,
code-fixed 'rmgr freshness run --limit N --max-seconds S [--dry-run]') and add
the disabled cron definition (40 */2 * * * UTC, limit 6, max_seconds 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 692292@bnt-lap001
Assistant-Session: 89c7d8e8-e1b3-47eb-9411-a0ab227fca2f
This commit is contained in:
tegwick 2026-10-04 13:11:54 +02:00
parent c6df768ef0
commit ba4ef00128
9 changed files with 449 additions and 2 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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",