ACTIVITY-WP-0044: disabled repo-manager-bounded-freshness draft (RMGR-WP-0020-T03)
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:
parent
c6df768ef0
commit
ba4ef00128
9 changed files with 449 additions and 2 deletions
63
activity-definitions/repo-manager-bounded-freshness.md
Normal file
63
activity-definitions/repo-manager-bounded-freshness.md
Normal file
|
|
@ -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"]
|
||||||
|
```
|
||||||
|
|
@ -25,7 +25,8 @@ schedule, retention action, or production rollout follows from role acceptance.
|
||||||
Scaleway physical recovery or Forgejo archive recovery evidence. Its old
|
Scaleway physical recovery or Forgejo archive recovery evidence. Its old
|
||||||
host kubeconfig names and RPO wording are not proof of current cluster
|
host kubeconfig names and RPO wording are not proof of current cluster
|
||||||
routing or an accepted guarantee; review those before reusing the wiring.
|
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
|
CNPG backup has one Temporal attempt because no durable remote idempotency
|
||||||
receipt is available. New restore dispatch must not inherit retry defaults.
|
receipt is available. New restore dispatch must not inherit retry defaults.
|
||||||
- The stale-token blocker was resolved under RPF-WP-0045. Read-only inspection
|
- The stale-token blocker was resolved under RPF-WP-0045. Read-only inspection
|
||||||
|
|
|
||||||
|
|
@ -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
|
from activity_core.context_resolvers.cnpg_backup import cnpg_option_a_backup
|
||||||
|
|
||||||
result = cnpg_option_a_backup(params)
|
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:
|
else:
|
||||||
raise ApplicationError(
|
raise ApplicationError(
|
||||||
f"bounded operation {spec.operation_id!r} uses a dedicated activity",
|
f"bounded operation {spec.operation_id!r} uses a dedicated activity",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,19 @@ _SPECS = (
|
||||||
credential_route="railiance-cnpg-option-a-backup",
|
credential_route="railiance-cnpg-option-a-backup",
|
||||||
evidence_mode="context-evidence-sink",
|
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}
|
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",
|
"forgejo_package_prune": "/opt/railiance-platform/tools/cmd/forgejo-package-prune",
|
||||||
"cnpg_option_a_backup": "/opt/railiance-platform/tools/cmd/cnpg-option-a-backup",
|
"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_.-]+$")
|
_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")),
|
"failed": _safe_count(result.get("failed")),
|
||||||
"script_exit_code": exit_code,
|
"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}")
|
raise ValueError(f"no result projection for bounded operation {operation_id!r}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -192,6 +231,8 @@ def validate_bounded_operations(
|
||||||
_validate_prune(params)
|
_validate_prune(params)
|
||||||
elif spec.operation_id == "cnpg_option_a_backup":
|
elif spec.operation_id == "cnpg_option_a_backup":
|
||||||
_validate_backup(params)
|
_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:
|
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")
|
_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(
|
def _validate_canonical_script(
|
||||||
params: dict[str, Any], field: str, operation_id: str
|
params: dict[str, Any], field: str, operation_id: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
63
src/activity_core/context_resolvers/repo_manager.py
Normal file
63
src/activity_core/context_resolvers/repo_manager.py
Normal 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
|
||||||
|
|
@ -40,6 +40,7 @@ _AUDIT_BUFFER: deque[dict[str, Any]] = deque(maxlen=100)
|
||||||
SIDE_EFFECT_MARKERS = (
|
SIDE_EFFECT_MARKERS = (
|
||||||
"forgejo_package_prune",
|
"forgejo_package_prune",
|
||||||
"cnpg_option_a_backup",
|
"cnpg_option_a_backup",
|
||||||
|
"repo_manager_freshness",
|
||||||
"apply: true",
|
"apply: true",
|
||||||
'"apply": true',
|
'"apply": true',
|
||||||
"'apply': true",
|
"'apply': true",
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ def _evidence_sinks(event_type: str) -> list[dict[str, str]]:
|
||||||
return [{"type": "state-hub-progress", "event_type": event_type}]
|
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) == {
|
assert set(BOUNDED_OPERATION_REGISTRY) == {
|
||||||
"sbom_nexus_ingest",
|
"sbom_nexus_ingest",
|
||||||
"forgejo_package_prune",
|
"forgejo_package_prune",
|
||||||
"cnpg_option_a_backup",
|
"cnpg_option_a_backup",
|
||||||
|
# ACTIVITY-WP-0044 / RMGR-WP-0020-T03 (draft definition, disabled)
|
||||||
|
"repo_manager_freshness",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
170
tests/test_repo_manager_freshness_definition.py
Normal file
170
tests/test_repo_manager_freshness_definition.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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`).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue