diff --git a/k8s/railiance/20-runtime.yaml b/k8s/railiance/20-runtime.yaml index 87dbcc3..2996e37 100644 --- a/k8s/railiance/20-runtime.yaml +++ b/k8s/railiance/20-runtime.yaml @@ -284,6 +284,79 @@ data: --- # ActivityDefinition: Phase 5 Stabilization Closeout Check + core-hub-stabilization-daily.md: | + --- + id: "b4c8e2f1-6a3d-4e5b-9f0c-2d7e8a1b3c4d" + name: "Core Hub Stabilization Daily Check" + type: activity-definition + version: "1.0" + enabled: true + owner: core-hub + governance: core-hub + status: active + created: "2026-07-07" + trigger: + type: cron + cron_expression: "0 9 * * *" + timezone: Europe/Berlin + misfire_policy: skip + context_sources: + - type: core-hub + query: stabilization_check + required: true + params: + source: activity-core + closeout: false + base_url: "https://hub.coulomb.social" + window_start: "2026-07-03T00:00:00+00:00" + window_end: "2026-07-10T17:35:00+00:00" + min_widget_types: 26 + evidence_sinks: + - type: state-hub-progress + event_type: core_hub_stabilization_check + author: activity-core + workstream_id: a8d66822-e435-4b1e-ad81-37a298d1795e + task_id: 16eb7ce3-b574-4667-a189-c14ff5d0502b + bind_to: context.core_hub_stabilization_check + --- + + # ActivityDefinition: Core Hub Stabilization Daily Check + core-hub-stabilization-closeout.md: | + --- + id: "c5d9f3a2-7b4e-5f6c-0a1d-3e8f9b2c4d5e" + name: "Core Hub Stabilization Closeout Check" + type: activity-definition + version: "1.0" + enabled: true + owner: core-hub + governance: core-hub + status: active + created: "2026-07-07" + trigger: + type: scheduled + at: "2026-07-10T17:35:00+00:00" + timezone: UTC + context_sources: + - type: core-hub + query: stabilization_check + required: true + params: + source: activity-core + closeout: true + base_url: "https://hub.coulomb.social" + window_start: "2026-07-03T00:00:00+00:00" + window_end: "2026-07-10T17:35:00+00:00" + min_widget_types: 26 + evidence_sinks: + - type: state-hub-progress + event_type: core_hub_stabilization_closeout + author: activity-core + workstream_id: a8d66822-e435-4b1e-ad81-37a298d1795e + task_id: 16eb7ce3-b574-4667-a189-c14ff5d0502b + bind_to: context.core_hub_stabilization_check + --- + + # ActivityDefinition: Core Hub Stabilization Closeout Check ops-service-inventory-probes.md: | --- id: "40d15a87-7ff6-4d8e-992c-37df15f95110" diff --git a/src/activity_core/context_resolvers/__init__.py b/src/activity_core/context_resolvers/__init__.py index 2886e84..2cf4587 100644 --- a/src/activity_core/context_resolvers/__init__.py +++ b/src/activity_core/context_resolvers/__init__.py @@ -1,4 +1,5 @@ from activity_core.context_resolvers import ( # noqa: F401 + core_hub, event_payload, kaizen, ops_inventory, diff --git a/src/activity_core/context_resolvers/core_hub.py b/src/activity_core/context_resolvers/core_hub.py new file mode 100644 index 0000000..1813327 --- /dev/null +++ b/src/activity_core/context_resolvers/core_hub.py @@ -0,0 +1,193 @@ +"""Core Hub production stabilization context adapter. + +Registered as source type ``core-hub``. + +Supported queries: + - stabilization_check: public production surface gates for the post-cutover + window before CORE-WP-0007-T02 operator sign-off. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any + +import httpx + +from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver +from activity_core.context_resolvers.state_hub import _parse_iso_datetime, _utc_now + +_DEFAULT_BASE_URL = "https://hub.coulomb.social" +_DEFAULT_TIMEOUT_SECONDS = 15.0 +_PUBLIC_CHECKS: tuple[tuple[str, int], ...] = ( + ("/healthz", 200), + ("/readyz", 200), + ("/api/v2/widget-types", 200), + ("/api/v2/hubs", 401), +) + + +class CoreHubContextResolver(ContextResolver): + """Resolve lightweight Core Hub stabilization checks over public HTTP.""" + + def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]: + if query == "stabilization_check": + return _core_hub_stabilization_check(params) + return {} + + +def _bounded_int( + value: Any, + *, + default: int, + minimum: int, + maximum: int, +) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(minimum, min(maximum, parsed)) + + +def _probe_status( + client: httpx.Client, + path: str, + expected_status: int, +) -> dict[str, Any]: + try: + response = client.get(path) + except Exception as exc: # pragma: no cover - network dependent + return { + "pass": False, + "path": path, + "expected_status": expected_status, + "error": str(exc), + } + return { + "pass": response.status_code == expected_status, + "path": path, + "expected_status": expected_status, + "status_code": response.status_code, + } + + +def _widget_type_count(client: httpx.Client, minimum: int) -> dict[str, Any]: + try: + response = client.get("/api/v2/widget-types") + except Exception as exc: # pragma: no cover - network dependent + return { + "pass": False, + "minimum": minimum, + "count": None, + "error": str(exc), + } + if response.status_code != 200: + return { + "pass": False, + "minimum": minimum, + "count": None, + "status_code": response.status_code, + } + try: + payload = response.json() + except ValueError as exc: + return { + "pass": False, + "minimum": minimum, + "count": None, + "error": f"invalid JSON: {exc}", + } + if isinstance(payload, list): + count = len(payload) + elif isinstance(payload, dict) and isinstance(payload.get("data"), list): + count = len(payload["data"]) + else: + count = 0 + return { + "pass": count >= minimum, + "minimum": minimum, + "count": count, + } + + +def _core_hub_stabilization_check(params: dict[str, Any]) -> dict[str, Any]: + closeout = bool(params.get("closeout")) + now = _utc_now() + window_start = _parse_iso_datetime( + params.get("window_start", "2026-07-03T00:00:00+00:00") + ) + window_end = _parse_iso_datetime( + params.get("window_end", "2026-07-10T17:35:00+00:00") + ) + within_window = bool( + window_start and window_end and window_start <= now <= window_end + ) + + if not within_window and not closeout: + return { + "kind": "core_hub_stabilization", + "skipped": True, + "reason": "outside_stabilization_window", + "closeout": closeout, + "overall_pass": True, + "operator_signoff_needed": False, + "base_url": str(params.get("base_url") or os.environ.get("CORE_HUB_BASE_URL") or _DEFAULT_BASE_URL), + "window": { + "start": window_start.isoformat() if window_start else None, + "end": window_end.isoformat() if window_end else None, + "within": within_window, + }, + "checks": {}, + } + + base_url = str( + params.get("base_url") + or os.environ.get("CORE_HUB_BASE_URL") + or _DEFAULT_BASE_URL + ).rstrip("/") + timeout_seconds = float( + params.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS) + ) + min_widget_types = _bounded_int( + params.get("min_widget_types", 26), + default=26, + minimum=1, + maximum=200, + ) + + checks: dict[str, Any] = {} + with httpx.Client( + base_url=base_url, + timeout=timeout_seconds, + headers={"Accept": "application/json", "User-Agent": "activity-core/core-hub-stabilization/0.1"}, + follow_redirects=True, + ) as client: + for path, expected_status in _PUBLIC_CHECKS: + key = path.strip("/").replace("/", "_").replace("-", "_") or "root" + checks[key] = _probe_status(client, path, expected_status) + checks["widget_types_count"] = _widget_type_count(client, min_widget_types) + + overall_pass = all(section.get("pass") for section in checks.values()) + result = { + "kind": "core_hub_stabilization", + "skipped": False, + "closeout": closeout, + "overall_pass": overall_pass, + "operator_signoff_needed": closeout and overall_pass, + "base_url": base_url, + "window": { + "start": window_start.isoformat() if window_start else None, + "end": window_end.isoformat() if window_end else None, + "within": within_window, + }, + "checks": checks, + "source": str(params.get("source") or "activity-core"), + } + if closeout and not overall_pass: + raise RuntimeError(f"core hub stabilization closeout failed: {result}") + return result + + +CONTEXT_RESOLVER_REGISTRY["core-hub"] = CoreHubContextResolver \ No newline at end of file diff --git a/src/activity_core/ops_evidence_sinks.py b/src/activity_core/ops_evidence_sinks.py index a78901b..09101ac 100644 --- a/src/activity_core/ops_evidence_sinks.py +++ b/src/activity_core/ops_evidence_sinks.py @@ -123,7 +123,11 @@ def _post_state_hub_progress( "context_key": context_key, } - if probe_result.get("checks") is not None: + if probe_result.get("kind") == "core_hub_stabilization": + compact = probe_result + summary = _core_hub_stabilization_summary_text(probe_result) + source_type = "core-hub" + elif probe_result.get("checks") is not None: compact = probe_result summary = _phase5_summary_text(probe_result) source_type = "state-hub" @@ -503,6 +507,19 @@ def _compact_access_path(access_path: dict[str, Any]) -> dict[str, Any]: } +def _core_hub_stabilization_summary_text(result: dict[str, Any]) -> str: + checks = result.get("checks") or {} + widget_types = checks.get("widget_types_count") or {} + status = "pass" if result.get("overall_pass") else "fail" + mode = "closeout" if result.get("closeout") else "daily" + return ( + f"Core Hub stabilization {mode}: {status}; " + f"base={result.get('base_url', '?')}; " + f"widget_types={widget_types.get('count', '?')}/" + f"{widget_types.get('minimum', '?')}" + ) + + def _phase5_summary_text(result: dict[str, Any]) -> str: checks = result.get("checks") or {} totals = checks.get("totals") or {} diff --git a/tests/test_core_hub_context_resolver.py b/tests/test_core_hub_context_resolver.py new file mode 100644 index 0000000..b946143 --- /dev/null +++ b/tests/test_core_hub_context_resolver.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import httpx + +from activity_core.context_resolvers.core_hub import CoreHubContextResolver + + +class DummyResponse: + def __init__(self, payload: Any, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.text = str(payload) + + def json(self) -> Any: + return self._payload + + +class DummyClient: + def __init__(self, responses: dict[str, tuple[Any, int]]) -> None: + self._responses = responses + + def __enter__(self) -> DummyClient: + return self + + def __exit__(self, *args: Any) -> None: + return None + + def get(self, path: str) -> DummyResponse: + payload, status = self._responses.get( + path, + ({}, 404), + ) + return DummyResponse(payload, status) + + +def test_core_hub_stabilization_check_passes(monkeypatch) -> None: + widget_types = [{"slug": f"type-{index}"} for index in range(26)] + responses = { + "/healthz": ({}, 200), + "/readyz": ({}, 200), + "/api/v2/widget-types": (widget_types, 200), + "/api/v2/hubs": ({}, 401), + } + fixed_now = datetime(2026, 7, 7, 10, 0, tzinfo=timezone.utc) + monkeypatch.setattr(httpx, "Client", lambda **kwargs: DummyClient(responses)) + monkeypatch.setattr( + "activity_core.context_resolvers.core_hub._utc_now", + lambda: fixed_now, + ) + + result = CoreHubContextResolver().resolve("stabilization_check", None, {}) + + assert result["overall_pass"] is True + assert result["skipped"] is False + assert result["checks"]["widget_types_count"]["pass"] is True + assert result["checks"]["api_v2_hubs"]["pass"] is True + + +def test_core_hub_stabilization_check_skips_outside_window(monkeypatch) -> None: + fixed_now = datetime(2026, 7, 12, 10, 0, tzinfo=timezone.utc) + monkeypatch.setattr( + "activity_core.context_resolvers.core_hub._utc_now", + lambda: fixed_now, + ) + + result = CoreHubContextResolver().resolve("stabilization_check", None, {}) + + assert result["skipped"] is True + assert result["reason"] == "outside_stabilization_window" + + +def test_core_hub_stabilization_closeout_raises_on_failure(monkeypatch) -> None: + responses = { + "/healthz": ({}, 500), + "/readyz": ({}, 200), + "/api/v2/widget-types": ([], 200), + "/api/v2/hubs": ({}, 401), + } + fixed_now = datetime(2026, 7, 7, 10, 0, tzinfo=timezone.utc) + monkeypatch.setattr(httpx, "Client", lambda **kwargs: DummyClient(responses)) + monkeypatch.setattr( + "activity_core.context_resolvers.core_hub._utc_now", + lambda: fixed_now, + ) + + try: + CoreHubContextResolver().resolve( + "stabilization_check", + None, + {"closeout": True}, + ) + except RuntimeError as exc: + assert "closeout failed" in str(exc) + else: + raise AssertionError("expected closeout failure") \ No newline at end of file