feat(workbench): prepare construction evidence endpoint seam (#893)
* feat(workbench): add construction endpoint handler seam * test(workbench): cover construction endpoint handler seam * feat(workbench-ui): add construction evidence endpoint helpers * test(workbench-ui): cover construction endpoint helpers
This commit is contained in:
parent
2c8258fe1a
commit
4c23aa4b00
4 changed files with 202 additions and 0 deletions
63
tests/test_workbench_construction_endpoint.py
Normal file
63
tests/test_workbench_construction_endpoint.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
20
workbench-ui/src/app/trace/constructionEvidenceEndpoint.ts
Normal file
20
workbench-ui/src/app/trace/constructionEvidenceEndpoint.ts
Normal file
|
|
@ -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: <T>(path: string) => Promise<T>,
|
||||
): Promise<ConstructionEvidence> {
|
||||
return fetcher<ConstructionEvidence>(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`;
|
||||
}
|
||||
71
workbench/construction_endpoint.py
Normal file
71
workbench/construction_endpoint.py
Normal file
|
|
@ -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/<turn_id>/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/<turn_id>/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
|
||||
Loading…
Reference in a new issue