diff --git a/tests/test_workbench_construction_endpoint.py b/tests/test_workbench_construction_endpoint.py new file mode 100644 index 00000000..b4251a3f --- /dev/null +++ b/tests/test_workbench_construction_endpoint.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from workbench.construction_endpoint import ( + construction_evidence_response, + construction_turn_id_from_path, +) + + +@dataclass(frozen=True, slots=True) +class _Entry: + turn_id: int + + +class _Journal: + def __init__(self, entries: dict[int, Any]) -> None: + self._entries = entries + + def get_entry(self, turn_id: int) -> Any: + try: + return self._entries[turn_id] + except KeyError: + raise FileNotFoundError(str(turn_id)) from None + + +def test_construction_turn_id_from_path_matches_only_construction_route() -> None: + assert construction_turn_id_from_path("/trace/7/construction") == "7" + assert construction_turn_id_from_path("/trace/7/pipeline") is None + assert construction_turn_id_from_path("/trace/7") is None + assert construction_turn_id_from_path("/trace//construction") is None + + +def test_construction_evidence_response_returns_missing_evidence_for_legacy_turn() -> None: + response = construction_evidence_response(_Journal({7: _Entry(turn_id=7)}), "7") + + assert response.status == 200 + assert response.payload["ok"] is True + data = response.payload["data"] + assert data["schema_version"] == "construction_evidence_v1" + assert data["turn_id"] == 7 + assert data["status"] == "missing_evidence" + assert data["diagnostic_only"] is True + assert data["serving_allowed"] is False + + +def test_construction_evidence_response_returns_404_for_bad_turn_id() -> None: + response = construction_evidence_response(_Journal({}), "not-an-int") + + assert response.status == 404 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "not_found" + assert "not-an-int" in response.payload["error"]["message"] + + +def test_construction_evidence_response_returns_404_for_missing_turn() -> None: + response = construction_evidence_response(_Journal({}), "8") + + assert response.status == 404 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "not_found" + assert "8" in response.payload["error"]["message"] diff --git a/workbench-ui/src/app/trace/constructionEvidenceEndpoint.test.ts b/workbench-ui/src/app/trace/constructionEvidenceEndpoint.test.ts new file mode 100644 index 00000000..8aebcc2e --- /dev/null +++ b/workbench-ui/src/app/trace/constructionEvidenceEndpoint.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ConstructionEvidence } from "../../types/constructionEvidence"; +import { + TRACE_CONSTRUCTION_EMPTY_LABEL, + TRACE_CONSTRUCTION_ERROR_MUTATION_STATUS, + TRACE_CONSTRUCTION_LOADING_LABEL, + fetchTraceConstructionWith, + traceConstructionPath, + traceConstructionReproducer, +} from "./constructionEvidenceEndpoint"; + +const evidence: ConstructionEvidence = { + schema_version: "construction_evidence_v1", + turn_id: 7, + status: "missing_evidence", + missing_reason: "construction evidence was not persisted for this turn", + problem_text: null, + proposals: [], + mentions: [], + bindings: [], + bound_relations: [], + contract_assessments: [], + diagnostic_only: true, + serving_allowed: false, +}; + +describe("construction evidence endpoint helpers", () => { + it("builds the trace construction endpoint path", () => { + expect(traceConstructionPath(7)).toBe("/trace/7/construction"); + }); + + it("fetches construction evidence through an injected fetcher", async () => { + const fetcher = vi.fn(async () => evidence); + + await expect(fetchTraceConstructionWith(7, fetcher)).resolves.toBe(evidence); + expect(fetcher).toHaveBeenCalledWith("/trace/7/construction"); + }); + + it("defines loading/empty/error labels for route conformance", () => { + expect(TRACE_CONSTRUCTION_LOADING_LABEL).toBe("Loading construction evidence..."); + expect(TRACE_CONSTRUCTION_EMPTY_LABEL).toBe("No construction evidence recorded for this turn."); + expect(TRACE_CONSTRUCTION_ERROR_MUTATION_STATUS).toBe("No trace mutation occurred."); + }); + + it("builds a copyable reproducer", () => { + expect(traceConstructionReproducer(7)).toBe("curl /trace/7/construction"); + }); +}); diff --git a/workbench-ui/src/app/trace/constructionEvidenceEndpoint.ts b/workbench-ui/src/app/trace/constructionEvidenceEndpoint.ts new file mode 100644 index 00000000..1345c3e1 --- /dev/null +++ b/workbench-ui/src/app/trace/constructionEvidenceEndpoint.ts @@ -0,0 +1,20 @@ +import type { ConstructionEvidence } from "../../types/constructionEvidence"; + +export function traceConstructionPath(turnId: number): string { + return `/trace/${encodeURIComponent(String(turnId))}/construction`; +} + +export async function fetchTraceConstructionWith( + turnId: number, + fetcher: (path: string) => Promise, +): Promise { + return fetcher(traceConstructionPath(turnId)); +} + +export const TRACE_CONSTRUCTION_LOADING_LABEL = "Loading construction evidence..."; +export const TRACE_CONSTRUCTION_EMPTY_LABEL = "No construction evidence recorded for this turn."; +export const TRACE_CONSTRUCTION_ERROR_MUTATION_STATUS = "No trace mutation occurred."; + +export function traceConstructionReproducer(turnId: number): string { + return `curl /trace/${encodeURIComponent(String(turnId))}/construction`; +} diff --git a/workbench/construction_endpoint.py b/workbench/construction_endpoint.py new file mode 100644 index 00000000..020899f7 --- /dev/null +++ b/workbench/construction_endpoint.py @@ -0,0 +1,71 @@ +"""Narrow construction-evidence endpoint seam for Workbench. + +This helper keeps the endpoint behavior testable without editing the large +`workbench.api` dispatch table from environments that cannot run local syntax and +route tests. The final API wiring is intentionally one line of dispatch: + + return construction_evidence_response(self._journal, raw_turn_id) + +The helper itself is read-only and projects only persisted construction evidence +through `construction_evidence_from_journal_entry`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from workbench.construction_evidence import construction_evidence_from_journal_entry +from workbench.schemas import error, ok + + +@dataclass(frozen=True, slots=True) +class ConstructionEndpointResponse: + status: int + payload: dict[str, Any] + + +def construction_evidence_response( + journal: Any, + raw_turn_id: str, +) -> ConstructionEndpointResponse: + """Return JSON-envelope payload for `/trace//construction`. + + The function is deliberately free of parsing/reconstruction side effects. It + reads a journal entry and projects construction evidence when already + persisted; legacy entries receive a typed `missing_evidence` data payload. + """ + + try: + turn_id = int(raw_turn_id) + except ValueError: + return ConstructionEndpointResponse( + 404, + error("not_found", f"trace construction not found: {raw_turn_id}"), + ) + + try: + entry = journal.get_entry(turn_id) + except FileNotFoundError: + return ConstructionEndpointResponse( + 404, + error("not_found", f"trace construction not found: {turn_id}"), + ) + + return ConstructionEndpointResponse( + 200, + ok(construction_evidence_from_journal_entry(entry)), + ) + + +def construction_turn_id_from_path(path: str) -> str | None: + """Extract raw turn id from `/trace//construction`. + + Returns None for non-matching paths so the main dispatch table can remain + explicit and order-safe. + """ + + if not (path.startswith("/trace/") and path.endswith("/construction")): + return None + raw = path.removeprefix("/trace/").removesuffix("/construction").strip("/") + return raw or None