REUSE-WP-0019-T04: reuse telemetry store and recording
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 10s
ci / validate-registry (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 1m16s

Implements the hub side of the shared reuse-event schema (already drafted
in WP-0018-T01, schemas/reuse-event.schema.json): a SQLite reuse_events
table, POST /v1/reuse-events (token-auth), GET /v1/reuse-events?capability_id=
(read-only).

reuse_surface/plan_check.py: refactored record_outcome around a new
shared post_or_fallback_reuse_event() helper -- tries the hub first, falls
back to the local JSONL only on failure/unreachability, never both. New
record_manual_reuse_event() backs a new CLI command, reuse-surface
record-reuse, for retroactive facts recorded outside plan-check.

Privacy/scope (repo slugs and capability ids only, no code, no secrets) is
enforced structurally via the schema's additionalProperties: false, not
just by convention.

21 new pytest cases, 145 total pass. Live-verified against a real running
hub instance: POST/GET /v1/reuse-events directly, record-reuse and
plan-check --record-outcome both posting successfully to the hub, and --
after actually killing the hub process -- confirmed the fallback path
writes correctly to the local JSONL instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-07 22:32:19 +02:00
parent 2fcc91f2aa
commit d181043717
12 changed files with 562 additions and 32 deletions

View file

@ -200,6 +200,21 @@ def create_app() -> FastAPI:
return JSONResponse(content={"accepted": True, "composed_at": composed_at})
@app.post("/v1/reuse-events", status_code=201, dependencies=[Depends(_require_auth)])
async def record_reuse_event(request: Request) -> dict[str, Any]:
payload = await request.json()
try:
return store.record_reuse_event(payload)
except ValueError as exc:
raise _http_error(400, "validation_error", str(exc)) from exc
@app.get("/v1/reuse-events")
def list_reuse_events(
capability_id: str | None = Query(default=None),
) -> dict[str, Any]:
events = store.list_reuse_events(capability_id)
return {"count": len(events), "events": events}
return app

View file

@ -10,6 +10,11 @@ import yaml
from jsonschema import Draft202012Validator
SCHEMA_PATH = Path(__file__).resolve().parent / "hub-registration.schema.yaml"
REUSE_EVENT_SCHEMA_PATH = Path(__file__).resolve().parent.parent.parent / "schemas" / "reuse-event.schema.json"
def _load_reuse_event_schema() -> dict[str, Any]:
return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
def _utc_now() -> str:
@ -78,6 +83,67 @@ class HubStore:
conn.execute(
"INSERT OR IGNORE INTO compose_state (id, composed_at, stale) VALUES (1, NULL, 0)"
)
# Reuse telemetry (REUSE-WP-0019-T04): append-only facts, never
# hand-edited. Schema shared with the local JSONL fallback in
# plan_check.py's record_outcome() -- see schemas/reuse-event.schema.json.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS reuse_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
consumer_repo TEXT NOT NULL,
capability_id TEXT,
verdict TEXT NOT NULL,
outcome TEXT,
source TEXT NOT NULL
)
"""
)
def record_reuse_event(self, payload: dict[str, Any]) -> dict[str, Any]:
errors = [
error.message
for error in Draft202012Validator(_load_reuse_event_schema()).iter_errors(payload)
]
if errors:
raise ValueError("; ".join(errors))
with self._connect() as conn:
conn.execute(
"""
INSERT INTO reuse_events (ts, consumer_repo, capability_id, verdict, outcome, source)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
payload["ts"],
payload["consumer_repo"],
payload.get("capability_id"),
payload["verdict"],
payload.get("outcome"),
payload["source"],
),
)
return payload
def list_reuse_events(self, capability_id: str | None = None) -> list[dict[str, Any]]:
query = "SELECT ts, consumer_repo, capability_id, verdict, outcome, source FROM reuse_events"
params: tuple[Any, ...] = ()
if capability_id:
query += " WHERE capability_id = ?"
params = (capability_id,)
query += " ORDER BY ts"
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [
{
"ts": row["ts"],
"consumer_repo": row["consumer_repo"],
"capability_id": row["capability_id"],
"verdict": row["verdict"],
"outcome": row["outcome"],
"source": row["source"],
}
for row in rows
]
def list_repos(self) -> list[dict[str, Any]]:
with self._connect() as conn: