diff --git a/activity-definitions/weekly-legacy-meter-review.md b/activity-definitions/weekly-legacy-meter-review.md new file mode 100644 index 0000000..88c2fcd --- /dev/null +++ b/activity-definitions/weekly-legacy-meter-review.md @@ -0,0 +1,35 @@ +--- +id: weekly-legacy-meter-review +name: Weekly Legacy-Meter Review +enabled: true +owner: custodian-agent +governance: custodian +status: active +trigger: + type: cron + cron_expression: "30 8 * * 1" + timezone: Europe/Berlin + misfire_policy: skip +context_sources: + - type: state-hub + query: legacy_meter_weekly_review + required: true + params: + days: 7 + evidence_sinks: + - type: state-hub-progress + event_type: legacy_meter_weekly_review + author: activity-core + workplan_id: 923bb94a-d16c-422c-b81e-16328bd7b60c + bind_to: context.legacy_meter_weekly_review +--- + +# Weekly Legacy-Meter Review + +Runs every Monday at 08:30 Berlin time. Fetches State Hub +`/legacy-meter/weekly-review` and posts a compact `legacy_meter_weekly_review` +progress event for STATE-WP-0069 retirement gating. + +The run is observational: it records retirement candidates and window usage +counts. Interface removal remains gated on legacy-meter evidence per +`docs/workplan-terminology-transition.md`. \ No newline at end of file diff --git a/k8s/railiance/20-runtime.yaml b/k8s/railiance/20-runtime.yaml index a801d7c..42e835c 100644 --- a/k8s/railiance/20-runtime.yaml +++ b/k8s/railiance/20-runtime.yaml @@ -293,6 +293,41 @@ data: --- # ActivityDefinition: Phase 5 Stabilization Daily Check + weekly-legacy-meter-review.md: | + --- + id: weekly-legacy-meter-review + name: Weekly Legacy-Meter Review + type: activity-definition + version: "1.0" + enabled: true + owner: custodian + governance: custodian + status: active + created: "2026-07-08" + trigger: + type: cron + cron_expression: "30 8 * * 1" + timezone: Europe/Berlin + misfire_policy: skip + context_sources: + - type: state-hub + query: legacy_meter_weekly_review + required: true + params: + days: 7 + evidence_sinks: + - type: state-hub-progress + event_type: legacy_meter_weekly_review + author: activity-core + workplan_id: 923bb94a-d16c-422c-b81e-16328bd7b60c + bind_to: context.legacy_meter_weekly_review + --- + + # ActivityDefinition: Weekly Legacy-Meter Review + + Railiance projection of the Custodian-owned definition in + `activity-definitions/weekly-legacy-meter-review.md`. Posts + `legacy_meter_weekly_review` progress for STATE-WP-0069 retirement gating. phase5-stabilization-closeout.md: | --- id: "e7d2b5a8-4c1f-4e9a-b6d3-8f2a1c4e6b09" diff --git a/src/activity_core/automation_status.py b/src/activity_core/automation_status.py index 427a167..dce4b8c 100644 --- a/src/activity_core/automation_status.py +++ b/src/activity_core/automation_status.py @@ -170,7 +170,12 @@ def progress_event_types(args: argparse.Namespace) -> list[str | None]: raw = args.progress_event_type if raw is None: env_value = os.environ.get("AUTOMATION_STATUS_PROGRESS_EVENT_TYPES") - raw = env_value.split(",") if env_value else ["daily_triage", "schedule_miss", "ops_inventory_probe"] + raw = env_value.split(",") if env_value else [ + "daily_triage", + "schedule_miss", + "ops_inventory_probe", + "legacy_meter_weekly_review", + ] values = [item.strip() for item in raw if item and item.strip()] return [None if item == "all" else item for item in values] diff --git a/src/activity_core/context_resolvers/state_hub.py b/src/activity_core/context_resolvers/state_hub.py index 3cbcb4f..3467add 100644 --- a/src/activity_core/context_resolvers/state_hub.py +++ b/src/activity_core/context_resolvers/state_hub.py @@ -16,6 +16,7 @@ Supported queries: - recently_on_scope_hourly: POST {STATE_HUB_URL}/recently-on-scope/hourly - consistency_sweep_remote_all: POST {STATE_HUB_URL}/consistency/sweep/remote-all - phase5_stabilization_check: hub-visible Phase 5 stabilization gates + - legacy_meter_weekly_review: GET {STATE_HUB_URL}/legacy-meter/weekly-review No caching — state hub data is live operational state and must not be stale within a single workflow run. @@ -149,6 +150,8 @@ class StateHubContextResolver(ContextResolver): return _validate_consistency_sweep_remote_all(result) if query == "phase5_stabilization_check": return _phase5_stabilization_check(params) + if query == "legacy_meter_weekly_review": + return _legacy_meter_weekly_review(params) return {} @@ -169,6 +172,42 @@ def _utc_now() -> datetime: return datetime.now(timezone.utc) +def _legacy_meter_weekly_review(params: dict[str, Any]) -> dict[str, Any]: + """Fetch State Hub legacy-meter weekly review for activity-core scheduling.""" + query_params = { + key: params[key] + for key in ("days", "window_start", "window_end") + if key in params + } + raw = _fetch_json("/legacy-meter/weekly-review", query_params or None) + interfaces = raw.get("interfaces") or [] + candidates = raw.get("retirement_candidates") or [] + window_calls = sum(int((item.get("window") or {}).get("calls") or 0) for item in interfaces) + return { + "kind": "legacy_meter_weekly_review", + "generated_at": raw.get("generated_at"), + "window_start": raw.get("window_start"), + "window_end": raw.get("window_end"), + "activity_core_handoff": raw.get("activity_core_handoff"), + "interface_count": len(interfaces), + "retirement_candidate_count": len(candidates), + "window_legacy_calls": window_calls, + "retirement_candidates": [ + { + "interface_key": (item.get("interface") or {}).get("interface_key"), + "interface_kind": (item.get("interface") or {}).get("interface_kind"), + "replacement_ref": (item.get("interface") or {}).get("replacement_ref"), + "retirement_reason": item.get("retirement_reason"), + } + for item in candidates[:25] + ], + "checks": { + "has_retirement_candidates": bool(candidates), + }, + "overall_pass": True, + } + + def _phase5_stabilization_check(params: dict[str, Any]) -> dict[str, Any]: closeout = bool(params.get("closeout")) now = _utc_now() diff --git a/src/activity_core/ops_evidence_sinks.py b/src/activity_core/ops_evidence_sinks.py index 1eb145c..ca71382 100644 --- a/src/activity_core/ops_evidence_sinks.py +++ b/src/activity_core/ops_evidence_sinks.py @@ -127,6 +127,10 @@ def _post_state_hub_progress( compact = probe_result summary = _core_hub_stabilization_summary_text(probe_result) source_type = "core-hub" + elif probe_result.get("kind") == "legacy_meter_weekly_review": + compact = probe_result + summary = _legacy_meter_summary_text(probe_result) + source_type = "state-hub" elif probe_result.get("checks") is not None: compact = probe_result summary = _phase5_summary_text(probe_result) @@ -518,6 +522,16 @@ def _core_hub_stabilization_summary_text(result: dict[str, Any]) -> str: ) +def _legacy_meter_summary_text(result: dict[str, Any]) -> str: + candidates = result.get("retirement_candidate_count", 0) + calls = result.get("window_legacy_calls", 0) + interfaces = result.get("interface_count", 0) + return ( + f"Legacy-meter weekly review: {candidates} retirement candidate(s) of " + f"{interfaces} registered interface(s); {calls} legacy call(s) in window" + ) + + def _phase5_summary_text(result: dict[str, Any]) -> str: checks = result.get("checks") or {} totals = checks.get("totals") or {} diff --git a/tests/test_ops_evidence_sinks.py b/tests/test_ops_evidence_sinks.py index 24dfa05..cbadfd6 100644 --- a/tests/test_ops_evidence_sinks.py +++ b/tests/test_ops_evidence_sinks.py @@ -137,6 +137,64 @@ def test_state_hub_progress_sink_posts_compact_probe_summary(monkeypatch) -> Non assert "token=secret" not in serialized +def test_legacy_meter_weekly_review_sink_posts_progress(monkeypatch) -> None: + posts: list[dict[str, Any]] = [] + + def fake_get(url: str, **kwargs: Any) -> DummyResponse: + return DummyResponse([]) + + def fake_post(url: str, **kwargs: Any) -> DummyResponse: + posts.append({"url": url, **kwargs}) + return DummyResponse({"id": "progress-legacy-meter-1"}) + + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(httpx, "post", fake_post) + + persist_ops_inventory_evidence( + { + "activity_id": "weekly-legacy-meter-review", + "run_id": "12345678-aaaa-bbbb-cccc-123456789abc", + "scheduled_for": "2026-07-08T06:30:00+00:00", + "version_used": 1, + "context_sources": [ + { + "type": "state-hub", + "query": "legacy_meter_weekly_review", + "bind_to": "context.legacy_meter_weekly_review", + "params": { + "days": 7, + "evidence_sinks": [ + { + "type": "state-hub-progress", + "state_hub_url": "http://state-hub.test", + "event_type": "legacy_meter_weekly_review", + "workplan_id": "923bb94a-d16c-422c-b81e-16328bd7b60c", + } + ], + }, + } + ], + "context": { + "legacy_meter_weekly_review": { + "kind": "legacy_meter_weekly_review", + "interface_count": 12, + "retirement_candidate_count": 2, + "window_legacy_calls": 5, + "retirement_candidates": [], + "checks": {"has_retirement_candidates": True}, + "overall_pass": True, + } + }, + } + ) + + body = posts[0]["json"] + assert body["event_type"] == "legacy_meter_weekly_review" + assert "2 retirement candidate(s)" in body["summary"] + assert body["workplan_id"] == "923bb94a-d16c-422c-b81e-16328bd7b60c" + assert body["workstream_id"] == "923bb94a-d16c-422c-b81e-16328bd7b60c" + + def test_state_hub_progress_prefers_workplan_id(monkeypatch) -> None: posts: list[dict[str, Any]] = [] diff --git a/tests/test_railiance_ops_inventory_wiring.py b/tests/test_railiance_ops_inventory_wiring.py index caec4c9..452f383 100644 --- a/tests/test_railiance_ops_inventory_wiring.py +++ b/tests/test_railiance_ops_inventory_wiring.py @@ -78,6 +78,24 @@ def test_external_configmap_projects_disabled_ops_probe_definition(tmp_path) -> ] +def test_external_configmap_projects_weekly_legacy_meter_review(tmp_path) -> None: + config = _by_kind_name("ConfigMap", "actcore-external-activity-definitions") + raw_definition = config["data"]["weekly-legacy-meter-review.md"] + definition_path = tmp_path / "weekly-legacy-meter-review.md" + definition_path.write_text(raw_definition, encoding="utf-8") + + definition = parse_file(definition_path) + + assert definition.id == "weekly-legacy-meter-review" + assert definition.enabled is True + assert definition.trigger_config["cron_expression"] == "30 8 * * 1" + source = definition.context_sources[0] + assert source["query"] == "legacy_meter_weekly_review" + assert source["params"]["evidence_sinks"][0]["event_type"] == ( + "legacy_meter_weekly_review" + ) + + def test_external_configmap_projects_enabled_daily_wsjf_definition(tmp_path) -> None: config = _by_kind_name("ConfigMap", "actcore-external-activity-definitions") raw_definition = config["data"]["daily-statehub-wsjf-triage.md"] diff --git a/tests/test_state_hub_context_resolver.py b/tests/test_state_hub_context_resolver.py index 0defda9..4689bfc 100644 --- a/tests/test_state_hub_context_resolver.py +++ b/tests/test_state_hub_context_resolver.py @@ -706,6 +706,68 @@ def test_daily_triage_digest_is_curated_scalar_json(monkeypatch) -> None: assert digest["ranked_suggestions"][0]["origin_ref"] == "issue-core-ingestion-api-key" +def test_legacy_meter_weekly_review_summarises_candidates(monkeypatch) -> None: + payloads = { + "/legacy-meter/weekly-review": { + "generated_at": "2026-07-08T08:30:00+00:00", + "window_start": "2026-07-01T08:30:00+00:00", + "window_end": "2026-07-08T08:30:00+00:00", + "activity_core_handoff": { + "activity_id": "statehub-legacy-interface-review", + "scheduler_owner": "activity-core", + }, + "interfaces": [ + { + "interface": { + "interface_key": "rest_api:GET /workstreams/", + "interface_kind": "rest_api", + "replacement_ref": "GET /workplans/", + }, + "window": {"calls": 2}, + "retirement_candidate": False, + }, + { + "interface": { + "interface_key": "mcp:create_workstream", + "interface_kind": "mcp_tool", + "replacement_ref": "create_workplan", + }, + "window": {"calls": 0}, + "retirement_candidate": True, + "retirement_reason": "no measured usage in review window", + }, + ], + "retirement_candidates": [ + { + "interface": { + "interface_key": "mcp:create_workstream", + "interface_kind": "mcp_tool", + "replacement_ref": "create_workplan", + }, + "window": {"calls": 0}, + "retirement_candidate": True, + "retirement_reason": "no measured usage in review window", + } + ], + } + } + + def fake_get(url: str, **kwargs: Any) -> DummyResponse: + path = url.replace("http://state-hub.test", "") + return DummyResponse(payloads.get(path, {})) + + monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test") + monkeypatch.setattr(httpx, "get", fake_get) + + result = StateHubContextResolver().resolve("legacy_meter_weekly_review", None, {"days": 7}) + + assert result["kind"] == "legacy_meter_weekly_review" + assert result["interface_count"] == 2 + assert result["retirement_candidate_count"] == 1 + assert result["window_legacy_calls"] == 2 + assert result["retirement_candidates"][0]["interface_key"] == "mcp:create_workstream" + + def test_phase5_stabilization_check_passes(monkeypatch) -> None: payloads = { "/state/health": {"status": "ok", "db": "connected"},