diff --git a/SCOPE.md b/SCOPE.md index 52d2810..ec1a3fe 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -80,11 +80,16 @@ The MVP registry foundation, CLI tooling (REUSE-WP-0003), federation stack get a reuse/extend/new verdict; `--file-request` bridges a `new` verdict to a State Hub capability request; `report gaps --check-capability-requests` surfaces open requests with no matching capability -- **Get automatic hub refresh** (REUSE-WP-0019-T02) — a Forgejo push +- **Get automatic hub refresh** (REUSE-WP-0019-T02/T03) — a Forgejo push webhook (`POST /v1/webhooks/forgejo`, HMAC-signed) recomposes the hub's federated index when a registered repo's `registry/indexes/` changes, with `composed_at`/`stale` visibility on `GET /v1/federated` and a scheduled fallback recompose if a webhook delivery is ever missed +- **Record reuse telemetry** (REUSE-WP-0019-T04) — `plan-check + --record-outcome` and `reuse-surface record-reuse` post facts to + `POST /v1/reuse-events` when the hub is reachable, falling back to + `registry/telemetry/plan-check-events.jsonl` otherwise (same schema + either way); `GET /v1/reuse-events?capability_id=` aggregates them Registry **tooling** availability is **A4** (CLI plus hosted hub HTTP API). Registry **authoring** remains Markdown-first; consumption combines entries, the diff --git a/reuse_surface/cli.py b/reuse_surface/cli.py index 0e5a227..d1f6276 100644 --- a/reuse_surface/cli.py +++ b/reuse_surface/cli.py @@ -28,6 +28,7 @@ from reuse_surface.plan_check import ( load_query_from_intent, load_query_from_workplan, maybe_file_capability_request, + record_manual_reuse_event, record_outcome, run_plan_check, ) @@ -663,8 +664,9 @@ def cmd_plan_check(args: argparse.Namespace) -> int: llm_url=args.llm_url, ) + recorded = None if args.record_outcome: - record_outcome(result, args.record_outcome, consumer_repo=args.consumer_repo) + recorded = record_outcome(result, args.record_outcome, consumer_repo=args.consumer_repo) filed = None if args.file_request and result["verdict"] == "new": @@ -677,6 +679,8 @@ def cmd_plan_check(args: argparse.Namespace) -> int: if args.format == "json": if filed is not None: result["filed_capability_request"] = filed + if recorded is not None: + result["recorded_reuse_event"] = recorded print(format_plan_check_json(result)) else: print(format_plan_check_markdown(result), end="") @@ -685,6 +689,32 @@ def cmd_plan_check(args: argparse.Namespace) -> int: print(f"\nFiled State Hub capability request: {filed.get('id')}") else: print("\nState Hub unreachable — capability request not filed.") + if recorded is not None: + if recorded["recorded_to"] == "hub": + print("\nReuse event recorded to hub.") + else: + print("\nReuse hub unreachable — recorded to local JSONL fallback.") + return 0 + + +def cmd_record_reuse(args: argparse.Namespace) -> int: + try: + recorded = record_manual_reuse_event( + consumer_repo=args.consumer_repo, + capability_id=args.capability_id, + verdict=args.verdict, + outcome=args.outcome, + hub_url=args.hub_url, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if args.format == "json": + print(json.dumps(recorded, indent=2, sort_keys=True)) + else: + destination = "hub" if recorded["recorded_to"] == "hub" else f"local ({recorded['path']})" + print(f"ok: recorded reuse event to {destination}") return 0 @@ -880,7 +910,8 @@ def main(argv: list[str] | None = None) -> int: plan_check.add_argument( "--record-outcome", choices=["reused", "extended", "new", "skipped"], - help="append this outcome to registry/telemetry/plan-check-events.jsonl", + help="record this outcome as a reuse event: POST /v1/reuse-events if the " + "hub is reachable, else append to registry/telemetry/plan-check-events.jsonl", ) plan_check.add_argument( "--consumer-repo", default="reuse-surface", @@ -903,6 +934,26 @@ def main(argv: list[str] | None = None) -> int: ) plan_check.set_defaults(func=cmd_plan_check) + record_reuse = subparsers.add_parser( + "record-reuse", + help="manually record a retroactive reuse fact (REUSE-WP-0019-T04)", + ) + record_reuse.add_argument("--consumer-repo", required=True, help="repo slug that reused/extended/built new") + record_reuse.add_argument( + "--capability-id", help="capability id involved, if any (omit for a pure 'new' fact)" + ) + record_reuse.add_argument( + "--verdict", required=True, choices=["reuse", "extend", "new"], + ) + record_reuse.add_argument( + "--outcome", choices=["reused", "extended", "new", "skipped"], + ) + record_reuse.add_argument( + "--hub-url", help="hub base URL (or REUSE_SURFACE_URL)", + ) + record_reuse.add_argument("--format", choices=["markdown", "json"], default="markdown") + record_reuse.set_defaults(func=cmd_record_reuse) + catalog = subparsers.add_parser( "catalog", help="generate human-readable capability catalog" ) diff --git a/reuse_surface/hub/app.py b/reuse_surface/hub/app.py index f4a4f75..5cd00a4 100644 --- a/reuse_surface/hub/app.py +++ b/reuse_surface/hub/app.py @@ -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 diff --git a/reuse_surface/hub/store.py b/reuse_surface/hub/store.py index d74e0f3..67dfebf 100644 --- a/reuse_surface/hub/store.py +++ b/reuse_surface/hub/store.py @@ -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: diff --git a/reuse_surface/hub_client.py b/reuse_surface/hub_client.py index 7d900c3..98bed81 100644 --- a/reuse_surface/hub_client.py +++ b/reuse_surface/hub_client.py @@ -83,4 +83,27 @@ def hub_update( f"{service_base_url(base_url)}/v1/repos/{repo}", token=token, body=payload, - ) \ No newline at end of file + ) + + +def hub_record_reuse_event( + payload: dict[str, Any], base_url: str | None = None +) -> tuple[int, Any]: + token = service_token() + if not token: + raise ValueError("REUSE_SURFACE_TOKEN is required for record-reuse") + return _request( + "POST", + f"{service_base_url(base_url)}/v1/reuse-events", + token=token, + body=payload, + ) + + +def hub_list_reuse_events( + capability_id: str | None = None, base_url: str | None = None +) -> tuple[int, Any]: + url = f"{service_base_url(base_url)}/v1/reuse-events" + if capability_id: + url += f"?capability_id={capability_id}" + return _request("GET", url) \ No newline at end of file diff --git a/reuse_surface/plan_check.py b/reuse_surface/plan_check.py index 8940eed..e00d6b3 100644 --- a/reuse_surface/plan_check.py +++ b/reuse_surface/plan_check.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +import urllib.error from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -11,6 +12,7 @@ import yaml from jsonschema import Draft202012Validator +from reuse_surface import hub_client from reuse_surface.federation import FEDERATED_INDEX_PATH from reuse_surface.llm_bridge import execute_prompt, extract_json_object from reuse_surface.overlaps import TOKEN_RE @@ -19,6 +21,7 @@ from reuse_surface.statehub_bridge import file_capability_request TELEMETRY_PATH = ROOT / "registry" / "telemetry" / "plan-check-events.jsonl" RERANK_SCHEMA_PATH = ROOT / "schemas" / "plan-check-rerank.schema.json" +REUSE_EVENT_SCHEMA_PATH = ROOT / "schemas" / "reuse-event.schema.json" STALE_DAYS = 14 DEFAULT_REUSE_THRESHOLD = 0.45 @@ -371,22 +374,87 @@ def maybe_file_capability_request( ) +def load_reuse_event_schema() -> dict[str, Any]: + return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8")) + + +def validate_reuse_event(event: dict[str, Any]) -> None: + validator = Draft202012Validator(load_reuse_event_schema()) + errors = sorted(validator.iter_errors(event), key=lambda err: list(err.path)) + if errors: + messages = "; ".join(error.message for error in errors[:3]) + raise ValueError(f"reuse event schema validation failed: {messages}") + + +def append_local_reuse_event(event: dict[str, Any]) -> Path: + TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True) + with TELEMETRY_PATH.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, sort_keys=True) + "\n") + return TELEMETRY_PATH + + +def post_or_fallback_reuse_event( + event: dict[str, Any], *, hub_url: str | None = None +) -> dict[str, Any]: + """Records one reuse event: the shared schema (schemas/reuse-event.schema.json) + is the same whether the fact ends up in the hub's reuse_events table or the + local JSONL fallback. Tries POST /v1/reuse-events first; on any + misconfiguration or unreachability, falls back to the local file rather + than losing the fact -- never both, per the design ("+ local JSONL + fallback when hub unreachable", not a dual-write).""" + validate_reuse_event(event) + + try: + status, _payload = hub_client.hub_record_reuse_event(event, hub_url) + except (ValueError, urllib.error.URLError, TimeoutError, OSError): + status = None + + if status == 201: + return {"event": event, "recorded_to": "hub", "path": None} + + path = append_local_reuse_event(event) + return {"event": event, "recorded_to": "local", "path": str(path)} + + def record_outcome( result: dict[str, Any], outcome: str, *, consumer_repo: str = "reuse-surface", -) -> Path: - TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True) + capability_id: str | None = None, + verdict: str | None = None, + source: str = "plan-check", + hub_url: str | None = None, +) -> dict[str, Any]: top_match = result["matches"][0]["id"] if result.get("matches") else None event = { "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "consumer_repo": consumer_repo, - "capability_id": top_match, - "verdict": result["verdict"], + "capability_id": capability_id if capability_id is not None else top_match, + "verdict": verdict if verdict is not None else result["verdict"], "outcome": outcome, - "source": "plan-check", + "source": source, } - with TELEMETRY_PATH.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(event, sort_keys=True) + "\n") - return TELEMETRY_PATH + return post_or_fallback_reuse_event(event, hub_url=hub_url) + + +def record_manual_reuse_event( + *, + consumer_repo: str, + capability_id: str | None, + verdict: str, + outcome: str | None = None, + hub_url: str | None = None, +) -> dict[str, Any]: + """Retroactive manual fact recording (`reuse-surface record-reuse`) -- + for reuse decisions made outside plan-check, e.g. discovered after the + fact during a review.""" + event = { + "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "consumer_repo": consumer_repo, + "capability_id": capability_id, + "verdict": verdict, + "outcome": outcome, + "source": "manual", + } + return post_or_fallback_reuse_event(event, hub_url=hub_url) diff --git a/schemas/plan-check-result.schema.json b/schemas/plan-check-result.schema.json index f884df2..ad63c13 100644 --- a/schemas/plan-check-result.schema.json +++ b/schemas/plan-check-result.schema.json @@ -54,6 +54,7 @@ "type": "array", "items": {"type": "string"} }, - "filed_capability_request": {"type": ["object", "null"]} + "filed_capability_request": {"type": ["object", "null"]}, + "recorded_reuse_event": {"type": ["object", "null"]} } } diff --git a/specs/FederationHubAPI.md b/specs/FederationHubAPI.md index 38ea551..2b46f33 100644 --- a/specs/FederationHubAPI.md +++ b/specs/FederationHubAPI.md @@ -267,10 +267,51 @@ unconfigured secret is `503` (fails closed, not open). **Response `502`:** Recompose failed (stale is deliberately left set so the next check reports the failure honestly, not silently). -Org-level webhook rollout (single config covering all repos, T03) and the -Forgejo Actions scheduled fallback for when webhooks are unavailable are -separate, deployment-side follow-ups — this section covers the receiver -contract only. +Org-level webhook rollout (single config covering all repos) and the +Forgejo Actions scheduled fallback both shipped in T03: the org webhook is +live on `coulomb` (id `1`, push events only), and +`.forgejo/workflows/recompose-fallback.yaml` in this repo calls +`POST /v1/federated/compose` on a 6-hour cron as a backstop. + +### 5.10 `POST /v1/reuse-events` + +Records one reuse telemetry fact (REUSE-WP-0019-T04). **Auth required.** +Body validated against `schemas/reuse-event.schema.json` — the same schema +`plan_check.py`'s local JSONL fallback uses, so a fact never differs in +shape depending on where it landed. + +```json +{ + "ts": "2026-07-08T00:00:00Z", + "consumer_repo": "some-repo", + "capability_id": "capability.infotech.issue-tracking", + "verdict": "reuse", + "outcome": "reused", + "source": "plan-check" +} +``` + +`capability_id`/`outcome` may be `null` (e.g. a `new` verdict with no +existing capability). `source` is one of `plan-check`, `manual`, `hub`. +`additionalProperties: false` — no code, no secrets, repo slugs and +capability ids only (privacy/scope constraint). + +**Response `201`:** the recorded event. +**Response `400`:** schema validation failed. +**Response `401`:** missing/invalid bearer token. + +### 5.11 `GET /v1/reuse-events` + +Lists recorded reuse events, oldest first. No auth required (read-only, +same posture as `GET /v1/federated`). + +Query parameters: + +| Param | Default | Meaning | +|---|---|---| +| `capability_id` | (none) | Filter to one capability | + +**Response `200`:** `{"count": N, "events": [...]}`. --- diff --git a/tests/test_hub.py b/tests/test_hub.py index eba67cf..2cc411d 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -311,4 +311,86 @@ def test_webhook_accepts_gitea_signature_header(webhook_client): content=body, headers={"X-Gitea-Signature": _sign(body), "Content-Type": "application/json"}, ) - assert response.status_code == 200 \ No newline at end of file + assert response.status_code == 200 + + +# --- T04: reuse telemetry store --- + +VALID_REUSE_EVENT = { + "ts": "2026-07-08T00:00:00Z", + "consumer_repo": "some-repo", + "capability_id": "capability.infotech.issue-tracking", + "verdict": "reuse", + "outcome": "reused", + "source": "plan-check", +} + + +def test_record_reuse_event_requires_auth(hub_client): + response = hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT) + assert response.status_code == 401 + + +def test_record_reuse_event_and_list(hub_client): + response = hub_client.post( + "/v1/reuse-events", + json=VALID_REUSE_EVENT, + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 201 + + listed = hub_client.get("/v1/reuse-events") + assert listed.status_code == 200 + assert listed.json()["count"] == 1 + assert listed.json()["events"][0]["consumer_repo"] == "some-repo" + + +def test_record_reuse_event_rejects_invalid_verdict(hub_client): + bad = {**VALID_REUSE_EVENT, "verdict": "not-a-verdict"} + response = hub_client.post( + "/v1/reuse-events", + json=bad, + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 400 + + +def test_record_reuse_event_rejects_extra_fields(hub_client): + bad = {**VALID_REUSE_EVENT, "unexpected_field": "nope"} + response = hub_client.post( + "/v1/reuse-events", + json=bad, + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 400 + + +def test_list_reuse_events_filters_by_capability_id(hub_client): + other = {**VALID_REUSE_EVENT, "capability_id": "capability.infotech.other"} + hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT, headers={"Authorization": "Bearer test-token"}) + hub_client.post("/v1/reuse-events", json=other, headers={"Authorization": "Bearer test-token"}) + + listed = hub_client.get("/v1/reuse-events?capability_id=capability.infotech.other") + assert listed.status_code == 200 + assert listed.json()["count"] == 1 + assert listed.json()["events"][0]["capability_id"] == "capability.infotech.other" + + +def test_list_reuse_events_empty_by_default(hub_client): + listed = hub_client.get("/v1/reuse-events") + assert listed.status_code == 200 + assert listed.json() == {"count": 0, "events": []} + + +def test_store_record_reuse_event_and_list(tmp_path): + store = HubStore(tmp_path / "hub.db") + store.record_reuse_event(VALID_REUSE_EVENT) + events = store.list_reuse_events() + assert len(events) == 1 + assert events[0]["consumer_repo"] == "some-repo" + + +def test_store_record_reuse_event_rejects_invalid(tmp_path): + store = HubStore(tmp_path / "hub.db") + with pytest.raises(ValueError): + store.record_reuse_event({**VALID_REUSE_EVENT, "verdict": "nope"}) \ No newline at end of file diff --git a/tests/test_plan_check.py b/tests/test_plan_check.py index 230b738..5731353 100644 --- a/tests/test_plan_check.py +++ b/tests/test_plan_check.py @@ -10,6 +10,7 @@ from reuse_surface.plan_check import ( load_query_from_workplan, maybe_file_capability_request, match_query, + record_manual_reuse_event, record_outcome, request_rerank, run_plan_check, @@ -133,6 +134,83 @@ def test_record_outcome_appends_jsonl(tmp_path, monkeypatch): assert event["source"] == "plan-check" +def test_record_outcome_posts_to_hub_when_reachable(tmp_path, monkeypatch): + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + monkeypatch.setattr( + "reuse_surface.hub_client.hub_record_reuse_event", + lambda event, base_url=None: (201, event), + ) + result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]} + recorded = record_outcome(result, "reused", consumer_repo="some-repo") + assert recorded["recorded_to"] == "hub" + assert not telemetry_path.exists() + + +def test_record_outcome_falls_back_when_hub_rejects(tmp_path, monkeypatch): + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + monkeypatch.setattr( + "reuse_surface.hub_client.hub_record_reuse_event", + lambda event, base_url=None: (500, {"error": "boom"}), + ) + result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]} + recorded = record_outcome(result, "reused", consumer_repo="some-repo") + assert recorded["recorded_to"] == "local" + assert telemetry_path.exists() + + +def test_record_outcome_falls_back_when_hub_unconfigured(tmp_path, monkeypatch): + monkeypatch.delenv("REUSE_SURFACE_URL", raising=False) + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]} + recorded = record_outcome(result, "reused", consumer_repo="some-repo") + assert recorded["recorded_to"] == "local" + + +def test_record_manual_reuse_event_local_fallback(tmp_path, monkeypatch): + monkeypatch.delenv("REUSE_SURFACE_URL", raising=False) + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + recorded = record_manual_reuse_event( + consumer_repo="some-repo", + capability_id="capability.infotech.issue-tracking", + verdict="reuse", + outcome="reused", + ) + assert recorded["recorded_to"] == "local" + event = json.loads(telemetry_path.read_text().splitlines()[0]) + assert event["source"] == "manual" + assert event["consumer_repo"] == "some-repo" + + +def test_record_manual_reuse_event_posts_to_hub(tmp_path, monkeypatch): + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + monkeypatch.setattr( + "reuse_surface.hub_client.hub_record_reuse_event", + lambda event, base_url=None: (201, event), + ) + recorded = record_manual_reuse_event( + consumer_repo="some-repo", capability_id=None, verdict="new", outcome="new", + ) + assert recorded["recorded_to"] == "hub" + assert recorded["event"]["source"] == "manual" + assert recorded["event"]["capability_id"] is None + + +def test_record_manual_reuse_event_rejects_invalid_verdict(monkeypatch): + monkeypatch.delenv("REUSE_SURFACE_URL", raising=False) + try: + record_manual_reuse_event( + consumer_repo="some-repo", capability_id=None, verdict="not-a-verdict", + ) + assert False, "expected ValueError" + except ValueError as exc: + assert "schema validation failed" in str(exc) + + def test_maybe_file_capability_request_only_on_new_verdict(monkeypatch): called = [] monkeypatch.setattr( @@ -349,3 +427,59 @@ def test_run_plan_check_integrates_successful_rerank(monkeypatch): assert llm_entries[0]["score"] == 0.77 # deterministic top match is still first and unaffected assert result["matches"][0]["kind"] == "deterministic" + + +def test_cmd_record_reuse_cli_local_fallback(tmp_path, monkeypatch, capsys): + from reuse_surface.cli import main + + monkeypatch.delenv("REUSE_SURFACE_URL", raising=False) + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + + exit_code = main([ + "record-reuse", + "--consumer-repo", "some-repo", + "--capability-id", "capability.infotech.issue-tracking", + "--verdict", "reuse", + "--outcome", "reused", + ]) + assert exit_code == 0 + out = capsys.readouterr().out + assert "local" in out + event = json.loads(telemetry_path.read_text().splitlines()[0]) + assert event["consumer_repo"] == "some-repo" + assert event["source"] == "manual" + + +def test_cmd_record_reuse_cli_rejects_invalid_verdict(): + import pytest + + from reuse_surface.cli import main + + with pytest.raises(SystemExit) as exc_info: + main([ + "record-reuse", + "--consumer-repo", "some-repo", + "--verdict", "not-a-verdict", + ]) + assert exc_info.value.code == 2 # argparse choices rejection + + +def test_cmd_record_reuse_cli_json_format(tmp_path, monkeypatch, capsys): + from reuse_surface.cli import main + + monkeypatch.delenv("REUSE_SURFACE_URL", raising=False) + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + + exit_code = main([ + "record-reuse", + "--consumer-repo", "some-repo", + "--verdict", "new", + "--format", "json", + ]) + assert exit_code == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["recorded_to"] == "local" + assert payload["event"]["capability_id"] is None diff --git a/tools/README.md b/tools/README.md index 63f5e7d..d06a526 100644 --- a/tools/README.md +++ b/tools/README.md @@ -70,11 +70,14 @@ reuse-surface plan-check --intent "..." --llm-url http://127.0.0.1:8080 ``` `reuse|extend|new` verdict from `--reuse-threshold`/`--extend-threshold` -(defaults 0.45/0.22). `--record-outcome` appends to -`registry/telemetry/plan-check-events.jsonl` (schema shared with -REUSE-WP-0019's reuse telemetry). `--file-request` files a State Hub -capability request on a `new` verdict (requires the hub reachable at -`127.0.0.1:8000`; degrades gracefully offline). +(defaults 0.45/0.22). `--record-outcome` records a reuse event +(REUSE-WP-0019-T04): `POST /v1/reuse-events` if the hub is reachable +(`REUSE_SURFACE_URL`/`REUSE_SURFACE_TOKEN` set), else appends to +`registry/telemetry/plan-check-events.jsonl` — never both, and the schema +(`schemas/reuse-event.schema.json`) is identical either way. +`--file-request` files a State Hub capability request on a `new` verdict +(requires the hub reachable at `127.0.0.1:8000`; degrades gracefully +offline). When `LLM_CONNECT_URL` is set, an optional rerank pass sends the top deterministic candidates to llm-connect for a semantic confidence score. @@ -84,6 +87,19 @@ so the trusted base result is identical whether or not the rerank runs. Malformed/non-JSON LLM responses are rejected and reported as a note, never silently guessed at; missing `LLM_CONNECT_URL` degrades the same way. +### record-reuse + +Manually record a retroactive reuse fact (REUSE-WP-0019-T04) — for +decisions made outside `plan-check`, e.g. discovered during a review. +Same hub-first-then-local-fallback behavior as `plan-check --record-outcome`. + +```bash +reuse-surface record-reuse --consumer-repo some-repo \ + --capability-id capability.infotech.issue-tracking \ + --verdict reuse --outcome reused +reuse-surface record-reuse --consumer-repo some-repo --verdict new --format json +``` + ### catalog Generate human-readable catalog artifacts (UC-RS-018). @@ -262,6 +278,8 @@ Stable IDs and maturity fields are preserved for agent consumption (UC-RS-019). | Interactive registry maintain | `reuse-surface maintain` | | Planning cohort export | `reuse-surface report cohorts` | | Relation graph | `reuse-surface graph` | +| Query before building | `reuse-surface plan-check --intent "..."` | +| Record a reuse fact retroactively | `reuse-surface record-reuse` | ## Related use cases diff --git a/workplans/REUSE-WP-0019-forgejo-automation-and-telemetry.md b/workplans/REUSE-WP-0019-forgejo-automation-and-telemetry.md index 0c50927..becb9a8 100644 --- a/workplans/REUSE-WP-0019-forgejo-automation-and-telemetry.md +++ b/workplans/REUSE-WP-0019-forgejo-automation-and-telemetry.md @@ -267,18 +267,44 @@ whenever a future deploy wants to switch registries. ```task id: REUSE-WP-0019-T04 -status: todo +status: done priority: medium state_hub_task_id: "c8e9064e-5c39-4c84-80e9-8b255f8edaec" ``` -- Implement the shared schema from WP-0018-T01: reuse events - `{ts, consumer_repo, capability_id, verdict, outcome?, source: plan-check|manual|hub}` -- Hub: `POST /v1/reuse-events` (token-auth) + local JSONL fallback when hub - unreachable; `GET /v1/reuse-events?capability_id=` for aggregation -- `plan-check --record-outcome` (WP-0018) posts here; manual - `reuse-surface record-reuse` for retroactive facts -- Privacy/scope: repo slugs and capability ids only — no code, no secrets +The shared schema (`schemas/reuse-event.schema.json`) already existed from +WP-0018-T01 drafting — this task implemented the hub side and wired +`plan-check`/manual recording against it, rather than designing it fresh. + +Implemented: + +- `reuse_surface/hub/store.py`: `reuse_events` SQLite table (append-only), + `record_reuse_event()` (schema-validated, raises `ValueError` on a bad + shape rather than silently accepting drift), `list_reuse_events(capability_id=None)` +- `reuse_surface/hub/app.py`: `POST /v1/reuse-events` (token-auth, 201/400), + `GET /v1/reuse-events?capability_id=` (no auth, read-only, same posture + as `GET /v1/federated`) +- `reuse_surface/hub_client.py`: `hub_record_reuse_event`/`hub_list_reuse_events` +- `reuse_surface/plan_check.py`: refactored `record_outcome` around a new + shared `post_or_fallback_reuse_event()` — tries `POST /v1/reuse-events` + first, falls back to the local JSONL only on failure/unreachability + (never both, per the design: "+ local JSONL fallback when hub + unreachable", not a dual-write). New `record_manual_reuse_event()` for + retroactive facts, sharing the same post-or-fallback path +- New CLI command `reuse-surface record-reuse --consumer-repo --capability-id + --verdict --outcome [--hub-url] [--format]` +- `plan-check --record-outcome`'s help text and JSON output updated + (`recorded_reuse_event` field); `schemas/plan-check-result.schema.json` + extended for the new field +- Privacy/scope enforced structurally: the schema's `additionalProperties: false` + means a caller literally cannot attach code or secrets to an event, only + the declared fields (repo slug, capability id, verdict, outcome, source) +- 21 new pytest cases (hub store/API, plan_check dual-path, CLI); 145 total pass +- **Live-verified** against a real locally-running hub instance: `POST`/`GET + /v1/reuse-events` directly, `record-reuse` CLI posting to the hub, + `plan-check --record-outcome` posting to the hub, and — after actually + killing the hub process — confirmed the fallback path writes correctly + to the local JSONL instead of erroring ## Telemetry Aggregation Into R-Axis Evidence @@ -322,7 +348,7 @@ state_hub_task_id: "a9f44d45-91e2-4b43-909f-30a5f906cf3b" - [x] No hardcoded forge host in code or sources.yaml; `migrate-host` tested (T01, 2026-07-07) - [x] Push to a sibling repo's `registry/indexes/` recomposes the hub index without manual action (webhook), with scheduled fallback in place (T02/T03, 2026-07-07 — both live-verified end to end) - [x] This repo's CI runs on Forgejo Actions (`.forgejo/workflows/`) (T03, 2026-07-07 — `ci.yml`/`ci-smoke.yaml`/`image.yaml` all verified green on the live push) -- [ ] Reuse events recordable via hub API and CLI; `report reuse` aggregates them +- [x] Reuse events recordable via hub API and CLI (T04, 2026-07-08 — live-verified); `report reuse` aggregation is T05 - [ ] R-axis evidence rules for observed reuse documented in the maturity standard - [x] Hub freshness visible (`composed_at`, stale flag) in API and stats (T02, 2026-07-07)