generated from coulomb/repo-seed
Add Core Hub ops evidence sink
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -16,6 +18,10 @@ _INTER_HUB_SINK_TYPES = {
|
||||
"inter-hub-event",
|
||||
"inter-hub-interaction-event",
|
||||
}
|
||||
_CORE_HUB_SINK_TYPES = {
|
||||
"core-hub",
|
||||
"core-hub-interaction-event",
|
||||
}
|
||||
|
||||
|
||||
def persist_ops_inventory_evidence(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
@@ -56,6 +62,12 @@ def persist_ops_inventory_evidence(payload: dict[str, Any]) -> list[dict[str, An
|
||||
results.append(
|
||||
_post_state_hub_progress(payload, bind_key, probe_result, sink)
|
||||
)
|
||||
elif sink_type in _CORE_HUB_SINK_TYPES:
|
||||
results.append(
|
||||
_post_core_hub_interaction_event(
|
||||
payload, bind_key, probe_result, sink
|
||||
)
|
||||
)
|
||||
elif sink_type in _INTER_HUB_SINK_TYPES:
|
||||
results.append(_inter_hub_result(sink))
|
||||
else:
|
||||
@@ -159,6 +171,213 @@ def _progress_exists(base_url: str, event_type: str, idempotency_key: str) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _post_core_hub_interaction_event(
|
||||
payload: dict[str, Any],
|
||||
context_key: str,
|
||||
probe_result: dict[str, Any],
|
||||
sink: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
raw_base_url = (
|
||||
sink.get("core_hub_url")
|
||||
or sink.get("base_url")
|
||||
or os.environ.get("CORE_HUB_BASE_URL")
|
||||
or ""
|
||||
)
|
||||
base_url = str(raw_base_url).rstrip("/")
|
||||
runtime_token = _core_hub_runtime_token(sink)
|
||||
widget_id = _core_hub_widget_id(sink, probe_result)
|
||||
|
||||
missing: list[str] = []
|
||||
if not base_url:
|
||||
missing.append("CORE_HUB_BASE_URL")
|
||||
if not runtime_token:
|
||||
missing.append("CORE_HUB_RUNTIME_TOKEN or CORE_HUB_RUNTIME_TOKEN_FILE")
|
||||
if not widget_id:
|
||||
missing.append("widget_id or CORE_HUB_WIDGET_ID")
|
||||
if missing:
|
||||
return {
|
||||
"type": sink.get("type"),
|
||||
"status": "skipped",
|
||||
"reason": "missing_core_hub_config",
|
||||
"missing": missing,
|
||||
"context_key": context_key,
|
||||
}
|
||||
|
||||
endpoint = _selected_endpoint(probe_result, sink)
|
||||
event_type = sink.get("event_type", "ops-endpoint-verified")
|
||||
timeout = float(sink.get("timeout_seconds", 10.0))
|
||||
body = {
|
||||
"widgetId": widget_id,
|
||||
"eventType": event_type,
|
||||
"viewContext": _core_hub_view_context(payload, context_key, endpoint, sink),
|
||||
"metadata": _core_hub_metadata(payload, context_key, probe_result, endpoint),
|
||||
}
|
||||
resp = httpx.post(
|
||||
f"{base_url}/api/v2/interaction-events",
|
||||
json=body,
|
||||
headers=_core_hub_headers(runtime_token),
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
event_id = data.get("id")
|
||||
if not event_id:
|
||||
raise RuntimeError("Core Hub interaction event response did not include an id")
|
||||
if not _core_hub_event_exists(base_url, runtime_token, str(event_id), timeout):
|
||||
raise RuntimeError("Core Hub interaction event was not visible after create")
|
||||
|
||||
return {
|
||||
"type": sink.get("type"),
|
||||
"status": "posted",
|
||||
"event_type": data.get("eventType", event_type),
|
||||
"event_id": event_id,
|
||||
"widget_id": data.get("widgetId", widget_id),
|
||||
"verified": True,
|
||||
"context_key": context_key,
|
||||
}
|
||||
|
||||
|
||||
def _core_hub_headers(runtime_token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {runtime_token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "activity-core-ops-evidence/0.1",
|
||||
}
|
||||
|
||||
|
||||
def _core_hub_runtime_token(sink: dict[str, Any]) -> str:
|
||||
token_file = (
|
||||
sink.get("runtime_token_file")
|
||||
or sink.get("token_file")
|
||||
or os.environ.get("CORE_HUB_RUNTIME_TOKEN_FILE")
|
||||
)
|
||||
if token_file:
|
||||
return Path(str(token_file)).read_text(encoding="utf-8").strip()
|
||||
env_name = (
|
||||
sink.get("runtime_token_env")
|
||||
or os.environ.get("CORE_HUB_RUNTIME_TOKEN_ENV")
|
||||
or "CORE_HUB_RUNTIME_TOKEN"
|
||||
)
|
||||
return os.environ.get(str(env_name), "").strip()
|
||||
|
||||
|
||||
def _core_hub_widget_id(sink: dict[str, Any], probe_result: dict[str, Any]) -> str:
|
||||
direct = sink.get("widget_id") or os.environ.get("CORE_HUB_WIDGET_ID")
|
||||
if direct:
|
||||
return str(direct)
|
||||
|
||||
endpoint = _selected_endpoint(probe_result, sink)
|
||||
widget_ref = endpoint.get("widget_ref") if endpoint else None
|
||||
if not widget_ref:
|
||||
return ""
|
||||
|
||||
mapping = sink.get("widget_mapping") or sink.get("capability_mapping")
|
||||
if mapping is None:
|
||||
mapping = os.environ.get("CORE_HUB_WIDGET_MAPPING")
|
||||
parsed = _parse_widget_mapping(mapping)
|
||||
return parsed.get(str(widget_ref), "")
|
||||
|
||||
|
||||
def _parse_widget_mapping(raw: Any) -> dict[str, str]:
|
||||
if isinstance(raw, dict):
|
||||
return {str(key): str(value) for key, value in raw.items() if value}
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return {}
|
||||
value = raw.strip()
|
||||
if value.startswith("{"):
|
||||
try:
|
||||
loaded = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if isinstance(loaded, dict):
|
||||
return {str(key): str(item) for key, item in loaded.items() if item}
|
||||
return {}
|
||||
if "=" not in value:
|
||||
return {}
|
||||
pairs: dict[str, str] = {}
|
||||
for part in value.split(","):
|
||||
key, _, item = part.partition("=")
|
||||
if key.strip() and item.strip():
|
||||
pairs[key.strip()] = item.strip()
|
||||
return pairs
|
||||
|
||||
|
||||
def _selected_endpoint(probe_result: dict[str, Any], sink: dict[str, Any]) -> dict[str, Any]:
|
||||
endpoints = [
|
||||
endpoint
|
||||
for endpoint in probe_result.get("endpoints", [])
|
||||
if isinstance(endpoint, dict)
|
||||
]
|
||||
endpoint_id = sink.get("endpoint_id")
|
||||
if endpoint_id:
|
||||
match = next(
|
||||
(endpoint for endpoint in endpoints if endpoint.get("endpoint_id") == endpoint_id),
|
||||
None,
|
||||
)
|
||||
if match:
|
||||
return match
|
||||
return next(
|
||||
(endpoint for endpoint in endpoints if endpoint.get("widget_ref")),
|
||||
endpoints[0] if endpoints else {},
|
||||
)
|
||||
|
||||
|
||||
def _core_hub_view_context(
|
||||
payload: dict[str, Any],
|
||||
context_key: str,
|
||||
endpoint: dict[str, Any],
|
||||
sink: dict[str, Any],
|
||||
) -> str:
|
||||
return str(
|
||||
sink.get("view_context")
|
||||
or endpoint.get("view_context")
|
||||
or f"activity-core/ops-inventory/{payload.get('run_id', 'unknown')}/{context_key}"
|
||||
)
|
||||
|
||||
|
||||
def _core_hub_metadata(
|
||||
payload: dict[str, Any],
|
||||
context_key: str,
|
||||
probe_result: dict[str, Any],
|
||||
endpoint: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
compact = _compact_probe_result(probe_result)
|
||||
return {
|
||||
"activity_id": payload.get("activity_id"),
|
||||
"activity_core_run_id": payload.get("run_id"),
|
||||
"scheduled_for": payload.get("scheduled_for"),
|
||||
"source_type": "ops-inventory",
|
||||
"context_key": context_key,
|
||||
"probe": {
|
||||
"generated_at": compact.get("generated_at"),
|
||||
"inventory_path": compact.get("inventory_path"),
|
||||
"status": compact.get("status"),
|
||||
"reason": compact.get("reason"),
|
||||
"summary": compact.get("summary", {}),
|
||||
},
|
||||
"endpoint": _compact_endpoint(endpoint) if endpoint else {},
|
||||
}
|
||||
|
||||
|
||||
def _core_hub_event_exists(
|
||||
base_url: str,
|
||||
runtime_token: str,
|
||||
event_id: str,
|
||||
timeout: float,
|
||||
) -> bool:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/api/v2/interaction-events",
|
||||
headers=_core_hub_headers(runtime_token),
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
data = payload.get("data") if isinstance(payload, dict) else []
|
||||
if not isinstance(data, list):
|
||||
return False
|
||||
return any(isinstance(item, dict) and item.get("id") == event_id for item in data)
|
||||
|
||||
def _inter_hub_result(sink: dict[str, Any]) -> dict[str, Any]:
|
||||
missing: list[str] = []
|
||||
if not (sink.get("inter_hub_url") or os.environ.get("INTER_HUB_URL")):
|
||||
|
||||
@@ -166,6 +166,93 @@ def test_state_hub_progress_sink_is_idempotent(monkeypatch) -> None:
|
||||
assert result[0]["idempotency_key"] == idempotency_key
|
||||
|
||||
|
||||
def test_core_hub_interaction_event_sink_posts_and_verifies_compact_event(monkeypatch) -> None:
|
||||
posts: list[dict[str, Any]] = []
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
||||
assert url == "http://core-hub.test/api/v2/interaction-events"
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer runtime-secret"
|
||||
posts.append({"url": url, **kwargs})
|
||||
return DummyResponse(
|
||||
{
|
||||
"id": "event-1",
|
||||
"eventType": "ops-endpoint-verified",
|
||||
"widgetId": "widget-1",
|
||||
}
|
||||
)
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
assert url == "http://core-hub.test/api/v2/interaction-events"
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer runtime-secret"
|
||||
return DummyResponse({"data": [{"id": "event-1"}]})
|
||||
|
||||
monkeypatch.setenv("CORE_HUB_RUNTIME_TOKEN", "runtime-secret")
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = persist_ops_inventory_evidence(
|
||||
_payload([
|
||||
{
|
||||
"type": "core-hub-interaction-event",
|
||||
"core_hub_url": "http://core-hub.test",
|
||||
"widget_id": "widget-1",
|
||||
"event_type": "ops-endpoint-verified",
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "core-hub-interaction-event",
|
||||
"status": "posted",
|
||||
"event_type": "ops-endpoint-verified",
|
||||
"event_id": "event-1",
|
||||
"widget_id": "widget-1",
|
||||
"verified": True,
|
||||
"context_key": "ops_probe",
|
||||
}
|
||||
]
|
||||
body = posts[0]["json"]
|
||||
assert body["widgetId"] == "widget-1"
|
||||
assert body["eventType"] == "ops-endpoint-verified"
|
||||
assert body["metadata"]["activity_core_run_id"] == _run_id()
|
||||
assert body["metadata"]["endpoint"]["url"] == "http://state-hub.test/health"
|
||||
assert body["metadata"]["endpoint"]["widget_ref"] == "ops:endpoint:state-hub-health"
|
||||
|
||||
serialized = json.dumps(body, sort_keys=True)
|
||||
assert "runtime-secret" not in serialized
|
||||
assert "secret response body" not in serialized
|
||||
assert "Authorization" not in serialized
|
||||
assert "user:pass" not in serialized
|
||||
assert "token=secret" not in serialized
|
||||
|
||||
|
||||
def test_core_hub_sink_skips_cleanly_when_config_missing(monkeypatch) -> None:
|
||||
monkeypatch.delenv("CORE_HUB_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("CORE_HUB_RUNTIME_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CORE_HUB_RUNTIME_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("CORE_HUB_WIDGET_ID", raising=False)
|
||||
monkeypatch.delenv("CORE_HUB_WIDGET_MAPPING", raising=False)
|
||||
|
||||
result = persist_ops_inventory_evidence(
|
||||
_payload([{"type": "core-hub-interaction-event"}])
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "core-hub-interaction-event",
|
||||
"status": "skipped",
|
||||
"reason": "missing_core_hub_config",
|
||||
"missing": [
|
||||
"CORE_HUB_BASE_URL",
|
||||
"CORE_HUB_RUNTIME_TOKEN or CORE_HUB_RUNTIME_TOKEN_FILE",
|
||||
"widget_id or CORE_HUB_WIDGET_ID",
|
||||
],
|
||||
"context_key": "ops_probe",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_inter_hub_sink_skips_cleanly_when_config_missing(monkeypatch) -> None:
|
||||
monkeypatch.delenv("INTER_HUB_URL", raising=False)
|
||||
monkeypatch.delenv("OPS_HUB_KEY", raising=False)
|
||||
|
||||
58
workplans/ACTIVITY-WP-0017-core-hub-ops-evidence-sink.md
Normal file
58
workplans/ACTIVITY-WP-0017-core-hub-ops-evidence-sink.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: ACTIVITY-WP-0017
|
||||
type: workplan
|
||||
title: "Core Hub ops evidence sink"
|
||||
domain: infotech
|
||||
repo: activity-core
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-27"
|
||||
updated: "2026-06-27"
|
||||
state_hub_workstream_id: "2a073bf4-febf-433e-a721-5daf71760912"
|
||||
---
|
||||
|
||||
# Core Hub ops evidence sink
|
||||
|
||||
## Goal
|
||||
|
||||
Provide the activity-core side of the Core Hub replacement evidence path for
|
||||
`CORE-WP-0008-T03`, without depending on the legacy Haskell Inter-Hub sink and
|
||||
without placing secret material in activity definitions, logs, State Hub, or
|
||||
chat.
|
||||
|
||||
## Task: Add Core Hub interaction-event sink
|
||||
|
||||
```task
|
||||
id: ACTIVITY-WP-0017-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "32aab1af-6be5-4b52-afa1-c11f52c65892"
|
||||
```
|
||||
|
||||
Add a `core-hub-interaction-event` ops evidence sink that posts sanitized
|
||||
ops-inventory probe evidence to Core Hub `/api/v2/interaction-events`, verifies
|
||||
the created event is visible, and reports only non-secret ids/statuses.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- runtime token is read through `CORE_HUB_RUNTIME_TOKEN_FILE` or a named
|
||||
environment variable, never from workplan content;
|
||||
- sink configuration accepts `CORE_HUB_BASE_URL` and a widget id or widget
|
||||
mapping;
|
||||
- emitted metadata reuses the existing compact/sanitized probe evidence path;
|
||||
- missing Core Hub config skips cleanly with explicit non-secret missing keys;
|
||||
- tests prove the POST/visibility check and secret non-disclosure.
|
||||
|
||||
Verification 2026-06-27: `tests/test_ops_evidence_sinks.py` passed, and
|
||||
a disposable local Core Hub runtime accepted an activity-core
|
||||
`core-hub-interaction-event` sink emission, then listed the created
|
||||
`ops-endpoint-verified` event back through `/api/v2/interaction-events`.
|
||||
The verification asserted sanitized metadata did not include response body,
|
||||
authorization header, URL userinfo, or token query material.
|
||||
|
||||
Completed 2026-06-27: implemented the Core Hub interaction-event sink in
|
||||
`activity_core.ops_evidence_sinks` with unit coverage for POST/visibility
|
||||
verification, missing config behavior, and secret non-disclosure. This provides
|
||||
the direct Core Hub consumer path needed by `CORE-WP-0008-T03`; deployed use
|
||||
still requires an approved Core Hub runtime token and widget id/mapping.
|
||||
Reference in New Issue
Block a user