diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 75117c9..6896c60 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,77 +2,89 @@ ## sprint objective -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. +Implement Sprint 6F by extending the AliceBot web shell so approved approvals can be executed from `/approvals` and their resulting execution state can be reviewed from `/approvals` and `/tasks` using only the shipped approval-execution and tool-execution read endpoints. ## completed work -- 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` -- 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: +- extended `apps/web/lib/api.ts` with typed execution support for: + - `POST /v0/approvals/{approval_id}/execute` + - `GET /v0/tool-executions` + - `GET /v0/tool-executions/{execution_id}` +- added fixture-backed execution records in `apps/web/lib/fixtures.ts` so fixture mode now covers: + - approved but not executed + - executed task and execution review +- updated `apps/web/app/approvals/page.tsx` to: + - discover linked execution records for the selected approval + - surface explicit live unavailable state when execution review cannot be loaded + - keep fixture fallback explicit when live API configuration is absent +- updated `apps/web/app/tasks/page.tsx` to: + - read latest execution detail from `task.latest_execution_id` + - fall back to fixture execution detail only when a matching fixture exists + - surface explicit unavailable messaging when a live execution read fails without fixture coverage +- extended `apps/web/components/approval-actions.tsx` to: + - keep approve/reject for pending approvals + - show execute for eligible approved approvals + - show bounded loading, success, failure, and read-only states +- extended `apps/web/components/approval-detail.tsx` and `apps/web/components/task-summary.tsx` with the new bounded `apps/web/components/execution-summary.tsx` +- updated `apps/web/components/task-step-list.tsx` to make execution linkage and blocked reasons clearer inside the existing step timeline +- refined `apps/web/app/globals.css` for the scoped surfaces with stronger containment, calmer grouping, better wrapping behavior, and more stable responsive stacking +- added or updated 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 + - `apps/web/components/approval-actions.test.tsx` + - `apps/web/components/execution-summary.test.tsx` ## incomplete work -- none inside the sprint’s scoped `/traces` UI deliverables +- no scoped sprint deliverables remain incomplete in code - intentionally not added: - - trace filtering - - trace search - - trace pagination - - trace mutation UI - backend changes + - new routes + - execution mutation beyond the shipped approval execute seam + - execution filtering, search, or pagination + - broader task workflow redesign outside `/approvals` and `/tasks` ## files changed -- `apps/web/app/traces/page.tsx` -- `apps/web/app/traces/loading.tsx` -- `apps/web/components/trace-list.tsx` +- `apps/web/app/approvals/page.tsx` +- `apps/web/app/tasks/page.tsx` +- `apps/web/app/globals.css` +- `apps/web/components/approval-actions.tsx` +- `apps/web/components/approval-detail.tsx` +- `apps/web/components/task-summary.tsx` +- `apps/web/components/task-step-list.tsx` +- `apps/web/components/status-badge.tsx` +- `apps/web/components/execution-summary.tsx` - `apps/web/lib/api.ts` - `apps/web/lib/fixtures.ts` - `apps/web/lib/api.test.ts` -- `apps/web/components/trace-list.test.tsx` +- `apps/web/components/approval-actions.test.tsx` +- `apps/web/components/execution-summary.test.tsx` - `BUILD_REPORT.md` ## 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 +- `/approvals` is: + - live-API-backed for approval list/detail and linked execution review when API configuration is present + - fixture-backed when API configuration is absent + - explicitly unavailable for linked execution review when live execution reads fail +- `/tasks` is: + - live-API-backed for task detail, step detail, and latest execution review when API configuration is present + - fixture-backed when API configuration is absent + - mixed only when a live task falls back to fixture execution detail ## backend endpoints consumed -- `GET /v0/traces` -- `GET /v0/traces/{trace_id}` -- `GET /v0/traces/{trace_id}/events` - -## tests run - -- `npm run lint` - - PASS -- `npm test` - - PASS - - `3` test files passed - - `13` tests passed -- `npm run build` - - PASS +- `POST /v0/approvals/{approval_id}/execute` +- `GET /v0/tool-executions` +- `GET /v0/tool-executions/{execution_id}` +- existing carried-forward reads already used by the shell: + - `GET /v0/approvals` + - `GET /v0/approvals/{approval_id}` + - `POST /v0/approvals/{approval_id}/approve` + - `POST /v0/approvals/{approval_id}/reject` + - `GET /v0/tasks` + - `GET /v0/tasks/{task_id}` + - `GET /v0/tasks/{task_id}/steps` ## exact commands run @@ -84,26 +96,35 @@ Implement Sprint 6E by replacing the fixture-only `/traces` route with a live ex - lint result: PASS - test result: PASS + - `4` test files passed + - `20` tests passed - 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` +- no browser-driven visual QA pass was executed in this turn +- desktop note: + - code inspection indicates `/approvals` and `/tasks` now use stronger internal grouping for action handling and execution review + - ids, badges, and payload snapshots have explicit wrapping and overflow handling inside bounded cards +- mobile note: + - the shared shell still collapses the split layouts to one column below the existing breakpoint + - execution review, action bars, and buttons now stack into full-width rows to preserve containment on narrow screens ## blockers/issues -- no implementation blockers remain inside sprint scope +- no blockers remain inside sprint scope - no backend contract changes were required ## recommended next step -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. +Run a browser-based QA pass against a live configured backend to validate: +- the execute transition from approved to executed or blocked +- the exact empty/unavailable messaging in live failure cases +- the density of output snapshots on long real-world payloads ## intentionally deferred after this sprint -- 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 +- any Gmail, Calendar, auth, runner, or broader workflow expansion +- any execution list filters, sorting controls, or search UI +- any task-step mutation UI beyond existing backend reads +- any redesign outside the scoped `/approvals` and `/tasks` review surfaces diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index a88af32..04d781d 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -6,21 +6,19 @@ PASS ## criteria met -- 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) +- The sprint stayed a UI sprint and did not widen backend scope. The implementation remains confined to the web shell and uses only the shipped approval/task/execution seams. +- The UI can trigger `POST /v0/approvals/{approval_id}/execute` for eligible approved approvals through `apps/web/lib/api.ts` and `apps/web/components/approval-actions.tsx`. +- The UI can show resulting execution state using existing execution and task reads in `apps/web/app/approvals/page.tsx`, `apps/web/app/tasks/page.tsx`, `apps/web/components/approval-detail.tsx`, `apps/web/components/task-summary.tsx`, `apps/web/components/task-step-list.tsx`, and `apps/web/components/execution-summary.tsx`. +- `/approvals` and `/tasks` make execution state understandable without widening backend scope. Loading, success, blocked/failure, empty, and unavailable states are all explicitly surfaced. +- When API configuration is absent, execution controls degrade to explicit fixture/read-only behavior rather than broken interaction. +- The sprint stayed within the listed in-scope screens, components, and files. +- `DESIGN_SYSTEM.md` was followed materially. The execution controls and review surfaces remain bounded and consistent with the existing operator-shell tone. +- `BUILD_REPORT.md` is aligned with the implemented sprint scope and now reflects the current verification totals. - Verification passed in `apps/web`: - `npm run lint` - `npm test` - `npm run build` - - current totals: `3` test files, `13` tests + - current totals: `4` test files, `20` tests - `next build` did not leave tracked churn in `apps/web/tsconfig.json` or `apps/web/next-env.d.ts`. ## criteria missed @@ -29,15 +27,15 @@ PASS ## quality issues -- No blocking quality issues found in the current Sprint 6E implementation. +- No blocking quality issues found in the current Sprint 6F implementation. ## regression risks -- 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. +- Residual risk is limited to live-data wording and density because the visual notes are still based on code inspection rather than a browser QA pass against a configured backend. That does not block sprint acceptance. ## docs issues -- No blocking docs issues remain for Sprint 6E. +- No blocking docs issues remain for Sprint 6F. ## should anything be added to RULES.md? @@ -49,5 +47,5 @@ PASS ## recommended next action -- 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. +- Sprint 6F can be considered review-passed. +- Next follow-up should be a browser-based QA pass against a live configured backend to validate the approved-to-executed or blocked transition and the exact operator-facing wording in live failure cases. diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/approvals/page.tsx index 6e650f4..bf368c8 100644 --- a/apps/web/app/approvals/page.tsx +++ b/apps/web/app/approvals/page.tsx @@ -3,14 +3,16 @@ import { ApprovalList } from "../../components/approval-list"; import { PageHeader } from "../../components/page-header"; import { combinePageModes, + getToolExecution, getApiConfig, getApprovalDetail, hasLiveApiConfig, + listToolExecutions, listApprovals, pageModeLabel, type ApiSource, } from "../../lib/api"; -import { approvalFixtures, getFixtureApproval } from "../../lib/fixtures"; +import { approvalFixtures, getFixtureApproval, getFixtureExecutionByApprovalId } from "../../lib/fixtures"; type SearchParams = Promise>; @@ -56,14 +58,50 @@ export default async function ApprovalsPage({ } } - const pageMode = combinePageModes(listSource, detail ? detailSource : null); + let execution = detail ? getFixtureExecutionByApprovalId(detail.id) : null; + let executionSource: ApiSource | null = execution ? "fixture" : null; + let executionUnavailableMessage: string | null = null; + + if (detail && liveModeReady && detailSource === "live") { + try { + const payload = await listToolExecutions(apiConfig.apiBaseUrl, apiConfig.userId); + const linked = payload.items.find((item) => item.approval_id === detail.id) ?? null; + + if (linked) { + try { + const detailPayload = await getToolExecution(apiConfig.apiBaseUrl, linked.id, apiConfig.userId); + execution = detailPayload.execution; + executionSource = "live"; + } catch { + execution = linked; + executionSource = "live"; + } + } else { + execution = null; + executionSource = null; + } + } catch { + if (detail.status === "approved") { + execution = null; + executionSource = null; + executionUnavailableMessage = + "The linked execution review could not be loaded from the configured backend."; + } + } + } + + const pageMode = combinePageModes( + listSource, + detail ? detailSource : null, + execution ? executionSource : null, + ); return (
{pageModeLabel(pageMode)} @@ -77,6 +115,9 @@ export default async function ApprovalsPage({ diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index e59da83..44f4bcf 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -288,16 +288,17 @@ code, .page-header__copy { display: grid; - gap: 10px; - max-width: 860px; + gap: 12px; + max-width: 780px; } .page-header h1 { margin: 0; font-family: var(--font-serif); font-size: clamp(2rem, 4vw, 3rem); - line-height: 1.05; + line-height: 1.02; letter-spacing: -0.04em; + text-wrap: balance; } .page-header p { @@ -345,10 +346,12 @@ code, .section-card { display: grid; - gap: 18px; - padding: 24px; - background: var(--surface); - border: 1px solid var(--border); + gap: 20px; + padding: 28px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 252, 248, 0.72)), + var(--surface); + border: 1px solid rgba(42, 52, 66, 0.1); border-radius: var(--radius-xl); box-shadow: var(--shadow-md); backdrop-filter: blur(14px); @@ -361,20 +364,23 @@ code, .section-card__header { display: grid; - gap: 8px; + gap: 10px; } .section-card__title { margin: 0; - font-size: 1.1rem; + font-size: 1.14rem; font-weight: 600; letter-spacing: -0.02em; + line-height: 1.2; + overflow-wrap: anywhere; } .section-card__description { margin: 0; color: var(--text-soft); line-height: 1.6; + max-width: 72ch; } .metric-value { @@ -454,13 +460,18 @@ code, grid-template-columns: repeat(2, minmax(0, 1fr)); } +.key-value-grid--compact { + gap: 12px; +} + .key-value-grid div { display: grid; - gap: 6px; - padding: 14px 16px; + gap: 7px; + padding: 15px 16px; border-radius: 16px; - background: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.72); border: 1px solid rgba(42, 52, 66, 0.08); + align-content: start; } .key-value-grid dt { @@ -548,11 +559,13 @@ code, display: inline-flex; align-items: center; justify-content: center; + min-height: 44px; padding: 12px 16px; border-radius: 14px; border: 1px solid transparent; font-weight: 600; line-height: 1; + text-align: center; transition: transform 140ms ease, background-color 140ms ease, @@ -696,10 +709,10 @@ code, .list-row, .timeline-item, .trace-event { - padding: 16px 18px; + padding: 18px 20px; border-radius: 18px; border: 1px solid rgba(42, 52, 66, 0.08); - background: rgba(255, 255, 255, 0.58); + background: rgba(255, 255, 255, 0.62); } .history-entry { @@ -713,7 +726,7 @@ code, .trace-event__topline, .detail-summary { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; } @@ -754,7 +767,7 @@ code, .split-layout { display: grid; gap: 24px; - grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.25fr); + grid-template-columns: minmax(340px, 0.94fr) minmax(0, 1.2fr); } .list-panel__header { @@ -780,17 +793,19 @@ code, .list-row { display: grid; - gap: 12px; + gap: 14px; transition: transform 140ms ease, border-color 140ms ease, - background-color 140ms ease; + background-color 140ms ease, + box-shadow 140ms ease; } .list-row:hover, .list-row:focus-visible { transform: translateY(-1px); border-color: var(--border-strong); + box-shadow: 0 12px 24px rgba(32, 43, 56, 0.04); } .list-row.is-selected { @@ -803,6 +818,8 @@ code, font-size: 0.98rem; font-weight: 600; letter-spacing: -0.02em; + line-height: 1.25; + overflow-wrap: anywhere; } .list-row__meta, @@ -823,6 +840,8 @@ code, border: 1px solid rgba(42, 52, 66, 0.08); color: var(--text-soft); font-size: 0.82rem; + max-width: 100%; + overflow-wrap: anywhere; } .inline-link { @@ -834,28 +853,36 @@ code, .detail-grid { display: grid; - gap: 18px; + gap: 20px; } .detail-group { display: grid; gap: 12px; + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(42, 52, 66, 0.08); } .detail-group h3 { margin: 0; - font-size: 0.92rem; + font-size: 0.95rem; letter-spacing: -0.01em; + line-height: 1.25; } .attribute-item, .evidence-chip { + display: inline-flex; + align-items: center; padding: 9px 11px; border-radius: 12px; background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(42, 52, 66, 0.08); color: var(--text-soft); font-size: 0.84rem; + line-height: 1.45; overflow-wrap: anywhere; } @@ -870,12 +897,12 @@ code, .timeline-item { display: grid; - gap: 12px; + gap: 14px; } .timeline-item__summary { display: grid; - gap: 8px; + gap: 12px; } .approval-action-bar, @@ -884,6 +911,87 @@ code, gap: 14px; } +.approval-action-bar { + padding: 18px; + border-radius: 18px; + background: rgba(247, 244, 239, 0.84); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.approval-action-bar__buttons { + align-items: center; +} + +.detail-summary__label { + overflow-wrap: anywhere; +} + +.execution-summary { + display: grid; + gap: 16px; + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.execution-summary--empty, +.execution-summary--unavailable { + background: rgba(255, 255, 255, 0.56); +} + +.execution-summary__topline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.execution-summary__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.execution-summary__label { + margin: 0; + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.execution-summary__note { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(248, 245, 240, 0.92); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.execution-summary__note--danger { + background: rgba(141, 68, 64, 0.05); + border-color: rgba(141, 68, 64, 0.12); +} + +.execution-summary__code { + margin: 0; + padding: 14px; + overflow: auto; + border-radius: 14px; + background: rgba(23, 31, 41, 0.94); + color: rgba(246, 248, 251, 0.94); + font-size: 0.81rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .loading-card { min-height: 100%; } @@ -987,7 +1095,8 @@ code, .timeline-item__topline, .trace-event__topline, .detail-summary, - .nav-card__topline { + .nav-card__topline, + .execution-summary__topline { flex-direction: column; align-items: flex-start; } @@ -1006,6 +1115,17 @@ code, .form-field-group--two-up { grid-template-columns: 1fr; } + + .detail-group, + .approval-action-bar, + .execution-summary { + padding: 16px; + } + + .button, + .button-secondary { + width: 100%; + } } @keyframes loading-sheen { diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 7badedb..5aba368 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -7,12 +7,14 @@ import { getApiConfig, getTaskDetail, getTaskSteps, + getToolExecution, hasLiveApiConfig, listTasks, pageModeLabel, type ApiSource, } from "../../lib/api"; import { + getFixtureExecution, getFixtureTask, getFixtureTaskStepSummary, getFixtureTaskSteps, @@ -79,14 +81,41 @@ export default async function TasksPage({ } } - const pageMode = combinePageModes(listSource, selectedTask ? taskSource : null, selectedTask ? stepSource : null); + let execution = selectedTask?.latest_execution_id ? getFixtureExecution(selectedTask.latest_execution_id) : null; + let executionSource: ApiSource | null = execution ? "fixture" : null; + let executionUnavailableMessage: string | null = null; + + if (selectedTask?.latest_execution_id && liveModeReady && taskSource === "live") { + try { + const payload = await getToolExecution( + apiConfig.apiBaseUrl, + selectedTask.latest_execution_id, + apiConfig.userId, + ); + execution = payload.execution; + executionSource = "live"; + } catch { + execution = getFixtureExecution(selectedTask.latest_execution_id); + executionSource = execution ? "fixture" : null; + executionUnavailableMessage = execution + ? null + : "The latest execution record could not be read from the configured backend."; + } + } + + const pageMode = combinePageModes( + listSource, + selectedTask ? taskSource : null, + selectedTask ? stepSource : null, + execution ? executionSource : null, + ); return (
{pageModeLabel(pageMode)} @@ -99,7 +128,14 @@ export default async function TasksPage({
- +
diff --git a/apps/web/components/approval-actions.test.tsx b/apps/web/components/approval-actions.test.tsx index bce80a8..c6df05f 100644 --- a/apps/web/components/approval-actions.test.tsx +++ b/apps/web/components/approval-actions.test.tsx @@ -4,9 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApprovalActions } from "./approval-actions"; -const { refreshMock, resolveApprovalMock } = vi.hoisted(() => ({ +const { refreshMock, resolveApprovalMock, executeApprovalMock } = vi.hoisted(() => ({ refreshMock: vi.fn(), resolveApprovalMock: vi.fn(), + executeApprovalMock: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -20,6 +21,7 @@ vi.mock("../lib/api", async () => { return { ...actual, resolveApproval: resolveApprovalMock, + executeApproval: executeApprovalMock, }; }); @@ -67,10 +69,36 @@ const pendingApproval = { resolution: null, }; +function ApprovalActionsHarness({ + initialApproval = pendingApproval, + initialHasExecution = false, +}: { + initialApproval?: typeof pendingApproval; + initialHasExecution?: boolean; +}) { + const [approval, setApproval] = React.useState(initialApproval); + const [hasExecution, setHasExecution] = React.useState(initialHasExecution); + + return ( + { + setApproval(payload.approval); + setHasExecution(true); + }} + /> + ); +} + describe("ApprovalActions", () => { beforeEach(() => { refreshMock.mockReset(); resolveApprovalMock.mockReset(); + executeApprovalMock.mockReset(); }); afterEach(() => { @@ -78,15 +106,21 @@ describe("ApprovalActions", () => { }); it("disables live actions in fixture mode", () => { - render(); + render( + , + ); expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled(); expect(screen.getByText(/disabled in fixture mode/i)).toBeInTheDocument(); }); - it("submits approval resolution and refreshes the route on success", async () => { - const onResolved = vi.fn(); + it("preserves approval success feedback after the parent rerenders with the resolved approval", async () => { resolveApprovalMock.mockResolvedValue({ approval: { ...pendingApproval, @@ -102,14 +136,7 @@ describe("ApprovalActions", () => { }, }); - render( - , - ); + render(); fireEvent.click(screen.getByRole("button", { name: "Approve" })); @@ -123,14 +150,124 @@ describe("ApprovalActions", () => { }); await waitFor(() => { - expect(onResolved).toHaveBeenCalledWith( - expect.objectContaining({ - status: "approved", - }), + expect(screen.getByText(/Approval resolved as approved/i)).toBeInTheDocument(); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(screen.getByText("Resolution saved")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Execute approved request" })).toBeInTheDocument(); + }); + + it("preserves execution success feedback after the parent rerenders with linked execution state", async () => { + executeApprovalMock.mockResolvedValue({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: pendingApproval.tool, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 4, + }, + }); + + const approvedApproval = { + ...pendingApproval, + status: "approved" as const, + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Execute approved request" })); + + await waitFor(() => { + expect(executeApprovalMock).toHaveBeenCalledWith( + "https://api.example.com", + "approval-1", + "user-1", ); }); + await waitFor(() => { + expect(screen.getByText(/Execution completed/i)).toBeInTheDocument(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); - expect(screen.getByText(/Approval resolved as approved/i)).toBeInTheDocument(); + expect(screen.getByText("Execution saved")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Executed" })).toBeDisabled(); + }); + + it("shows an explicit blocked execution badge after a blocked execute response", async () => { + executeApprovalMock.mockResolvedValue({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: pendingApproval.tool, + result: { + handler_key: null, + status: "blocked", + output: null, + reason: "tool budget exceeded", + }, + events: null, + trace: { + trace_id: "trace-3", + trace_event_count: 4, + }, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Execute approved request" })); + + await waitFor(() => { + expect(screen.getByText("Execution blocked")).toBeInTheDocument(); + }); + + expect(screen.getByText(/recorded as blocked/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/components/approval-actions.tsx b/apps/web/components/approval-actions.tsx index 8a610d1..5cece4c 100644 --- a/apps/web/components/approval-actions.tsx +++ b/apps/web/components/approval-actions.tsx @@ -1,60 +1,102 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import type { ApprovalItem } from "../lib/api"; -import { resolveApproval } from "../lib/api"; +import type { ApprovalExecutionResponse, ApprovalItem } from "../lib/api"; +import { executeApproval, resolveApproval } from "../lib/api"; import { StatusBadge } from "./status-badge"; type ApprovalActionsProps = { approval: ApprovalItem; + hasExecution: boolean; apiBaseUrl?: string; userId?: string; onResolved: (approval: ApprovalItem) => void; + onExecuted: (payload: ApprovalExecutionResponse) => void; }; type FeedbackState = { tone: "info" | "success" | "danger"; + kind: "availability" | "resolution" | "execution"; message: string; + badgeStatus?: string; + badgeLabel?: string; }; -function actionAvailabilityMessage(liveModeReady: boolean, approvalStatus: ApprovalItem["status"]) { +function actionAvailabilityMessage( + liveModeReady: boolean, + approvalStatus: ApprovalItem["status"], + hasExecution: boolean, +) { if (!liveModeReady) { - return "Approve and reject controls are disabled in fixture mode."; + if (hasExecution) { + return "Fixture mode is read-only. Review the recorded execution detail below."; + } + + return approvalStatus === "pending" + ? "Approve and reject controls are disabled in fixture mode." + : "Execution controls are unavailable until live API configuration is present."; + } + + if (approvalStatus === "pending") { + return "Choose approve or reject to resolve the approval through the shipped backend seam."; + } + + if (approvalStatus === "approved" && !hasExecution) { + return "This approval is resolved and eligible for execution. Run it only when the reviewed request is ready to proceed."; } - if (approvalStatus !== "pending") { - return "This approval has already been resolved. The action bar is now read-only."; + if (approvalStatus === "approved" && hasExecution) { + return "A linked execution record already exists. The action bar is now read-only."; } - return "Choose approve or reject to resolve the approval through the shipped backend seam."; + return "This approval is not in an executable state. The action bar is read-only."; } export function ApprovalActions({ approval, + hasExecution, apiBaseUrl, userId, onResolved, + onExecuted, }: ApprovalActionsProps) { const router = useRouter(); + const lastResetContextRef = useRef<{ approvalId: string; liveModeReady: boolean } | null>(null); const [feedback, setFeedback] = useState({ tone: "info", - message: actionAvailabilityMessage(Boolean(apiBaseUrl && userId), approval.status), + kind: "availability", + message: actionAvailabilityMessage(Boolean(apiBaseUrl && userId), approval.status, hasExecution), }); - const [pendingAction, setPendingAction] = useState<"approve" | "reject" | null>(null); + const [pendingAction, setPendingAction] = useState<"approve" | "reject" | "execute" | null>(null); const liveModeReady = Boolean(apiBaseUrl && userId); - const actionLocked = !liveModeReady || approval.status !== "pending" || pendingAction !== null; + const actionBusy = pendingAction !== null; + const canResolve = liveModeReady && approval.status === "pending" && !actionBusy; + const canExecute = liveModeReady && approval.status === "approved" && !hasExecution && !actionBusy; useEffect(() => { - setFeedback({ - tone: "info", - message: actionAvailabilityMessage(liveModeReady, approval.status), - }); - setPendingAction(null); - }, [approval.id, approval.status, liveModeReady]); + const lastResetContext = lastResetContextRef.current; + const shouldReset = + lastResetContext == null || + lastResetContext.approvalId !== approval.id || + lastResetContext.liveModeReady !== liveModeReady; + + if (shouldReset) { + setFeedback({ + tone: "info", + kind: "availability", + message: actionAvailabilityMessage(liveModeReady, approval.status, hasExecution), + }); + setPendingAction(null); + lastResetContextRef.current = { + approvalId: approval.id, + liveModeReady, + }; + } + }, [approval.id, approval.status, hasExecution, liveModeReady]); async function handleResolve(action: "approve" | "reject") { if (!apiBaseUrl || !userId) { @@ -64,6 +106,7 @@ export function ApprovalActions({ setPendingAction(action); setFeedback({ tone: "info", + kind: "resolution", message: action === "approve" ? "Submitting approval resolution..." : "Submitting rejection resolution...", }); @@ -72,6 +115,7 @@ export function ApprovalActions({ onResolved(payload.approval); setFeedback({ tone: "success", + kind: "resolution", message: action === "approve" ? "Approval resolved as approved. The inbox and downstream task view have been refreshed." @@ -82,54 +126,120 @@ export function ApprovalActions({ const message = error instanceof Error ? error.message : "Resolution failed"; setFeedback({ tone: "danger", + kind: "resolution", + message, + badgeStatus: "rejected", + badgeLabel: "Resolution failed", + }); + } finally { + setPendingAction(null); + } + } + + async function handleExecute() { + if (!apiBaseUrl || !userId) { + return; + } + + setPendingAction("execute"); + setFeedback({ + tone: "info", + kind: "execution", + message: "Submitting approved execution...", + }); + + try { + const payload = await executeApproval(apiBaseUrl, approval.id, userId); + onExecuted(payload); + setFeedback({ + tone: payload.result.status === "blocked" ? "danger" : "success", + kind: "execution", + message: + payload.result.status === "blocked" + ? "Execution was recorded as blocked. Review the execution summary for the blocking reason." + : "Execution completed and the review panels have been refreshed.", + badgeStatus: payload.result.status === "blocked" ? "blocked" : "completed", + badgeLabel: payload.result.status === "blocked" ? "Execution blocked" : "Execution saved", + }); + router.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Execution failed"; + setFeedback({ + tone: "danger", + kind: "execution", message, + badgeStatus: "failed", + badgeLabel: "Execution failed", }); } finally { setPendingAction(null); } } + const badgeStatus = pendingAction + ? pendingAction === "execute" + ? "executing" + : "submitting" + : feedback.badgeStatus ?? + (feedback.tone === "danger" + ? feedback.kind === "execution" + ? "failed" + : "rejected" + : feedback.tone); + + const badgeLabel = pendingAction + ? pendingAction === "approve" + ? "Submitting approve" + : pendingAction === "reject" + ? "Submitting reject" + : "Executing" + : feedback.badgeLabel ?? + (feedback.tone === "success" + ? feedback.kind === "execution" + ? "Execution saved" + : "Resolution saved" + : feedback.tone === "danger" + ? feedback.kind === "execution" + ? "Execution failed" + : "Resolution failed" + : liveModeReady + ? "Ready" + : "Fixture mode"); + return (
- +

{feedback.message}

- - + {approval.status === "pending" ? ( + <> + + + + ) : null} + + {approval.status === "approved" ? ( + + ) : null}
); diff --git a/apps/web/components/approval-detail.tsx b/apps/web/components/approval-detail.tsx index 2c933b7..da9a7ba 100644 --- a/apps/web/components/approval-detail.tsx +++ b/apps/web/components/approval-detail.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react"; -import type { ApprovalItem, ApiSource } from "../lib/api"; +import type { ApprovalExecutionResponse, ApprovalItem, ApiSource, ToolExecutionItem } from "../lib/api"; import { EmptyState } from "./empty-state"; import { SectionCard } from "./section-card"; import { StatusBadge } from "./status-badge"; import { ApprovalActions } from "./approval-actions"; +import { ExecutionSummary } from "./execution-summary"; function formatDate(value: string) { return new Intl.DateTimeFormat("en", { @@ -32,6 +33,9 @@ function formatAttributeValue(value: unknown) { type ApprovalDetailProps = { initialApproval: ApprovalItem | null; detailSource: ApiSource; + initialExecution: ToolExecutionItem | null; + executionSource?: ApiSource | null; + executionUnavailableMessage?: string | null; apiBaseUrl?: string; userId?: string; }; @@ -39,14 +43,21 @@ type ApprovalDetailProps = { export function ApprovalDetail({ initialApproval, detailSource, + initialExecution, + executionSource, + executionUnavailableMessage, apiBaseUrl, userId, }: ApprovalDetailProps) { const [approval, setApproval] = useState(initialApproval); + const [execution, setExecution] = useState(initialExecution); + const [executionPreview, setExecutionPreview] = useState(null); useEffect(() => { setApproval(initialApproval); - }, [initialApproval]); + setExecution(initialExecution); + setExecutionPreview(null); + }, [initialApproval, initialExecution]); if (!approval) { return ( @@ -67,7 +78,7 @@ export function ApprovalDetail({
@@ -139,9 +150,26 @@ export function ApprovalDetail({

Approval action bar

{ + setApproval(payload.approval); + setExecutionPreview(payload); + }} + /> +
+ +
+

Execution review

+
diff --git a/apps/web/components/execution-summary.test.tsx b/apps/web/components/execution-summary.test.tsx new file mode 100644 index 0000000..f605a87 --- /dev/null +++ b/apps/web/components/execution-summary.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ExecutionSummary } from "./execution-summary"; + +const execution = { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-1", + request_event_id: "event-1", + result_event_id: "event-2", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + item: "Magnesium", + }, + }, + tool: { + id: "tool-1", + tool_key: "proxy.echo", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { + ok: true, + mode: "no_side_effect", + }, + reason: null, + }, + executed_at: "2026-03-17T00:10:00Z", +}; + +const executionPreview = { + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: execution.request, + tool: execution.tool, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-route-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: { + resolved_at: "2026-03-17T00:05:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: execution.tool, + result: execution.result, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-1", + trace_event_count: 9, + }, +}; + +describe("ExecutionSummary", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the empty execution state", () => { + render( + , + ); + + expect(screen.getByText("Task has not executed yet")).toBeInTheDocument(); + expect(screen.getByText(/Execution detail will appear here/i)).toBeInTheDocument(); + expect(screen.getByText("Not executed")).toBeInTheDocument(); + }); + + it("renders a persisted execution record", () => { + render( + , + ); + + expect(screen.getByText("Execution record in review")).toBeInTheDocument(); + expect(screen.getByText("Live execution detail")).toBeInTheDocument(); + expect(screen.getByText("Merchant Proxy")).toBeInTheDocument(); + expect(screen.getByText(/"mode": "no_side_effect"/i)).toBeInTheDocument(); + }); + + it("renders an unavailable message instead of implying no execution", () => { + render( + , + ); + + expect(screen.getByText("Execution review could not be loaded")).toBeInTheDocument(); + expect(screen.getByText(/did not return a usable record/i)).toBeInTheDocument(); + }); + + it("prefers a fresh execute preview over a stale unavailable message", () => { + render( + , + ); + + expect(screen.getByText("Latest execution result")).toBeInTheDocument(); + expect(screen.getByText("Latest execute response")).toBeInTheDocument(); + expect(screen.queryByText("Execution review could not be loaded")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/execution-summary.tsx b/apps/web/components/execution-summary.tsx new file mode 100644 index 0000000..5f0767b --- /dev/null +++ b/apps/web/components/execution-summary.tsx @@ -0,0 +1,135 @@ +import type { ApiSource, ApprovalExecutionResponse, ToolExecutionItem } from "../lib/api"; +import { StatusBadge } from "./status-badge"; + +type ExecutionSummaryProps = { + execution: ToolExecutionItem | null; + preview?: ApprovalExecutionResponse | null; + source?: ApiSource | null; + unavailableMessage?: string | null; + emptyTitle: string; + emptyDescription: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +export function ExecutionSummary({ + execution, + preview, + source, + unavailableMessage, + emptyTitle, + emptyDescription, +}: ExecutionSummaryProps) { + if (!execution && !preview) { + if (unavailableMessage) { + return ( +
+
+
+ +

Execution review could not be loaded

+
+
+

{unavailableMessage}

+
+ ); + } + + return ( +
+
+
+ +

{emptyTitle}

+
+
+

{emptyDescription}

+
+ ); + } + + const result = execution?.result ?? preview?.result ?? null; + const tool = execution?.tool ?? preview?.tool ?? null; + const traceId = execution?.trace_id ?? preview?.trace.trace_id ?? null; + const executedAt = execution?.executed_at ?? null; + const reviewSource = execution ? (source === "live" ? "Live execution detail" : "Fixture execution detail") : "Latest execute response"; + + return ( +
+
+
+ +

+ {execution ? "Execution record in review" : "Latest execution result"} +

+
+ {reviewSource} +
+ +
+ {execution ? ( +
+
Execution
+
{execution.id}
+
+ ) : null} +
+
Handler
+
{result?.handler_key ?? "Unavailable"}
+
+
+
Status
+
{result?.status ?? execution?.status ?? "Unknown"}
+
+
+
Executed
+
{executedAt ? formatDate(executedAt) : "Just returned from execute"}
+
+ {traceId ? ( +
+
Trace
+
{traceId}
+
+ ) : null} + {tool ? ( +
+
Tool
+
{tool.name}
+
+ ) : null} +
+ + {result?.reason ? ( +
+

Execution reason

+

{result.reason}

+
+ ) : null} + + {result?.budget_decision ? ( +
+

Budget decision

+
{formatJson(result.budget_decision)}
+
+ ) : null} + + {result?.output ? ( +
+

Output snapshot

+
{formatJson(result.output)}
+
+ ) : null} +
+ ); +} diff --git a/apps/web/components/status-badge.tsx b/apps/web/components/status-badge.tsx index 24168ca..9880092 100644 --- a/apps/web/components/status-badge.tsx +++ b/apps/web/components/status-badge.tsx @@ -22,6 +22,7 @@ function toneForStatus(status: string) { "created", "blocked", "approval_required", + "executing", ].includes(normalized) ) { return normalized === "blocked" ? "danger" : "warning"; diff --git a/apps/web/components/task-step-list.tsx b/apps/web/components/task-step-list.tsx index daa0c89..8906975 100644 --- a/apps/web/components/task-step-list.tsx +++ b/apps/web/components/task-step-list.tsx @@ -99,22 +99,32 @@ export function TaskStepList({ {step.lineage.parent_step_id || step.lineage.source_approval_id || step.lineage.source_execution_id ? (
{step.lineage.parent_step_id ? ( - Parent step: {step.lineage.parent_step_id} + + Parent step: {step.lineage.parent_step_id} + ) : null} {step.lineage.source_approval_id ? ( - + Source approval: {step.lineage.source_approval_id} ) : null} {step.lineage.source_execution_id ? ( - + Source execution: {step.lineage.source_execution_id} ) : null}
) : null} + {step.outcome.execution_id ? ( +
+ Execution record: {step.outcome.execution_id} +
+ ) : null} {step.outcome.blocked_reason ? ( -

Blocked reason: {step.outcome.blocked_reason}

+
+

Blocked reason

+

{step.outcome.blocked_reason}

+
) : null}
diff --git a/apps/web/components/task-summary.tsx b/apps/web/components/task-summary.tsx index 0fcf29c..8b75ff7 100644 --- a/apps/web/components/task-summary.tsx +++ b/apps/web/components/task-summary.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; -import type { ApiSource, TaskItem } from "../lib/api"; +import type { ApiSource, TaskItem, ToolExecutionItem } from "../lib/api"; import { EmptyState } from "./empty-state"; +import { ExecutionSummary } from "./execution-summary"; import { SectionCard } from "./section-card"; import { StatusBadge } from "./status-badge"; @@ -9,9 +10,19 @@ type TaskSummaryProps = { task: TaskItem | null; taskSource: ApiSource; stepSource: ApiSource; + execution: ToolExecutionItem | null; + executionSource?: ApiSource | null; + executionUnavailableMessage?: string | null; }; -export function TaskSummary({ task, taskSource, stepSource }: TaskSummaryProps) { +export function TaskSummary({ + task, + taskSource, + stepSource, + execution, + executionSource, + executionUnavailableMessage, +}: TaskSummaryProps) { if (!task) { return (
@@ -83,6 +94,17 @@ export function TaskSummary({ task, taskSource, stepSource }: TaskSummaryProps)
) : null}
+ +
+

Execution review

+ +
); diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts index e65d95d..695106d 100644 --- a/apps/web/lib/api.test.ts +++ b/apps/web/lib/api.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError, combinePageModes, + executeApproval, + getToolExecution, getTraceDetail, getTraceEvents, listTraces, @@ -158,6 +160,168 @@ describe("api helpers", () => { ); }); + it("executes approved requests and reads execution detail from the shipped endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: { + resolved_at: "2026-03-17T00:02:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 9, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + execution: { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-2", + request_event_id: "event-1", + result_event_id: "event-2", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + executed_at: "2026-03-17T00:03:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await executeApproval("https://api.example.com", "approval-1", "user-1"); + await getToolExecution("https://api.example.com", "execution-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/approvals/approval-1/execute", + expect.objectContaining({ + method: "POST", + }), + ], + [ + "https://api.example.com/v0/tool-executions/execution-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + }); + }); + it("reads the shipped trace review endpoints with user-scoped query params", async () => { fetchMock.mockResolvedValueOnce( new Response( diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 0102866..94f116e 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -34,6 +34,8 @@ export type ToolRecord = { created_at: string; }; +export type JsonObject = Record; + export type GovernedRequestRecord = { thread_id: string; tool_id: string; @@ -117,6 +119,51 @@ export type TaskStepListSummary = { order: string[]; }; +export type ToolExecutionResult = { + handler_key: string | null; + status: string; + output: JsonObject | null; + reason: string | null; + budget_decision?: JsonObject; +}; + +export type ToolExecutionItem = { + id: string; + approval_id: string; + task_step_id: string; + thread_id: string; + tool_id: string; + trace_id: string; + request_event_id: string | null; + result_event_id: string | null; + status: string; + handler_key: string | null; + request: GovernedRequestRecord; + tool: ToolRecord; + result: ToolExecutionResult; + executed_at: string; +}; + +export type ApprovalExecutionResponse = { + request: { + approval_id: string; + task_step_id: string; + }; + approval: ApprovalItem; + tool: ToolRecord; + result: ToolExecutionResult; + events: { + request_event_id: string; + request_sequence_no: number; + result_event_id: string; + result_sequence_no: number; + } | null; + trace: { + trace_id: string; + trace_event_count: number; + }; +}; + export type TraceReviewSummaryItem = { id: string; thread_id: string; @@ -381,6 +428,31 @@ export function getTaskSteps(apiBaseUrl: string, taskId: string, userId: string) ); } +export function executeApproval(apiBaseUrl: string, approvalId: string, userId: string) { + return requestJson(apiBaseUrl, `/v0/approvals/${approvalId}/execute`, { + method: "POST", + body: JSON.stringify({ user_id: userId }), + }); +} + +export function listToolExecutions(apiBaseUrl: string, userId: string) { + return requestJson<{ items: ToolExecutionItem[]; summary: { total_count: number; order: string[] } }>( + apiBaseUrl, + "/v0/tool-executions", + undefined, + { user_id: userId }, + ); +} + +export function getToolExecution(apiBaseUrl: string, executionId: string, userId: string) { + return requestJson<{ execution: ToolExecutionItem }>( + apiBaseUrl, + `/v0/tool-executions/${executionId}`, + undefined, + { user_id: userId }, + ); +} + export function listTraces(apiBaseUrl: string, userId: string) { return requestJson<{ items: TraceReviewSummaryItem[]; summary: TraceReviewListSummary }>( apiBaseUrl, diff --git a/apps/web/lib/fixtures.ts b/apps/web/lib/fixtures.ts index 43d4e9c..75323f2 100644 --- a/apps/web/lib/fixtures.ts +++ b/apps/web/lib/fixtures.ts @@ -5,6 +5,7 @@ import type { TaskItem, TaskStepItem, TaskStepListSummary, + ToolExecutionItem, ToolRecord, } from "./api"; import type { TraceItem } from "../components/trace-list"; @@ -395,7 +396,7 @@ export const taskFixtures: TaskItem[] = [ id: "33333333-3333-4333-8333-333333333334", thread_id: THREAD_VITAMIN_D, tool_id: PURCHASE_TOOL.id, - status: "approved", + status: "executed", request: { thread_id: THREAD_VITAMIN_D, tool_id: PURCHASE_TOOL.id, @@ -410,9 +411,52 @@ export const taskFixtures: TaskItem[] = [ }, tool: PURCHASE_TOOL, latest_approval_id: "44444444-4444-4444-8444-444444444445", - latest_execution_id: null, + latest_execution_id: "99999999-1111-4111-8111-111111111111", created_at: "2026-03-16T14:00:00Z", - updated_at: "2026-03-16T14:22:00Z", + updated_at: "2026-03-16T14:24:00Z", + }, +]; + +export const executionFixtures: ToolExecutionItem[] = [ + { + id: "99999999-1111-4111-8111-111111111111", + approval_id: "44444444-4444-4444-8444-444444444445", + task_step_id: "77777777-7777-4777-8777-777777777778", + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + trace_id: "trace-exec-311", + request_event_id: "event-request-311", + result_event_id: "event-result-311", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + quantity: "1", + }, + }, + tool: PURCHASE_TOOL, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { + mode: "no_side_effect", + tool_key: "proxy.echo", + action: "place_order", + scope: "supplements", + merchant: "Fullscript", + item: "Vitamin D3 + K2", + }, + reason: null, + }, + executed_at: "2026-03-16T14:24:00Z", }, ]; @@ -464,7 +508,7 @@ export const taskStepFixtures: Record = { task_id: "33333333-3333-4333-8333-333333333334", sequence_no: 1, kind: "governed_request", - status: "approved", + status: "executed", request: { thread_id: THREAD_VITAMIN_D, tool_id: PURCHASE_TOOL.id, @@ -482,21 +526,21 @@ export const taskStepFixtures: Record = { routing_decision: "require_approval", approval_id: "44444444-4444-4444-8444-444444444445", approval_status: "approved", - execution_id: null, - execution_status: null, + execution_id: "99999999-1111-4111-8111-111111111111", + execution_status: "completed", blocked_reason: null, }, lineage: { parent_step_id: null, source_approval_id: null, - source_execution_id: null, + source_execution_id: "99999999-1111-4111-8111-111111111111", }, trace: { - trace_id: "66666666-6666-4666-8666-666666666667", - trace_kind: "approval_resolution", + trace_id: "trace-exec-311", + trace_kind: "tool.proxy.execute", }, created_at: "2026-03-16T14:00:00Z", - updated_at: "2026-03-16T14:22:00Z", + updated_at: "2026-03-16T14:24:00Z", }, ], }; @@ -513,6 +557,14 @@ export function getFixtureTask(taskId: string) { return taskFixtures.find((item) => item.id === taskId) ?? null; } +export function getFixtureExecution(executionId: string) { + return executionFixtures.find((item) => item.id === executionId) ?? null; +} + +export function getFixtureExecutionByApprovalId(approvalId: string) { + return executionFixtures.find((item) => item.approval_id === approvalId) ?? null; +} + export function getFixtureTaskSteps(taskId: string) { return taskStepFixtures[taskId] ?? []; }