From 847dad94c4ea2e27115f6d4b513f76d11e60de3a Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Tue, 17 Mar 2026 11:57:33 +0100 Subject: [PATCH] Sprint 6E: live explain-why trace UI --- BUILD_REPORT.md | 240 +++++----------- REVIEW_REPORT.md | 49 ++-- apps/web/app/traces/loading.tsx | 51 ++++ apps/web/app/traces/page.tsx | 368 +++++++++++++++--------- apps/web/components/trace-list.test.tsx | 239 +++++++++++++++ apps/web/components/trace-list.tsx | 170 ++++++++--- apps/web/lib/api.test.ts | 98 +++++++ apps/web/lib/api.ts | 61 ++++ apps/web/lib/fixtures.ts | 177 ++++++++++++ 9 files changed, 1095 insertions(+), 358 deletions(-) create mode 100644 apps/web/app/traces/loading.tsx create mode 100644 apps/web/components/trace-list.test.tsx diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 7e8fc96..75117c9 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,198 +2,108 @@ ## sprint objective -Implement Sprint 6D by exposing deterministic user-scoped read-only trace review APIs for: - -- `GET /v0/traces` -- `GET /v0/traces/{trace_id}` -- `GET /v0/traces/{trace_id}/events` - -The sprint stayed limited to explain-why trace reads over existing persisted `traces` and `trace_events` data. +Implement Sprint 6E by replacing the fixture-only `/traces` route with a live explain-why review surface that uses the shipped backend trace review APIs when API configuration is present, while preserving explicit fixture fallback only when live configuration is absent. ## completed work -- introduced stable trace review contracts in `apps/api/src/alicebot_api/contracts.py` - - `TraceReviewSummaryRecord` - - `TraceReviewRecord` - - `TraceReviewListSummary` - - `TraceReviewListResponse` - - `TraceReviewDetailResponse` - - `TraceReviewEventRecord` - - `TraceReviewEventListSummary` - - `TraceReviewEventListResponse` - - `TRACE_REVIEW_LIST_ORDER` - - `TRACE_REVIEW_EVENT_LIST_ORDER` -- added a narrow trace review module in `apps/api/src/alicebot_api/traces.py` - - `list_trace_records()` - - `get_trace_record()` - - `list_trace_event_records()` - - `TraceNotFoundError` -- extended `apps/api/src/alicebot_api/store.py` with read-only trace review queries - - `list_trace_reviews()` - - `get_trace_review_optional()` - - deterministic list SQL with trace-event counts - - deterministic trace-event SQL ordering -- added FastAPI endpoints in `apps/api/src/alicebot_api/main.py` +- extended `apps/web/lib/api.ts` with typed trace review reads for: - `GET /v0/traces` - `GET /v0/traces/{trace_id}` - `GET /v0/traces/{trace_id}/events` -- added unit coverage in `tests/unit/test_traces.py` for - - deterministic list ordering - - stable detail shape - - stable event-list shape - - invisible-trace not-found behavior - - endpoint translation and 404 mapping -- added Postgres-backed integration coverage in `tests/integration/test_traces_api.py` for - - deterministic trace list ordering - - trace detail reads - - ordered trace-event reads - - cross-user isolation - - invisible-trace 404 behavior +- moved `/traces` fixture data into `apps/web/lib/fixtures.ts` and added `getFixtureTrace()` for explicit no-config fallback +- replaced `apps/web/app/traces/page.tsx` with live route wiring that: + - uses live trace list/detail/event reads when API configuration is present + - stays fixture-backed when API configuration is absent + - shows an explicit API-unavailable state when live trace reads fail + - keeps partial live detail bounded when detail or event reads fail +- added `apps/web/app/traces/loading.tsx` route-level loading UI +- updated `apps/web/components/trace-list.tsx` to render: + - live trace summaries + - key metadata + - ordered event review + - empty state + - API-unavailable state + - bounded partial event-unavailable state +- added narrow frontend coverage in: + - `apps/web/lib/api.test.ts` + - `apps/web/components/trace-list.test.tsx` + - route-level `/traces` branching coverage for fixture-backed, live-unavailable, and partial live-event states ## incomplete work -- none inside the sprint’s scoped backend deliverables -- no UI migration from fixture-backed `/traces` -- no trace creation or mutation changes -- no filtering, search, or expanded explainability surface beyond the three read endpoints +- none inside the sprint’s scoped `/traces` UI deliverables +- intentionally not added: + - trace filtering + - trace search + - trace pagination + - trace mutation UI + - backend changes ## files changed -- `apps/api/src/alicebot_api/contracts.py` -- `apps/api/src/alicebot_api/main.py` -- `apps/api/src/alicebot_api/store.py` -- `apps/api/src/alicebot_api/traces.py` -- `tests/unit/test_traces.py` -- `tests/integration/test_traces_api.py` +- `apps/web/app/traces/page.tsx` +- `apps/web/app/traces/loading.tsx` +- `apps/web/components/trace-list.tsx` +- `apps/web/lib/api.ts` +- `apps/web/lib/fixtures.ts` +- `apps/web/lib/api.test.ts` +- `apps/web/components/trace-list.test.tsx` - `BUILD_REPORT.md` -## exact ordering rules +## route backing mode + +- `/traces` is live-API-backed when API configuration is present +- `/traces` is fixture-backed when API configuration is absent +- `/traces` shows an explicit unavailable state when live API configuration is present but trace reads fail +- the route is not mixed in the steady-state implementation -- trace list reads use `created_at DESC, id DESC` -- trace-event reads use `sequence_no ASC, id ASC` +## backend endpoints consumed + +- `GET /v0/traces` +- `GET /v0/traces/{trace_id}` +- `GET /v0/traces/{trace_id}/events` ## tests run -- `./.venv/bin/python -m pytest tests/unit/test_traces.py` +- `npm run lint` - PASS - - `5` tests passed -- `./.venv/bin/python -m pytest tests/integration/test_traces_api.py` - - initial sandboxed run could not reach local Postgres on `localhost:5432` - - rerun as part of the full integration suite below passed -- `./.venv/bin/python -m pytest tests/unit` +- `npm test` - PASS - - `451` tests passed -- `./.venv/bin/python -m pytest tests/integration` + - `3` test files passed + - `13` tests passed +- `npm run build` - PASS - - `143` tests passed - -## unit and integration test results - -- unit result: PASS - - trace review module coverage and endpoint translation coverage passed -- integration result: PASS - - live Postgres-backed trace review list/detail/event and isolation coverage passed - -## example trace list response - -```json -{ - "items": [ - { - "id": "00000000-0000-4000-8000-000000000002", - "thread_id": "11111111-1111-4111-8111-111111111111", - "kind": "tool.proxy.execute", - "compiler_version": "response_generation_v0", - "status": "completed", - "created_at": "2026-03-17T09:00:00+00:00", - "trace_event_count": 2 - }, - { - "id": "00000000-0000-4000-8000-000000000001", - "thread_id": "11111111-1111-4111-8111-111111111111", - "kind": "context.compile", - "compiler_version": "continuity_v0", - "status": "completed", - "created_at": "2026-03-17T09:00:00+00:00", - "trace_event_count": 1 - } - ], - "summary": { - "total_count": 2, - "order": ["created_at_desc", "id_desc"] - } -} -``` - -## example trace detail response - -```json -{ - "trace": { - "id": "00000000-0000-4000-8000-000000000002", - "thread_id": "11111111-1111-4111-8111-111111111111", - "kind": "tool.proxy.execute", - "compiler_version": "response_generation_v0", - "status": "completed", - "limits": { - "max_sessions": 1, - "max_events": 2 - }, - "created_at": "2026-03-17T09:00:00+00:00", - "trace_event_count": 2 - } -} -``` - -## example trace-event list response - -```json -{ - "items": [ - { - "id": "10000000-0000-4000-8000-000000000002", - "trace_id": "00000000-0000-4000-8000-000000000002", - "sequence_no": 1, - "kind": "tool.proxy.execute.request", - "payload": { - "approval_id": "approval-2" - }, - "created_at": "2026-03-17T09:00:00+00:00" - }, - { - "id": "10000000-0000-4000-8000-000000000001", - "trace_id": "00000000-0000-4000-8000-000000000002", - "sequence_no": 2, - "kind": "tool.proxy.execute.summary", - "payload": { - "approval_id": "approval-2" - }, - "created_at": "2026-03-17T09:00:00+00:00" - } - ], - "summary": { - "trace_id": "00000000-0000-4000-8000-000000000002", - "total_count": 2, - "order": ["sequence_no_asc", "id_asc"] - } -} -``` + +## exact commands run + +- `cd /Users/samirusani/Desktop/Codex/AliceBot/apps/web && npm run lint` +- `cd /Users/samirusani/Desktop/Codex/AliceBot/apps/web && npm test` +- `cd /Users/samirusani/Desktop/Codex/AliceBot/apps/web && npm run build` + +## lint, test, and build results + +- lint result: PASS +- test result: PASS +- build result: PASS + +## desktop and mobile visual verification notes + +- no browser-based visual QA pass was executed in this turn +- desktop note: code inspection indicates the existing split review layout remains in place for `/traces`, with summary, metadata, and ordered events kept in bounded cards +- mobile note: code inspection indicates the trace route still collapses to one column below the shared shell breakpoints in `apps/web/app/globals.css` ## blockers/issues -- no remaining product-scope blockers -- one execution-time environment issue occurred during verification - - sandboxed integration setup could not connect to local Postgres on `localhost:5432` - - rerunning the required integration suite with local database access resolved verification +- no implementation blockers remain inside sprint scope +- no backend contract changes were required ## recommended next step -Hook the existing `/traces` web surface off these live endpoints in a separate sprint, while preserving the same narrow persisted-data-only contract. +Run a browser-based QA pass against a live configured backend with real trace records to validate the wording and density of the generated live summaries and event facts. ## intentionally deferred after this sprint -- any UI changes -- any trace mutation endpoints -- any new trace production behavior -- any connector, Gmail, Calendar, approval-flow, or execution-scope expansion -- any search, filtering, pagination, or explainability enrichment beyond persisted trace and trace-event reads +- any Gmail, Calendar, auth, runner, or broader task workflow scope +- any redesign outside the existing `/traces` shell +- any trace enrichment beyond the shipped list/detail/event endpoints +- any search, filtering, pagination, or mutation controls diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index c20c146..a88af32 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -6,49 +6,48 @@ PASS ## criteria met -- `GET /v0/traces`, `GET /v0/traces/{trace_id}`, and `GET /v0/traces/{trace_id}/events` are implemented in `apps/api/src/alicebot_api/main.py`. -- The sprint stayed limited to read-only trace review over existing persisted `traces` and `trace_events` data. -- Stable trace review contracts were added in `apps/api/src/alicebot_api/contracts.py`. -- The review seam in `apps/api/src/alicebot_api/traces.py` returns deterministic list, detail, and event payloads. -- Deterministic ordering is explicit and test-backed: - - trace list: `created_at DESC, id DESC` - - trace events: `sequence_no ASC, id ASC` -- User isolation is preserved through user-scoped connections plus existing row-level security behavior, and invisible traces return `404`. -- Unit coverage was added for ordering, response shape, endpoint mapping, and invisible-trace handling. -- Integration coverage was added for list/detail/event reads, cross-user isolation, and invisible-trace `404` behavior. -- Acceptance verification passed: - - `./.venv/bin/python -m pytest tests/unit` -> `451 passed` - - `./.venv/bin/python -m pytest tests/integration` -> `143 passed` +- The sprint stayed a UI sprint and did not widen backend scope. +- `/traces` now uses the shipped trace review APIs when API configuration is present via the shared helper reads in [apps/web/lib/api.ts](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/lib/api.ts) and the route wiring in [apps/web/app/traces/page.tsx](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/app/traces/page.tsx). +- `/traces` no longer depends exclusively on local fixture data. When API configuration is absent, the route falls back explicitly to fixture data from [apps/web/lib/fixtures.ts](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/lib/fixtures.ts). +- Loading, empty, unavailable, and bounded partial-detail/event states are implemented across [apps/web/app/traces/loading.tsx](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/app/traces/loading.tsx), [apps/web/app/traces/page.tsx](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/app/traces/page.tsx), and [apps/web/components/trace-list.tsx](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/components/trace-list.tsx). +- The trace UI keeps the intended bounded visual hierarchy: summary first, key metadata second, ordered events third. +- The implementation stays within the Sprint 6E in-scope files and does not add Gmail, Calendar, auth, runner, filtering, search, pagination, mutation, or backend changes. +- `BUILD_REPORT.md` is aligned to Sprint 6E and documents the route mode, shipped endpoints, exact commands, verification results, visual notes, and deferred scope. +- Frontend coverage was added in: + - [apps/web/lib/api.test.ts](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/lib/api.test.ts) + - [apps/web/components/trace-list.test.tsx](/Users/samirusani/Desktop/Codex/AliceBot/apps/web/components/trace-list.test.tsx) +- Verification passed in `apps/web`: + - `npm run lint` + - `npm test` + - `npm run build` + - current totals: `3` test files, `13` tests +- `next build` did not leave tracked churn in `apps/web/tsconfig.json` or `apps/web/next-env.d.ts`. ## criteria missed -- none +- None. ## quality issues -- none blocking -- No sloppy scope expansion was found. The diff is confined to the intended API/store/contracts/test surface plus `BUILD_REPORT.md`. +- No blocking quality issues found in the current Sprint 6E implementation. ## regression risks -- Low risk overall. The new seam is narrow and read-only. -- The main ongoing dependency is correct use of `user_connection()` so row-level security remains active for trace visibility. Current unit and integration coverage exercises that path. +- Residual product risk is limited to real-data wording and density because the visual verification notes are code-inspection notes rather than a browser QA pass. This does not block Sprint 6E acceptance but is still worth validating against a live configured backend. ## docs issues -- none blocking -- `BUILD_REPORT.md` matches the sprint packet requirements, including contracts, ordering rules, commands run, example responses, and deferred scope. +- No blocking docs issues remain for Sprint 6E. ## should anything be added to RULES.md? -- no +- No. ## should anything update ARCHITECTURE.md? -- no required update for sprint acceptance -- Optional future update: document the new read-only trace review API surface when the `/traces` UI switches from fixtures to live backend reads. +- No. ## recommended next action -- Accept the sprint. -- In a follow-up sprint, replace the fixture-backed `/traces` UI with these live endpoints without widening the backend contract. +- Sprint 6E can be considered review-passed. +- Next follow-up should be a browser-based QA pass against a live configured backend with real trace records to validate the operator-facing wording and event density. diff --git a/apps/web/app/traces/loading.tsx b/apps/web/app/traces/loading.tsx new file mode 100644 index 0000000..ebafbdf --- /dev/null +++ b/apps/web/app/traces/loading.tsx @@ -0,0 +1,51 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( +
+ + Loading route state +
+ } + /> + +
+ +
+ +
+
+
+
+ + + +
+ +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/traces/page.tsx b/apps/web/app/traces/page.tsx index 5bf40fb..e2eee77 100644 --- a/apps/web/app/traces/page.tsx +++ b/apps/web/app/traces/page.tsx @@ -1,134 +1,162 @@ import { PageHeader } from "../../components/page-header"; import { SectionCard } from "../../components/section-card"; -import { TraceList, type TraceItem } from "../../components/trace-list"; - -const traceFixtures: TraceItem[] = [ - { - id: "trace-ctx-401", - kind: "context_compile", - status: "completed", - title: "Context compile for magnesium reorder guidance", - summary: "Compiled prior task state, admitted memories, and recent thread continuity before assistant response assembly.", - eventCount: 9, - createdAt: "2026-03-17T08:45:00Z", - source: "Context compiler", - scope: "thread-magnesium", - related: { - threadId: "thread-magnesium", - taskId: "task-201", - }, - evidence: [ - "Memory evidence admitted for supplement preference and merchant history.", - "Recent approval state included as part of the continuity pack.", - "Task-step lineage referenced before response generation.", - ], - events: [ - { - id: "event-1", - kind: "compiler.scope", - title: "Scope resolved", - detail: "Single-user thread scope and compile limits were established for the request.", - }, - { - id: "event-2", - kind: "memory.retrieve", - title: "Memory evidence attached", - detail: "Preference and purchase-history memories were ranked into the response context pack.", - }, - { - id: "event-3", - kind: "task.retrieve", - title: "Task lifecycle linked", - detail: "Open task and step state were included so the answer could acknowledge the approval dependency.", - }, - ], - }, - { - id: "trace-approval-101", - kind: "approval_request", - status: "requires_review", - title: "Approval request for supplement purchase", - summary: "Routing required user approval before the merchant proxy could execute the purchase request.", - eventCount: 6, - createdAt: "2026-03-17T06:50:00Z", - source: "Approval workflow", - scope: "supplements", - related: { - threadId: "thread-magnesium", - taskId: "task-201", - approvalId: "approval-101", - }, - evidence: [ - "Policy rule marked purchase actions as approval-gated.", - "Tool metadata matched the requested action and scope.", - "Task-step trace link points back to the original governed request.", - ], - events: [ - { - id: "event-4", - kind: "tool.route", - title: "Routing completed", - detail: "The merchant proxy was selected as the governing tool for the request.", - }, - { - id: "event-5", - kind: "approval.state", - title: "Approval opened", - detail: "Approval record persisted with pending resolution state and task-step linkage.", - }, - { - id: "event-6", - kind: "task.lifecycle", - title: "Task updated", - detail: "Task lifecycle moved into a pending approval state while retaining request provenance.", - }, - ], - }, - { - id: "trace-exec-311", - kind: "proxy_execution", - status: "completed", - title: "Governed execution for vitamin reorder", - summary: "Approved supplement purchase request executed through the proxy handler with task and trace linkage preserved.", - eventCount: 7, - createdAt: "2026-03-16T14:24:00Z", - source: "Proxy execution", - scope: "supplements", +import { TraceList, type TraceEventItem, type TraceItem } from "../../components/trace-list"; +import { + getApiConfig, + getTraceDetail, + getTraceEvents, + hasLiveApiConfig, + listTraces, + pageModeLabel, + type TraceReviewEventItem, + type TraceReviewItem, + type TraceReviewSummaryItem, +} from "../../lib/api"; +import { getFixtureTrace, traceFixtures } from "../../lib/fixtures"; + +type SearchParams = Promise>; + +function formatKind(kind: string) { + return kind + .split(/[._]/) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatStatus(status: string) { + return status.replaceAll("_", " "); +} + +function shortId(value: string) { + return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value; +} + +function stringifyValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (value === null) { + return "null"; + } + + if (Array.isArray(value)) { + return value.map((item) => stringifyValue(item)).join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return "unknown"; +} + +function buildTraceSummary(trace: TraceReviewSummaryItem | TraceReviewItem) { + const eventLabel = trace.trace_event_count === 1 ? "ordered event" : "ordered events"; + return `${formatKind(trace.kind)} recorded ${trace.trace_event_count} ${eventLabel} for thread ${shortId(trace.thread_id)} and ended in ${formatStatus(trace.status)} status.`; +} + +function buildBaseTraceItem(trace: TraceReviewSummaryItem): TraceItem { + return { + id: trace.id, + kind: trace.kind, + status: trace.status, + title: `${formatKind(trace.kind)} review`, + summary: buildTraceSummary(trace), + eventCount: trace.trace_event_count, + createdAt: trace.created_at, + source: trace.compiler_version, + scope: `Thread ${shortId(trace.thread_id)}`, related: { - threadId: "thread-vitamin-d", - taskId: "task-182", - approvalId: "approval-100", - executionId: "execution-311", + threadId: trace.thread_id, + compilerVersion: trace.compiler_version, }, - evidence: [ - "Execution occurred only after approval resolution.", - "Handler output and trace references stayed attached to the governed action record.", - "Task and task-step lifecycle traces were appended alongside execution status.", - ], - events: [ - { - id: "event-7", - kind: "approval.check", - title: "Approval validated", - detail: "Execution preflight confirmed the approval was in an executable state.", - }, - { - id: "event-8", - kind: "budget.check", - title: "Budget check passed", - detail: "Execution budget constraints did not block the governed action.", - }, - { - id: "event-9", - kind: "execution.result", - title: "Handler completed", - detail: "Proxy output was recorded for the approved supplement reorder with a linked execution trace and task-step status update.", - }, + metadata: [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, ], + evidence: [], + events: [], + detailSource: "live", + eventSource: "live", + }; +} + +function buildEventFacts(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return [`Sequence ${event.sequence_no}`, `Payload: ${stringifyValue(payload)}`]; + } + + const entries = Object.entries(payload as Record).slice(0, 4); + return [ + `Sequence ${event.sequence_no}`, + ...entries.map(([key, value]) => `${key}: ${stringifyValue(value)}`), + ]; +} + +function buildEventDetail(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return `This event captured payload value ${stringifyValue(payload)}.`; + } + + const keys = Object.keys(payload as Record); + if (keys.length === 0) { + return "This event completed without additional payload fields."; + } + + return `This event captured ${keys.length} payload field${keys.length === 1 ? "" : "s"} for operator review.`; +} + +function buildEventTitle(event: TraceReviewEventItem) { + return `${formatKind(event.kind)} event`; +} + +function buildLiveTraceItem( + trace: TraceReviewItem, + events: TraceReviewEventItem[], + options?: { + detailUnavailable?: boolean; + eventsUnavailable?: boolean; }, -]; +): TraceItem { + const metadata = [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, + ...Object.entries(trace.limits).map(([key, value]) => `Limit ${key}: ${stringifyValue(value)}`), + ]; -type SearchParams = Promise>; + const evidence = events.length + ? [ + `${events.length} ordered event${events.length === 1 ? "" : "s"} loaded from the shipped trace review API.`, + ] + : ["No ordered events were returned for this trace."]; + + return { + ...buildBaseTraceItem(trace), + metadata, + evidence, + events: events.map((event) => ({ + id: event.id, + kind: event.kind, + title: buildEventTitle(event), + detail: buildEventDetail(event), + facts: buildEventFacts(event), + })), + detailUnavailable: options?.detailUnavailable, + eventsUnavailable: options?.eventsUnavailable, + }; +} export default async function TracesPage({ searchParams, @@ -139,33 +167,107 @@ export default async function TracesPage({ string, string | string[] | undefined >; - const selectedId = typeof params.trace === "string" ? params.trace : undefined; + const requestedId = typeof params.trace === "string" ? params.trace : undefined; + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let traces = traceFixtures; + let apiUnavailable = false; + + if (liveModeReady) { + try { + const payload = await listTraces(apiConfig.apiBaseUrl, apiConfig.userId); + const selectedSummary = payload.items.find((item) => item.id === requestedId) ?? payload.items[0] ?? null; + const mapped = payload.items.map((item) => buildBaseTraceItem(item)); + + if (selectedSummary) { + const selectedIndex = mapped.findIndex((item) => item.id === selectedSummary.id); + let selectedTrace = mapped[selectedIndex]; + + try { + const detailPayload = await getTraceDetail( + apiConfig.apiBaseUrl, + selectedSummary.id, + apiConfig.userId, + ); + + try { + const eventPayload = await getTraceEvents( + apiConfig.apiBaseUrl, + selectedSummary.id, + apiConfig.userId, + ); + + selectedTrace = buildLiveTraceItem(detailPayload.trace, eventPayload.items); + } catch { + selectedTrace = buildLiveTraceItem(detailPayload.trace, [], { + eventsUnavailable: true, + }); + } + } catch { + selectedTrace = { + ...selectedTrace, + metadata: [ + `Trace: ${selectedSummary.id}`, + `Thread: ${selectedSummary.thread_id}`, + `Compiler: ${selectedSummary.compiler_version}`, + `Status: ${formatStatus(selectedSummary.status)}`, + ], + evidence: [ + "The selected summary came from the live trace list, but full trace detail could not be read.", + ], + detailUnavailable: true, + eventsUnavailable: true, + }; + } + + if (selectedIndex >= 0) { + mapped[selectedIndex] = selectedTrace; + } + } + + traces = mapped; + } catch { + traces = []; + apiUnavailable = true; + } + } else { + const selectedFixture = requestedId ? getFixtureTrace(requestedId) : null; + if (selectedFixture) { + traces = [selectedFixture, ...traceFixtures.filter((item) => item.id !== selectedFixture.id)]; + } + } + + const selectedId = requestedId ?? traces[0]?.id; + const pageMode = liveModeReady ? "live" : "fixture"; return (
- Fixture-backed detail view - Existing backend concepts only + {pageModeLabel(pageMode)} + + {apiUnavailable ? "Trace API unavailable" : `${traces.length} entries`} +
} /> - +
    -
  • Which evidence types contributed to the outcome and whether they were appropriate.
  • -
  • How the lifecycle moved from request to approval or execution without losing provenance.
  • -
  • Whether the current trace surface needs deeper live-event wiring in a future sprint.
  • +
  • Whether the summary matches the trace kind, status, and ordered event count returned by the backend.
  • +
  • Whether key metadata keeps thread, compiler, and limit context visible without turning into a debugger dump.
  • +
  • Whether the ordered events explain the outcome clearly enough without requiring broader trace filtering or mutation scope.
diff --git a/apps/web/components/trace-list.test.tsx b/apps/web/components/trace-list.test.tsx new file mode 100644 index 0000000..1807ae4 --- /dev/null +++ b/apps/web/components/trace-list.test.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import TracesPage from "../app/traces/page"; +import { TraceList, type TraceItem } from "./trace-list"; + +const { + getApiConfigMock, + hasLiveApiConfigMock, + listTracesMock, + getTraceDetailMock, + getTraceEventsMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listTracesMock: vi.fn(), + getTraceDetailMock: vi.fn(), + getTraceEventsMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listTraces: listTracesMock, + getTraceDetail: getTraceDetailMock, + getTraceEvents: getTraceEventsMock, + }; +}); + +const liveTrace: TraceItem = { + id: "trace-1", + kind: "context.compile", + status: "completed", + title: "Context Compile review", + summary: "Context Compile recorded 2 ordered events for thread thread-1 and ended in completed status.", + eventCount: 2, + createdAt: "2026-03-17T00:00:00Z", + source: "continuity_v0", + scope: "Thread thread-1", + related: { + threadId: "thread-1", + compilerVersion: "continuity_v0", + }, + metadata: ["Trace: trace-1", "Thread: thread-1", "Compiler: continuity_v0"], + evidence: ["2 ordered events loaded from the shipped trace review API."], + events: [ + { + id: "event-1", + kind: "context.summary", + title: "Context Summary event", + detail: "This event captured 1 payload field for operator review.", + facts: ["Sequence 1", "thread_id: thread-1"], + }, + ], + detailSource: "live", + eventSource: "live", +}; + +describe("TraceList", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTracesMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders an explicit unavailable state when the configured trace API cannot be reached", () => { + render(); + + expect(screen.getByText("Trace API unavailable")).toBeInTheDocument(); + expect(screen.getByText("No live trace detail")).toBeInTheDocument(); + }); + + it("renders an empty split state when no traces are available", () => { + render(); + + expect(screen.getByText("Trace review is empty")).toBeInTheDocument(); + expect(screen.getByText("Explain-why detail is idle")).toBeInTheDocument(); + }); + + it("keeps the selected trace bounded when ordered events are unavailable", () => { + render( + , + ); + + expect(screen.getByText("Key metadata")).toBeInTheDocument(); + expect(screen.getByText("Ordered events unavailable")).toBeInTheDocument(); + expect(screen.getByText("Detail: Live trace detail")).toBeInTheDocument(); + }); + + it("renders ordered event review for a selected trace", () => { + render(); + + expect(screen.getAllByText("Context Compile review")).toHaveLength(2); + expect(screen.getByText("Context Summary event")).toBeInTheDocument(); + expect(screen.getByText("Sequence 1")).toBeInTheDocument(); + }); +}); + +describe("TracesPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTracesMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("stays fixture-backed when live API configuration is absent", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + + render( + await TracesPage({ + searchParams: Promise.resolve({ + trace: "trace-approval-101", + }), + }), + ); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getAllByText("Approval request review").length).toBeGreaterThan(0); + expect(listTracesMock).not.toHaveBeenCalled(); + }); + + it("shows an explicit unavailable state when the live trace list cannot be loaded", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listTracesMock.mockRejectedValue(new Error("trace list failed")); + + render(await TracesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getAllByText("Trace API unavailable")).toHaveLength(2); + expect(screen.getByText("Explainability review is unavailable")).toBeInTheDocument(); + }); + + it("keeps the live route bounded when detail loads but ordered events fail", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listTracesMock.mockResolvedValue({ + items: [ + { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + }, + ], + summary: { + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + }); + getTraceDetailMock.mockResolvedValue({ + trace: { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + limits: { + max_sessions: 3, + max_events: 8, + }, + }, + }); + getTraceEventsMock.mockRejectedValue(new Error("event read failed")); + + render( + await TracesPage({ + searchParams: Promise.resolve({ + trace: "trace-1", + }), + }), + ); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Ordered events unavailable")).toBeInTheDocument(); + expect(screen.getByText("Limit max_events: 8")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/trace-list.tsx b/apps/web/components/trace-list.tsx index 223f14e..e2362f5 100644 --- a/apps/web/components/trace-list.tsx +++ b/apps/web/components/trace-list.tsx @@ -1,9 +1,18 @@ import Link from "next/link"; +import type { ApiSource } from "../lib/api"; import { EmptyState } from "./empty-state"; import { SectionCard } from "./section-card"; import { StatusBadge } from "./status-badge"; +export type TraceEventItem = { + id: string; + kind: string; + title: string; + detail: string; + facts?: string[]; +}; + export type TraceItem = { id: string; kind: string; @@ -19,14 +28,15 @@ export type TraceItem = { taskId?: string; approvalId?: string; executionId?: string; + compilerVersion?: string; }; + metadata: string[]; evidence: string[]; - events: Array<{ - id: string; - kind: string; - title: string; - detail: string; - }>; + events: TraceEventItem[]; + detailSource: ApiSource; + eventSource: ApiSource; + detailUnavailable?: boolean; + eventsUnavailable?: boolean; }; function formatDate(value: string) { @@ -41,22 +51,65 @@ function formatDate(value: string) { export function TraceList({ traces, selectedId, + apiUnavailable = false, }: { traces: TraceItem[]; selectedId?: string; + apiUnavailable?: boolean; }) { + if (apiUnavailable) { + return ( +
+ + + + + + + +
+ ); + } + if (traces.length === 0) { return ( - - - +
+ + + + + + + +
); } @@ -67,7 +120,7 @@ export function TraceList({
@@ -89,7 +142,7 @@ export function TraceList({

{trace.summary}

- {trace.kind.replace(/_/g, " ")} + {trace.kind.replaceAll(".", " ")} {trace.eventCount} events
@@ -101,13 +154,17 @@ export function TraceList({
- {selected.kind.replace(/_/g, " ")} · {selected.eventCount} events + {selected.kind.replaceAll(".", " ")} · {selected.eventCount} events
@@ -116,6 +173,12 @@ export function TraceList({
Source: {selected.source} Scope: {selected.scope} + + Detail: {selected.detailSource === "live" ? "Live trace detail" : "Fixture trace detail"} + + + Events: {selected.eventSource === "live" ? "Live event review" : "Fixture event review"} + {selected.related.threadId ? ( Thread: {selected.related.threadId} ) : null} @@ -128,13 +191,16 @@ export function TraceList({ {selected.related.executionId ? ( Execution: {selected.related.executionId} ) : null} + {selected.related.compilerVersion ? ( + Compiler: {selected.related.compilerVersion} + ) : null}
-

Evidence in view

+

Key metadata

- {selected.evidence.map((item) => ( + {selected.metadata.map((item) => ( {item} @@ -142,21 +208,55 @@ export function TraceList({
+ {selected.evidence.length > 0 ? ( +
+

Review notes

+
+ {selected.evidence.map((item) => ( + + {item} + + ))} +
+
+ ) : null} +
-

Key events

-
    - {selected.events.map((event) => ( -
  1. -
    -
    - {event.kind} -

    {event.title}

    +

    Ordered events

    + {selected.eventsUnavailable ? ( + + ) : selected.events.length === 0 ? ( + + ) : ( +
      + {selected.events.map((event) => ( +
    1. +
      +
      + {event.kind} +

      {event.title}

      +
      -
    -

    {event.detail}

    -
  2. - ))} -
+

{event.detail}

+ {event.facts?.length ? ( +
+ {event.facts.map((fact) => ( + + {fact} + + ))} +
+ ) : null} + + ))} + + )}
diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts index c6a5e05..e65d95d 100644 --- a/apps/web/lib/api.test.ts +++ b/apps/web/lib/api.test.ts @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError, combinePageModes, + getTraceDetail, + getTraceEvents, + listTraces, pageModeLabel, resolveApproval, submitApprovalRequest, @@ -154,4 +157,99 @@ describe("api helpers", () => { }), ); }); + + it("reads the shipped trace review endpoints with user-scoped query params", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + }, + ], + summary: { + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + trace: { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + limits: { + max_sessions: 3, + max_events: 8, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "event-1", + trace_id: "trace-1", + sequence_no: 1, + kind: "context.summary", + payload: { + thread_id: "thread-1", + }, + created_at: "2026-03-17T00:00:01Z", + }, + ], + summary: { + trace_id: "trace-1", + total_count: 1, + order: ["sequence_no_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listTraces("https://api.example.com", "user-1"); + await getTraceDetail("https://api.example.com", "trace-1", "user-1"); + await getTraceEvents("https://api.example.com", "trace-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/traces?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ], + [ + "https://api.example.com/v0/traces/trace-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/traces/trace-1/events?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + }); }); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index e96b945..0102866 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -117,6 +117,40 @@ export type TaskStepListSummary = { order: string[]; }; +export type TraceReviewSummaryItem = { + id: string; + thread_id: string; + kind: string; + compiler_version: string; + status: string; + created_at: string; + trace_event_count: number; +}; + +export type TraceReviewItem = TraceReviewSummaryItem & { + limits: Record; +}; + +export type TraceReviewEventItem = { + id: string; + trace_id: string; + sequence_no: number; + kind: string; + payload: unknown; + created_at: string; +}; + +export type TraceReviewListSummary = { + total_count: number; + order: string[]; +}; + +export type TraceReviewEventListSummary = { + trace_id: string; + total_count: number; + order: string[]; +}; + export type ApprovalRequestPayload = { user_id: string; thread_id: string; @@ -346,3 +380,30 @@ export function getTaskSteps(apiBaseUrl: string, taskId: string, userId: string) { user_id: userId }, ); } + +export function listTraces(apiBaseUrl: string, userId: string) { + return requestJson<{ items: TraceReviewSummaryItem[]; summary: TraceReviewListSummary }>( + apiBaseUrl, + "/v0/traces", + undefined, + { user_id: userId }, + ); +} + +export function getTraceDetail(apiBaseUrl: string, traceId: string, userId: string) { + return requestJson<{ trace: TraceReviewItem }>( + apiBaseUrl, + `/v0/traces/${traceId}`, + undefined, + { user_id: userId }, + ); +} + +export function getTraceEvents(apiBaseUrl: string, traceId: string, userId: string) { + return requestJson<{ items: TraceReviewEventItem[]; summary: TraceReviewEventListSummary }>( + apiBaseUrl, + `/v0/traces/${traceId}/events`, + undefined, + { user_id: userId }, + ); +} diff --git a/apps/web/lib/fixtures.ts b/apps/web/lib/fixtures.ts index 46b3d0f..43d4e9c 100644 --- a/apps/web/lib/fixtures.ts +++ b/apps/web/lib/fixtures.ts @@ -7,6 +7,7 @@ import type { TaskStepListSummary, ToolRecord, } from "./api"; +import type { TraceItem } from "../components/trace-list"; const PURCHASE_TOOL: ToolRecord = { id: "22222222-2222-4222-8222-222222222222", @@ -28,6 +29,178 @@ const PURCHASE_TOOL: ToolRecord = { const THREAD_MAGNESIUM = "11111111-1111-4111-8111-111111111111"; const THREAD_VITAMIN_D = "11111111-1111-4111-8111-111111111112"; +export const traceFixtures: TraceItem[] = [ + { + id: "trace-ctx-401", + kind: "context.compile", + status: "completed", + title: "Context compile review", + summary: + "Compiled prior task state, admitted memories, and recent thread continuity before assistant response assembly.", + eventCount: 3, + createdAt: "2026-03-17T08:45:00Z", + source: "continuity_v0", + scope: "Thread magnesium review", + related: { + threadId: "thread-magnesium", + compilerVersion: "continuity_v0", + }, + metadata: [ + "Trace: trace-ctx-401", + "Thread: thread-magnesium", + "Compiler: continuity_v0", + "Status: completed", + "Limit max_sessions: 3", + "Limit max_events: 8", + ], + evidence: [ + "Memory evidence admitted for supplement preference and merchant history.", + "Recent approval state included as part of the continuity pack.", + "Task-step lineage referenced before response generation.", + ], + events: [ + { + id: "event-1", + kind: "compiler.scope", + title: "Scope resolved", + detail: "Single-user thread scope and compile limits were established for the request.", + facts: ["Sequence 1", "Captured at Mar 17, 08:45"], + }, + { + id: "event-2", + kind: "memory.retrieve", + title: "Memory evidence attached", + detail: "Preference and purchase-history memories were ranked into the response context pack.", + facts: ["Sequence 2", "Captured at Mar 17, 08:45"], + }, + { + id: "event-3", + kind: "task.retrieve", + title: "Task lifecycle linked", + detail: "Open task and step state were included so the answer could acknowledge the approval dependency.", + facts: ["Sequence 3", "Captured at Mar 17, 08:45"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-approval-101", + kind: "approval.request", + status: "requires_review", + title: "Approval request review", + summary: + "Routing required user approval before the merchant proxy could execute the purchase request.", + eventCount: 3, + createdAt: "2026-03-17T06:50:00Z", + source: "approval_request_v0", + scope: "Supplement purchase review", + related: { + threadId: "thread-magnesium", + taskId: "task-201", + approvalId: "approval-101", + compilerVersion: "approval_request_v0", + }, + metadata: [ + "Trace: trace-approval-101", + "Thread: thread-magnesium", + "Task: task-201", + "Approval: approval-101", + "Compiler: approval_request_v0", + "Status: requires_review", + ], + evidence: [ + "Policy rule marked purchase actions as approval-gated.", + "Tool metadata matched the requested action and scope.", + "Task-step trace link points back to the original governed request.", + ], + events: [ + { + id: "event-4", + kind: "tool.route", + title: "Routing completed", + detail: "The merchant proxy was selected as the governing tool for the request.", + facts: ["Sequence 1", "Captured at Mar 17, 06:50"], + }, + { + id: "event-5", + kind: "approval.state", + title: "Approval opened", + detail: "Approval record persisted with pending resolution state and task-step linkage.", + facts: ["Sequence 2", "Captured at Mar 17, 06:50"], + }, + { + id: "event-6", + kind: "task.lifecycle", + title: "Task updated", + detail: "Task lifecycle moved into a pending approval state while retaining request provenance.", + facts: ["Sequence 3", "Captured at Mar 17, 06:50"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-exec-311", + kind: "tool.proxy.execute", + status: "completed", + title: "Proxy execution review", + summary: + "Approved supplement purchase request executed through the proxy handler with task and trace linkage preserved.", + eventCount: 3, + createdAt: "2026-03-16T14:24:00Z", + source: "proxy_execution_v0", + scope: "Supplement execution review", + related: { + threadId: "thread-vitamin-d", + taskId: "task-182", + approvalId: "approval-100", + executionId: "execution-311", + compilerVersion: "proxy_execution_v0", + }, + metadata: [ + "Trace: trace-exec-311", + "Thread: thread-vitamin-d", + "Task: task-182", + "Approval: approval-100", + "Execution: execution-311", + "Compiler: proxy_execution_v0", + "Status: completed", + ], + evidence: [ + "Execution occurred only after approval resolution.", + "Handler output and trace references stayed attached to the governed action record.", + "Task and task-step lifecycle traces were appended alongside execution status.", + ], + events: [ + { + id: "event-7", + kind: "approval.check", + title: "Approval validated", + detail: "Execution preflight confirmed the approval was in an executable state.", + facts: ["Sequence 1", "Captured at Mar 16, 14:24"], + }, + { + id: "event-8", + kind: "budget.check", + title: "Budget check passed", + detail: "Execution budget constraints did not block the governed action.", + facts: ["Sequence 2", "Captured at Mar 16, 14:24"], + }, + { + id: "event-9", + kind: "execution.result", + title: "Handler completed", + detail: + "Proxy output was recorded for the approved supplement reorder with a linked execution trace and task-step status update.", + facts: ["Sequence 3", "Captured at Mar 16, 14:24"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, +]; + export const requestHistoryFixtures: RequestHistoryEntry[] = [ { id: "trace-request-101", @@ -332,6 +505,10 @@ export function getFixtureApproval(approvalId: string) { return approvalFixtures.find((item) => item.id === approvalId) ?? null; } +export function getFixtureTrace(traceId: string) { + return traceFixtures.find((item) => item.id === traceId) ?? null; +} + export function getFixtureTask(taskId: string) { return taskFixtures.find((item) => item.id === taskId) ?? null; }