diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 57e85c69..6b848367 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -710,9 +710,9 @@ jobs: if ( report.testResults?.length !== 1 || result?.status !== 'passed' - || result?.assertionResults?.length !== 1 - || report.numTotalTests !== 1 - || report.numPassedTests !== 1 + || result?.assertionResults?.length !== 2 + || report.numTotalTests !== 2 + || report.numPassedTests !== 2 || report.numFailedTests !== 0 || report.numPendingTests !== 0 || report.numTodoTests !== 0 @@ -720,7 +720,7 @@ jobs: || report.numPendingTestSuites !== 0 || markerCount !== 1 ) { - throw new Error('The mandatory S5 PostgreSQL proof must run exactly one file and one passing test with one terminal marker and no skips.') + throw new Error('The mandatory S5 PostgreSQL proof must run exactly one file, two passing tests, one terminal marker, and no skips.') } NODE - name: Remove the disposable S5 HTTP authorization proof database diff --git a/web/__tests__/epic-172-s5-postgres-routes.test.ts b/web/__tests__/epic-172-s5-postgres-routes.test.ts index c5e8d137..aff92ae8 100644 --- a/web/__tests__/epic-172-s5-postgres-routes.test.ts +++ b/web/__tests__/epic-172-s5-postgres-routes.test.ts @@ -420,16 +420,62 @@ run('S5 real PostgreSQL HTTP authorization boundary', () => { request(ownerCredential, `/api/mcps/terminal-state/${ids.ownerTask}`), { params: Promise.resolve({ taskId: ids.ownerTask }) }, ) - expect(await terminalState.json()).toMatchObject({ - terminalPackages: [{ - runtimeAuditId: ids.ownerAudit, - workPackageId: ids.ownerPackage, - state: 'terminal', - deliveryOutcome: 'submitted', - terminalOutcome: 'succeeded', - }], - }) + expect(await terminalState.json()).toMatchObject({ terminalPackages: [] }) console.log('S5_POSTGRES_HTTP_AUTHORIZATION_OK') }) + + it('keeps one real PostgreSQL observation across a committed ordinary and protected transition', async () => { + const { readS5AuthoritativeTaskState } = await import('@/lib/mcps/s5-server-reader') + let transitionCommitted = false + const transitionedTerminalAt = '2099-01-01T00:00:00.000Z' + const before = await readS5AuthoritativeTaskState(ids.ownerTask, ids.owner, async () => { + await admin.begin(async (tx) => { + await tx` + update tasks set status = 'failed', updated_at = clock_timestamp() + where id = ${ids.ownerTask}::uuid + ` + await tx` + update work_packages set status = 'failed', updated_at = clock_timestamp() + where id = ${ids.ownerPackage}::uuid + ` + await tx` + update work_package_local_run_evidence + set terminal = '{"status":"failed"}'::jsonb, terminal_at = ${transitionedTerminalAt}::timestamptz + where id = ${ids.ownerEvidence}::uuid + ` + await tx` + update filesystem_mcp_runtime_audits + set status = 'failed', + assembly = '{"state":"not_assembled","failureStage":"preflight"}'::jsonb, + delivery = '{"state":"not_exposed"}'::jsonb, + terminal = '{"status":"failed","failureCode":"preflight_failed"}'::jsonb, + terminal_at = ${transitionedTerminalAt}::timestamptz + where id = ${ids.ownerAudit}::uuid + ` + }) + transitionCommitted = true + }) + expect(transitionCommitted).toBe(true) + // The exporter and the least-privilege reader both retain the old state; + // current terminal authority remains the blocked package, not its old audit. + expect(before.taskStatus).toBe('approved') + expect(before.localEvidenceAvailable).toBe(true) + expect(before.packages).toMatchObject([{ workPackageId: ids.ownerPackage, status: 'blocked' }]) + expect(before.terminalPackages).toEqual([]) + const beforeEvidence = before.evidenceRecords.find((evidence) => evidence.id === ids.ownerEvidence) + expect(beforeEvidence?.terminalAt).not.toBe(transitionedTerminalAt) + + const after = await readS5AuthoritativeTaskState(ids.ownerTask, ids.owner) + expect(after.taskStatus).toBe('failed') + expect(after.packages).toMatchObject([{ workPackageId: ids.ownerPackage, status: 'failed' }]) + expect(after.terminalPackages).toMatchObject([{ + runtimeAuditId: ids.ownerAudit, workPackageId: ids.ownerPackage, + state: 'terminal', terminalOutcome: 'failed', + }]) + expect(after.localEvidenceAvailable).toBe(true) + expect(after.evidenceRecords.find((evidence) => evidence.id === ids.ownerEvidence)?.terminalAt) + .toBe(transitionedTerminalAt) + expect(before.freshnessFingerprint).not.toBe(after.freshnessFingerprint) + }) }) diff --git a/web/__tests__/epic-172-s5-protected-reader.test.ts b/web/__tests__/epic-172-s5-protected-reader.test.ts index 0939deaa..54bbdd03 100644 --- a/web/__tests__/epic-172-s5-protected-reader.test.ts +++ b/web/__tests__/epic-172-s5-protected-reader.test.ts @@ -9,7 +9,11 @@ const ENV_NAME = 'FORGE_LOCAL_RUN_EVIDENCE_READER_DATABASE_URL' const original = process.env[ENV_NAME] function mockClient(behaviour: () => Promise) { - const client = Object.assign(vi.fn(behaviour), { end: vi.fn().mockResolvedValue(undefined) }) + const client = Object.assign(vi.fn(behaviour), { + begin: vi.fn(async (_options: unknown, run: (tx: unknown) => Promise) => run(client)), + unsafe: vi.fn().mockResolvedValue(undefined), + end: vi.fn().mockResolvedValue(undefined), + }) postgresFactory.mockReturnValue(client) return client } @@ -138,4 +142,34 @@ describe('S5 protected local run evidence reader', () => { await expect(readS5ProtectedLocalRunEvidence('task-1')).resolves.toBeNull() expect(postgresFactory).not.toHaveBeenCalled() }) + + it('imports only a validated exported snapshot from the same database', async () => { + process.env[ENV_NAME] = 'postgres://forge_local_evidence_reader@localhost/forge' + const client = mockClient(() => Promise.resolve([{ evidenceRows: [], auditRows: [] }])) + const { readS5ProtectedTerminalSnapshot } = await import('@/lib/mcps/s5-protected-reader') + await expect(readS5ProtectedTerminalSnapshot('task-1', { + snapshotId: '00000003-0000001B-1', databaseUrl: 'postgres://forge_app@localhost/forge', + })).resolves.toEqual({ evidenceRows: [], auditRows: [] }) + expect(client.begin).toHaveBeenCalledWith('isolation level repeatable read read only', expect.any(Function)) + expect(client.unsafe).toHaveBeenCalledWith("set transaction snapshot '00000003-0000001B-1'") + for (const snapshotId of [ + '00000003-0000001B-1', 'FFFFFFFF-00000000-ABCDEF12', + ]) { + await expect(readS5ProtectedTerminalSnapshot('task-1', { + snapshotId, databaseUrl: 'postgres://forge_app@localhost/forge', + })).resolves.toEqual({ evidenceRows: [], auditRows: [] }) + } + for (const snapshotId of [ + '000003A1-1', "00000003-0000001B-1'; select 1; --", + '00000003-0000001B-1 --', ' 00000003-0000001B-1', + '00000003-0000001B-1 ', '00000003_0000001B_1', + ]) { + await expect(readS5ProtectedTerminalSnapshot('task-1', { + snapshotId, databaseUrl: 'postgres://forge_app@localhost/forge', + })).resolves.toBeNull() + } + await expect(readS5ProtectedTerminalSnapshot('task-1', { + snapshotId: '00000003-0000001B-1', databaseUrl: 'postgres://forge_app@other-host/forge', + })).resolves.toBeNull() + }) }) diff --git a/web/__tests__/epic-172-s5-server-reader.test.ts b/web/__tests__/epic-172-s5-server-reader.test.ts index 9a27e942..f2a8424b 100644 --- a/web/__tests__/epic-172-s5-server-reader.test.ts +++ b/web/__tests__/epic-172-s5-server-reader.test.ts @@ -4,12 +4,46 @@ vi.mock('server-only', () => ({ default: {} })) import { computeFreshnessFingerprint, + canonicalTaskPresentationProjection, isS5FreshnessFingerprint, normalizeS5RecoveryMarkers, normalizeS5TerminalAudit, + s5EffectiveAdmissionDecision, } from '@/lib/mcps/s5-server-reader' describe('S5 authoritative reader identities', () => { + it('joins recovery actions and terminal evidence into one fail-closed task DTO', () => { + const taskId = '00000000-0000-4000-8000-000000000001' + const packageId = '00000000-0000-4000-8000-000000000002' + const auditId = '00000000-0000-4000-8000-000000000003' + const fingerprint = `sha256:${'a'.repeat(64)}` + const state = { + computedAt: '2026-07-30T00:00:00.000Z', observedAtMs: 0, localEvidenceAvailable: true, + taskId, projectId: '00000000-0000-4000-8000-000000000004', taskStatus: 'approved', freshnessFingerprint: fingerprint, + packages: [{ + workPackageId: packageId, title: 'Packet package', assignedRole: 'backend', status: 'blocked', + requestedCapabilities: ['filesystem.project.write'], boundedRuntimeRequestedCapabilities: [], blockingCapabilities: [], + currentDecision: null, decisionHistory: [], blockMetadata: null, pointerFingerprint: '', pointerVersion: '0', + }], + projectGrant: null, + recoveryMarkers: [{ + workPackageId: packageId, kind: 'packet_issuance' as const, state: 'current' as const, action: 'retry_execution', + allowedActions: ['retry_execution', 'decline_packet_recovery'], evidenceId: auditId, evidenceFingerprint: fingerprint, + }], + terminalPackages: [{ + runtimeAuditId: auditId, workPackageId: packageId, state: 'terminal' as const, assemblyState: 'assembled' as const, + deliveryOutcome: 'submitted' as const, terminalOutcome: 'succeeded' as const, terminalAt: '2026-07-30T00:00:00.000Z', + }], evidenceRecords: [], + } + + const projection = canonicalTaskPresentationProjection(state) + expect(projection.freshnessFingerprint).toBe(fingerprint) + expect(projection.admission).toEqual([{ workPackageId: packageId, title: 'Packet package', requiresMcp: false, decision: 'unavailable' }]) + expect(projection.recoveries[0].actions).toEqual([]) + expect(projection.terminals[0]).toMatchObject({ workPackageId: packageId, state: 'terminal', outcome: 'succeeded' }) + expect(JSON.stringify(projection)).not.toContain('claimToken') + }) + it('canonicalizes nested mutable state independent of object insertion order', () => { expect(computeFreshnessFingerprint({ task: { status: 'approved', id: 'task-1' }, @@ -115,4 +149,58 @@ describe('S5 authoritative reader identities', () => { state: 'unavailable', }) }) + + it('keeps recovery actionable when a historical audit is not a current terminal package', () => { + const state = { + computedAt: '2026-07-30T00:00:00.000Z', observedAtMs: 0, localEvidenceAvailable: true, + taskId: 'task', projectId: 'project', taskStatus: 'approved', freshnessFingerprint: `sha256:${'b'.repeat(64)}`, + packages: [{ workPackageId: 'package', title: 'Recovered package', assignedRole: 'backend', status: 'blocked', requestedCapabilities: [], boundedRuntimeRequestedCapabilities: [], blockingCapabilities: [], currentDecision: null, decisionHistory: [], blockMetadata: null, pointerFingerprint: '', pointerVersion: '0' }], + projectGrant: null, + recoveryMarkers: [{ workPackageId: 'package', kind: 'packet_issuance' as const, state: 'current' as const, action: 'retry_execution', allowedActions: ['retry_execution'], evidenceId: 'audit', evidenceFingerprint: `sha256:${'c'.repeat(64)}` }], + terminalPackages: [], evidenceRecords: [], + } + expect(canonicalTaskPresentationProjection(state).recoveries[0]?.actions).toHaveLength(1) + }) + + it('uses current terminal status and reconciled effective admission rather than package decision history', () => { + const state = { + computedAt: '2026-07-30T00:00:00.000Z', observedAtMs: 0, localEvidenceAvailable: true, + taskId: 'task', projectId: 'project', taskStatus: 'completed', freshnessFingerprint: `sha256:${'d'.repeat(64)}`, + packages: [{ workPackageId: 'package', title: 'Terminal package', assignedRole: 'backend', status: 'completed', requestedCapabilities: ['filesystem.project.read'], boundedRuntimeRequestedCapabilities: ['filesystem.project.read'], blockingCapabilities: [], currentDecision: null, decisionHistory: [], blockMetadata: null, pointerFingerprint: '', pointerVersion: '0', effectiveAdmission: { phase: 'approved' as const, source: 'project-level' as const, status: 'approved' as const, grantMode: 'always_allow' as const, consumed: false, coveredCapabilities: ['filesystem.project.read'], revocationReason: null } }], + projectGrant: null, + recoveryMarkers: [{ workPackageId: 'package', kind: 'packet_issuance' as const, state: 'current' as const, action: 'retry_execution', allowedActions: ['retry_execution'], evidenceId: 'audit', evidenceFingerprint: `sha256:${'e'.repeat(64)}` }], + terminalPackages: [], evidenceRecords: [], + } + const projection = canonicalTaskPresentationProjection(state) + expect(projection.recoveries[0]?.actions).toEqual([]) + expect(projection.admission[0]?.decision).toBe('approved') + }) + + it('uses exact admission coverage for local, project, consumed, and revoked authority', () => { + const packageBase = { + workPackageId: 'package', title: 'Package', assignedRole: 'backend', status: 'blocked', + requestedCapabilities: ['filesystem.project.read', 'filesystem.project.search'], + boundedRuntimeRequestedCapabilities: ['filesystem.project.read', 'filesystem.project.search'], + blockingCapabilities: [], currentDecision: null, decisionHistory: [], blockMetadata: null, + pointerFingerprint: '', pointerVersion: '0', + } + const grant = (overrides: Record) => ({ + phase: 'approved' as const, source: 'package-local' as const, status: 'approved' as const, + grantMode: 'allow_once' as const, consumed: false, coveredCapabilities: ['filesystem.project.read'], + revocationReason: null, ...overrides, + }) + expect(s5EffectiveAdmissionDecision({ ...packageBase, effectiveAdmission: grant({}) })).toBe('unavailable') + expect(s5EffectiveAdmissionDecision({ ...packageBase, effectiveAdmission: grant({ + coveredCapabilities: ['filesystem.project.read', 'filesystem.project.search'], + }) })).toBe('approved') + expect(s5EffectiveAdmissionDecision({ ...packageBase, effectiveAdmission: grant({ + source: 'project-level', grantMode: 'always_allow', coveredCapabilities: ['filesystem.project.read', 'filesystem.project.search'], + }) })).toBe('approved') + expect(s5EffectiveAdmissionDecision({ ...packageBase, effectiveAdmission: grant({ + coveredCapabilities: ['filesystem.project.read', 'filesystem.project.search'], consumed: true, + }) })).toBe('unavailable') + expect(s5EffectiveAdmissionDecision({ ...packageBase, effectiveAdmission: grant({ + phase: 'revoked', status: 'not_issued', grantMode: null, coveredCapabilities: [], revocationReason: 'project_grant_removed', + }) })).toBe('denied') + }) }) diff --git a/web/__tests__/mcp-canonical-task-presentation.test.tsx b/web/__tests__/mcp-canonical-task-presentation.test.tsx new file mode 100644 index 00000000..b61347c3 --- /dev/null +++ b/web/__tests__/mcp-canonical-task-presentation.test.tsx @@ -0,0 +1,252 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { + CanonicalMcpOperatorPanel, + canonicalMcpOperatorActionRequest, + createMcpPresentationRequestSequencer, +} from '@/app/dashboard/tasks/[id]/page' +import { BrandedTerminalJoinView } from '@/components/mcps/BrandedTerminalJoinView' +import { + CANONICAL_MCP_PRESENTATION_MAX_AGE_MS, + canonicalMcpPresentationAgeMs, + canonicalMcpPresentationIsFresh, + canonicalMcpTaskPresentationFromUnknown, + type CanonicalMcpOperatorAction, + type CanonicalMcpTaskPresentation, +} from '@/lib/mcps/admission-copy' + +const taskId = '00000000-0000-4000-8000-000000000001' +const packageId = '00000000-0000-4000-8000-000000000002' +const auditId = '00000000-0000-4000-8000-000000000003' +const evidenceId = '00000000-0000-4000-8000-000000000004' +const secondPackageId = '00000000-0000-4000-8000-000000000005' +const secondAuditId = '00000000-0000-4000-8000-000000000006' +const freshness = `sha256:${'a'.repeat(64)}` +const marker = `sha256:${'b'.repeat(64)}` +const evidence = `sha256:${'c'.repeat(64)}` + +function packet(overrides: Partial = {}): CanonicalMcpTaskPresentation { + return { + schemaVersion: 1, + computedAt: new Date().toISOString(), + freshnessFingerprint: freshness, + taskId, + localEvidenceAvailable: true, + admission: [{ workPackageId: packageId, title: 'Packet package', requiresMcp: true, decision: 'approved' }], + recoveries: [{ + workPackageId: packageId, + title: 'Packet package', + badgeText: 'Recovery available', + headline: 'Operator recovery is available', + body: 'Choose a server-authorized action.', + tone: 'warning', + actions: [{ + action: 'retry_execution', + label: 'Retry packet execution', + identity: { schemaVersion: 2, priorRuntimeAuditId: auditId, markerFingerprint: marker }, + }], + }], + terminals: [], + ...overrides, + } +} + +describe('canonical task MCP presentation', () => { + it('renders an allowed packet recovery action and forwards its exact endpoint identity', () => { + const presentation = packet() + const markup = renderToStaticMarkup( + undefined} />, + ) + + expect(markup).toContain('Retry packet execution') + expect(markup).toContain('Current admission approved') + expect(canonicalMcpOperatorActionRequest(presentation.recoveries[0].actions[0], freshness)).toEqual({ + schemaVersion: 1, + action: 'retry_execution', + expectedFreshnessFingerprint: freshness, + priorRuntimeAuditId: auditId, + markerFingerprint: marker, + }) + }) + + it('renders a local marker with its exact local-evidence identity', () => { + const presentation = packet({ + recoveries: [{ + workPackageId: packageId, + title: 'Local package', + badgeText: 'Recovery available', + headline: 'Operator recovery is available', + body: 'Choose a server-authorized action.', + tone: 'warning', + actions: [{ + action: 'retry_local_execution', + label: 'Start another local attempt', + identity: { schemaVersion: 1, localRunEvidenceId: evidenceId, evidenceFingerprint: evidence }, + }], + }], + }) + + expect(canonicalMcpOperatorActionRequest(presentation.recoveries[0].actions[0], freshness)).toEqual({ + schemaVersion: 1, + action: 'retry_local_execution', + expectedFreshnessFingerprint: freshness, + localRunEvidenceId: evidenceId, + evidenceFingerprint: evidence, + }) + }) + + it('groups duplicate recovery labels by package and preserves each package identity', () => { + const presentation = packet({ + recoveries: [ + packet().recoveries[0], + { + ...packet().recoveries[0], + workPackageId: secondPackageId, + title: 'Second packet package', + actions: [{ + action: 'retry_execution', + label: 'Retry packet execution', + identity: { schemaVersion: 2, priorRuntimeAuditId: secondAuditId, markerFingerprint: marker }, + }], + }, + ], + }) + const markup = renderToStaticMarkup( + undefined} />, + ) + + expect(markup).toContain('aria-label="Packet package: Operator recovery is available: Retry packet execution"') + expect(markup).toContain('aria-label="Second packet package: Operator recovery is available: Retry packet execution"') + expect(canonicalMcpOperatorActionRequest(presentation.recoveries[0].actions[0], freshness)).toMatchObject({ priorRuntimeAuditId: auditId }) + expect(canonicalMcpOperatorActionRequest(presentation.recoveries[1].actions[0], freshness)).toMatchObject({ priorRuntimeAuditId: secondAuditId }) + }) + + it('rejects mixed action families in both directions and never creates a request', () => { + const invalidActions = [ + { + action: 'retry_execution', + label: 'Invalid mixed action', + identity: { schemaVersion: 1, localRunEvidenceId: evidenceId, evidenceFingerprint: evidence }, + }, + { + action: 'retry_local_execution', + label: 'Invalid mixed action', + identity: { schemaVersion: 2, priorRuntimeAuditId: auditId, markerFingerprint: marker }, + }, + ] satisfies readonly CanonicalMcpOperatorAction[] + for (const action of invalidActions) { + expect(canonicalMcpTaskPresentationFromUnknown(packet({ + recoveries: [{ ...packet().recoveries[0], actions: [action] }], + }))).toBeNull() + expect(canonicalMcpOperatorActionRequest(action, freshness)).toBeNull() + const forgedMarkup = renderToStaticMarkup( + undefined} + />, + ) + expect(forgedMarkup).not.toContain(' { + vi.useFakeTimers() + const observedAt = new Date('2026-07-31T00:00:00.000Z') + vi.setSystemTime(observedAt) + const presentation = packet({ computedAt: observedAt.toISOString() }) + expect(canonicalMcpPresentationIsFresh(presentation)).toBe(true) + expect(renderToStaticMarkup( undefined} />)).toContain(' undefined} />) + expect(staleMarkup).not.toContain('Retry packet execution') + expect(staleMarkup).toContain('Refresh runtime state') + expect(staleMarkup).toContain('Recovery actions are hidden') + vi.useRealTimers() + }) + + it('offers an explicit quiet-state refresh that restores controls and ignores an older response', () => { + const stale = packet({ computedAt: new Date(0).toISOString() }) + const staleMarkup = renderToStaticMarkup( + undefined} />, + ) + expect(staleMarkup).toContain('Refresh runtime state') + expect(staleMarkup).not.toContain('Retry packet execution') + + const refreshedMarkup = renderToStaticMarkup( + undefined} />, + ) + expect(refreshedMarkup).toContain('Retry packet execution') + + const requests = createMcpPresentationRequestSequencer() + const older = requests.begin() + const newer = requests.begin() + expect(requests.isCurrent(older)).toBe(false) + expect(requests.isCurrent(newer)).toBe(true) + }) + + it('fails closed for future observations and client clock rollback', () => { + const observedAt = new Date('2026-07-31T00:00:30.000Z') + const presentation = packet({ computedAt: observedAt.toISOString() }) + + expect(canonicalMcpPresentationAgeMs(presentation, observedAt.getTime() - 1)).toBeNull() + expect(canonicalMcpPresentationIsFresh(presentation, observedAt.getTime() - 1)).toBe(false) + expect(canonicalMcpPresentationIsFresh( + packet({ computedAt: new Date(observedAt.getTime() + CANONICAL_MCP_PRESENTATION_MAX_AGE_MS + 1).toISOString() }), + observedAt.getTime(), + )).toBe(false) + }) + + it('keeps current and terminal-only observations non-live while terminal outcomes stay live', () => { + const observedAt = new Date() + const currentMarkup = renderToStaticMarkup( + undefined} />, + ) + expect(currentMarkup).toContain('aria-hidden="true"') + expect(currentMarkup).not.toContain('role="status"') + + const staleMarkup = renderToStaticMarkup( + undefined} />, + ) + expect(staleMarkup).toContain('role="status"') + expect(staleMarkup).toContain('Recovery actions are hidden') + + const terminalMarkup = renderToStaticMarkup( + , + ) + const terminalOnlyMarkup = renderToStaticMarkup( + , + ) + expect(terminalMarkup).toContain('role="status"') + expect(terminalMarkup).toContain('aria-live="polite"') + expect(terminalOnlyMarkup).not.toContain('role="status"') + }) + + it('hides controls for unavailable, terminal, and stale/unknown server presentations while branding terminal evidence', () => { + const presentation = packet({ + localEvidenceAvailable: false, + recoveries: [{ + ...packet().recoveries[0], + badgeText: 'Terminal', + actions: [], + }], + terminals: [{ + workPackageId: packageId, + title: 'Packet package', + state: 'terminal', + outcome: 'failed', + terminalAt: '2026-07-30T00:00:00.000Z', + }], + }) + const markup = renderToStaticMarkup( + undefined} />, + ) + + expect(markup).not.toContain(' { + it('keeps the client task page on the pure branded view and the server wrapper on the same view', async () => { + const [taskPage, serverWrapper] = await Promise.all([ + readFile('app/dashboard/tasks/[id]/page.tsx', 'utf8'), + readFile('components/mcps/BrandedTerminalJoin.tsx', 'utf8'), + ]) + + expect(taskPage).toContain("from '@/components/mcps/BrandedTerminalJoinView'") + expect(taskPage).not.toContain("from '@/components/mcps/BrandedTerminalJoin'") + expect(serverWrapper).toContain("import 'server-only'") + expect(serverWrapper).toContain("from './BrandedTerminalJoinView'") + }) +}) diff --git a/web/app/api/mcps/presentation/[taskId]/route.ts b/web/app/api/mcps/presentation/[taskId]/route.ts index be03beea..ae169571 100644 --- a/web/app/api/mcps/presentation/[taskId]/route.ts +++ b/web/app/api/mcps/presentation/[taskId]/route.ts @@ -1,12 +1,12 @@ import 'server-only' import { NextResponse, type NextRequest } from 'next/server' -import { admissionProjection } from '@/lib/mcps/s5-server-reader' +import { canonicalTaskPresentationProjection } from '@/lib/mcps/s5-server-reader' import { readAuthorizedS5State, S5RouteAuthorizationError } from '@/lib/mcps/s5-route' export async function GET(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { try { const { taskId } = await params - return NextResponse.json(admissionProjection((await readAuthorizedS5State(request, taskId)).state)) + return NextResponse.json(canonicalTaskPresentationProjection((await readAuthorizedS5State(request, taskId)).state)) } catch (error) { if (error instanceof S5RouteAuthorizationError) return NextResponse.json({ error: error.message }, { status: error.status }) console.error('[mcps/presentation GET] Unexpected fixed-category failure') diff --git a/web/app/dashboard/tasks/[id]/page.tsx b/web/app/dashboard/tasks/[id]/page.tsx index 2fe558fe..b091c4a4 100644 --- a/web/app/dashboard/tasks/[id]/page.tsx +++ b/web/app/dashboard/tasks/[id]/page.tsx @@ -28,6 +28,7 @@ import { } from '@/components/ui/select' import { MarkdownView } from '@/components/MarkdownView' import { McpPresentation } from '@/components/mcps/McpPresentation' +import { BrandedTerminalJoinView } from '@/components/mcps/BrandedTerminalJoinView' import { PlanDiffView } from '@/components/PlanDiffView' import { mergeAgentRun, useTaskStream } from '@/hooks/useTaskStream' import type { AgentRun, Artifact, TaskQuestion } from '@/hooks/useTaskStream' @@ -42,7 +43,13 @@ import { } from '@/lib/mcps/execution-design-metadata' import { admissionPresentationFromUnknown, - type PresentationCta, + CANONICAL_MCP_PRESENTATION_MAX_AGE_MS, + canonicalMcpOperatorActionIsBound, + canonicalMcpPresentationAgeMs, + canonicalMcpPresentationIsFresh, + canonicalMcpTaskPresentationFromUnknown, + type CanonicalMcpOperatorAction, + type CanonicalMcpTaskPresentation, } from '@/lib/mcps/admission-copy' import { latestMcpPlanReviewForDisplay, @@ -1723,69 +1730,173 @@ function BrokerRetrySummary({ broker }: { broker: WorkforceRecord | null }) { ) } -function PresentationActionButton({ - action, - onActivate, +export function CanonicalMcpOperatorPanel({ + presentation, + pending, + refreshPending = false, + onAction, + onRefresh = () => undefined, }: { - action: PresentationCta - onActivate: (action: PresentationCta) => void + presentation: CanonicalMcpTaskPresentation | null + pending: boolean + refreshPending?: boolean + onAction: (action: CanonicalMcpOperatorAction) => void + onRefresh?: () => void }) { - const decline = action.kind === 'decline_packet_recovery' || action.kind === 'decline_local_retry' + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, []) + if (presentation === null) return null + const freshnessAge = canonicalMcpPresentationAgeMs(presentation, now) + const isFresh = canonicalMcpPresentationIsFresh(presentation, now) + const freshnessSeconds = freshnessAge === null ? -1 : Math.floor(freshnessAge / 1000) + const terminalPackageIds = new Set(presentation.terminals.map((terminal) => terminal.workPackageId)) + const actionableRecoveries = presentation.recoveries.filter((recovery) => !terminalPackageIds.has(recovery.workPackageId)) return ( - +
+
+
+

+ MCP runtime state +

+

+ This view is one server observation. Recovery controls disappear if its evidence or terminal state cannot be verified. +

+
+ + Server observed + +
+ + {!isFresh && ( +
+

+ This observation is older than {CANONICAL_MCP_PRESENTATION_MAX_AGE_MS / 1000} seconds. Recovery actions are hidden until Forge refreshes it. +

+ +
+ )} + + {!presentation.localEvidenceAvailable && ( +

+ Protected local evidence is unavailable. Recovery actions are hidden until Forge can verify it again. +

+ )} + + {presentation.admission.some((item) => item.requiresMcp) && ( +
+ {presentation.admission.filter((item) => item.requiresMcp).map((item) => ( +
+
{item.title}
+
+ {item.decision === 'approved' ? 'Current admission approved' : item.decision === 'denied' ? 'Current admission denied' : 'Current admission unavailable'} +
+
+ ))} +
+ )} + + {actionableRecoveries.length > 0 && ( +
+ {actionableRecoveries.map((recovery) => { + const recoveryActions = isFresh ? recovery.actions.filter(canonicalMcpOperatorActionIsBound) : [] + const headingId = `mcp-recovery-${recovery.workPackageId}-heading` + return ( +
+

{recovery.title}: {recovery.headline}

+ 0 ? 'action_required' : 'deferred', + tone: recovery.tone, + badgeText: recovery.badgeText, + headline: `${recovery.title}: ${recovery.headline}`, + body: recovery.body, + actions: [], + }} + /> + {recoveryActions.length > 0 && ( +
+ {recoveryActions.map((action) => ( + + ))} +
+ )} +
+ ) + })} +
+ )} + + {presentation.terminals.length > 0 && ( +
+ {presentation.terminals.map((terminal) => terminal.state === 'terminal' ? ( + + {terminal.title}: {terminal.outcome === 'succeeded' ? 'completed' : 'failed'} + + ) : ( + + {terminal.title}: terminal evidence unavailable + + ))} +
+ )} +
) } -function McpGrantCards({ - grants, - onAction, - packageId, - projectId, -}: { - grants: WorkforceRecord[] - onAction: (action: PresentationCta) => void - packageId: string - projectId: string -}) { - if (grants.length === 0) return null - const packageGrantTargetId = packageId === '' ? undefined : `filesystem-grant-${packageId}` +export function canonicalMcpOperatorActionRequest( + action: CanonicalMcpOperatorAction, + expectedFreshnessFingerprint: string, +): Record | null { + if (!canonicalMcpOperatorActionIsBound(action)) return null + const identity = action.identity + return identity.schemaVersion === 1 + ? { + schemaVersion: 1, + action: action.action, + expectedFreshnessFingerprint, + localRunEvidenceId: identity.localRunEvidenceId, + evidenceFingerprint: identity.evidenceFingerprint, + } + : { + schemaVersion: 1, + action: action.action, + expectedFreshnessFingerprint, + priorRuntimeAuditId: identity.priorRuntimeAuditId, + markerFingerprint: identity.markerFingerprint, + } +} - return ( -
-

Current MCP admission

-

- Current package admission is separate from plan history and run evidence. No live MCP tool handles are issued in beta. -

-
- {grants.map((grant, index) => { - const presentation = admissionPresentationFromUnknown({ - ...grant, - retryable: booleanField(grant, ['retryable']) ?? false, - }, { - projectId, - ...(packageGrantTargetId ? { packageGrantTargetId } : {}), - }) - return ( - ( - - )} - /> - ) - })} -
-
- ) +/** Monotonic client request ordering prevents an older presentation response from winning. */ +export function createMcpPresentationRequestSequencer() { + let latestRequest = 0 + return { + begin: () => ++latestRequest, + isCurrent: (requestId: number) => requestId === latestRequest, + } } function filesystemEffectiveState(pkg: WorkPackage): { @@ -2727,8 +2838,6 @@ function WorkforcePanel({ filesystemAudits, fallbackAgents, onGateDecided, - onMcpAction, - projectId, taskId, taskStatus, artifacts, @@ -2741,8 +2850,6 @@ function WorkforcePanel({ filesystemAudits: FilesystemAudit[] fallbackAgents: PlannedAgent[] onGateDecided: () => Promise - onMcpAction: (action: PresentationCta) => void - projectId: string taskId: string taskStatus: string | null artifacts: Artifact[] @@ -2808,7 +2915,6 @@ function WorkforcePanel({ const harnessName = stringField(pkg, ['harnessDisplayName', 'harnessRole']) const mcpRequirements = jsonArrayField(pkg, ['mcpRequirements']) const pkgMetadata = recordField(pkg, ['metadata']) - const mcpGrants = pkgMetadata ? jsonArrayField(pkgMetadata, ['mcpGrants', 'grants']) : [] const mcpSubtasks = pkgMetadata ? jsonArrayField(pkgMetadata, ['mcpAwareSubtasks', 'mcpSubtasks']) : [] const broker = mcpBrokerMetadata(pkg) const packageArtifacts = packageArtifactsFor(pkg, artifacts) @@ -3003,12 +3109,6 @@ function WorkforcePanel({ taskId={taskId} taskStatus={taskStatus} /> - {packageArtifacts.length > 0 && (
@@ -3416,7 +3516,6 @@ export function initialMcpReviewItems( function McpAccessPlanPanel({ approvalGate, design, - onAction, onSaved, projectId, status, @@ -3424,7 +3523,6 @@ function McpAccessPlanPanel({ }: { approvalGate: ApprovalGate | null design: McpExecutionDesignMetadata | null - onAction: (action: PresentationCta) => void onSaved: () => Promise projectId: string status: string @@ -3577,9 +3675,6 @@ function McpAccessPlanPanel({
  • ( - - )} />
  • ) @@ -4229,7 +4324,9 @@ export default function TaskDetailPage() { const [attempts, setAttempts] = useState([]) const [workPackages, setWorkPackages] = useState([]) const [approvalGates, setApprovalGates] = useState([]) - const [mcpFreshness, setMcpFreshness] = useState(null) + const [mcpPresentation, setMcpPresentation] = useState(null) + const [mcpPresentationLoading, setMcpPresentationLoading] = useState(true) + const [mcpPresentationError, setMcpPresentationError] = useState(null) const [mcpActionError, setMcpActionError] = useState(null) const [mcpActionPending, setMcpActionPending] = useState(false) const [vcsChanges, setVcsChanges] = useState([]) @@ -4260,6 +4357,7 @@ export default function TaskDetailPage() { const [retrySubmitted, setRetrySubmitted] = useState(false) const liveLogTimersRef = useRef>(new Map()) const lastLogSequenceRef = useRef(0) + const mcpPresentationRequestRef = useRef(createMcpPresentationRequestSequencer()) // SSE stream const { @@ -4397,17 +4495,30 @@ export default function TaskDetailPage() { // The canonical server presentation. Its freshness fingerprint is the exact // state the operator is looking at; every operator action echoes it back so // the server can refuse (409, zero mutation) if anything moved underneath. - const loadMcpPresentation = useCallback(async () => { + const loadMcpPresentation = useCallback(async (options: { preserveOnError?: boolean } = {}) => { + const requestId = mcpPresentationRequestRef.current.begin() + setMcpPresentationLoading(true) + setMcpPresentationError(null) try { const res = await fetch(`/api/mcps/presentation/${taskId}`) if (!res.ok) { - setMcpFreshness(null) + if (mcpPresentationRequestRef.current.isCurrent(requestId)) { + if (!options.preserveOnError) setMcpPresentation(null) + setMcpPresentationError('Current MCP runtime state could not be refreshed. Try again.') + } return } const body = await res.json().catch(() => null) - setMcpFreshness(typeof body?.freshnessFingerprint === 'string' ? body.freshnessFingerprint : null) + if (mcpPresentationRequestRef.current.isCurrent(requestId)) { + setMcpPresentation(canonicalMcpTaskPresentationFromUnknown(body)) + } } catch { - setMcpFreshness(null) + if (mcpPresentationRequestRef.current.isCurrent(requestId)) { + if (!options.preserveOnError) setMcpPresentation(null) + setMcpPresentationError('Current MCP runtime state could not be refreshed. Try again.') + } + } finally { + if (mcpPresentationRequestRef.current.isCurrent(requestId)) setMcpPresentationLoading(false) } }, [taskId]) @@ -4467,14 +4578,16 @@ export default function TaskDetailPage() { if (taskStatus && REFRESH_STATUSES.has(taskStatus)) { loadTask() loadLogs() + loadMcpPresentation() } - }, [taskStatus, loadLogs, loadTask]) + }, [taskStatus, loadLogs, loadMcpPresentation, loadTask]) useEffect(() => { if (streamRefreshRevision > 0) { loadTask() + loadMcpPresentation() } - }, [streamRefreshRevision, loadTask]) + }, [streamRefreshRevision, loadMcpPresentation, loadTask]) useEffect(() => { if (taskLogRevision > 0) { @@ -4659,63 +4772,12 @@ export default function TaskDetailPage() { } } - function handleMcpPresentationAction(action: PresentationCta) { - if (action.kind === 'link') { - router.push(action.href) - return - } - - // Packet-context reapproval is deliberately not an S5 recovery action. - // It is completed through the existing project filesystem decision - // control below, which owns the current decision/version compare-and-set. - if ( - action.kind !== 'reapprove_packet_context' - && mcpFreshness !== null - && 'request' in action - && action.request - ) { - const identity = action.request - if (identity.schemaVersion === 1) { - void submitMcpOperatorAction({ - schemaVersion: 1, - action: action.kind, - expectedFreshnessFingerprint: mcpFreshness, - localRunEvidenceId: identity.localRunEvidenceId, - evidenceFingerprint: identity.evidenceFingerprint, - }) - } else { - void submitMcpOperatorAction({ - schemaVersion: 1, - action: action.kind === 'retry_packet_execution' - ? 'retry_execution' - : action.kind === 'review_submission' - ? 'acknowledge_possible_submission' - : action.kind, - expectedFreshnessFingerprint: mcpFreshness, - priorRuntimeAuditId: identity.priorRuntimeAuditId, - markerFingerprint: identity.markerFingerprint, - }) - } - } - - const targetId = action.kind === 'scroll' - ? action.targetId - : action.kind === 'request_changes' - ? 'task-plan-actions' - : action.kind === 'reapprove_packet_context' - ? action.targetId - : null - if (action.kind === 'request_changes') { - setActionMode('replan') - setActionError(null) - } - if (targetId) { - window.requestAnimationFrame(() => { - const target = document.getElementById(targetId) - target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - target?.focus({ preventScroll: true }) - }) - } + function handleCanonicalMcpOperatorAction(action: CanonicalMcpOperatorAction) { + if (mcpPresentation === null) return + if (!canonicalMcpPresentationIsFresh(mcpPresentation)) return + const request = canonicalMcpOperatorActionRequest(action, mcpPresentation.freshnessFingerprint) + if (request === null) return + void submitMcpOperatorAction(request) } if (loading) { @@ -5219,15 +5281,31 @@ export default function TaskDetailPage() { {mcpActionError}

    )} + {mcpPresentationError !== null && ( +

    + {mcpPresentationError} +

    + )} {mcpActionPending && (

    Applying the MCP recovery action…

    )} + { void loadMcpPresentation({ preserveOnError: true }) }} + /> + {!mcpPresentationLoading && mcpPresentation === null && ( +

    + Current MCP runtime state is unavailable. Recovery controls are hidden until Forge can load one verified observation. +

    + )} { - switch (presentation.state) { - case 'terminal': - return 'border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400' - case 'current': - return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300' - case 'terminal_only': - return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300' - } - })() - - const badge = (() => { - switch (presentation.state) { - case 'terminal': - return ( - - - Terminal - {presentation.terminalAt} - - ) - case 'current': - return ( - - - Current - - {presentation.freshnessSeconds}s - - - ) - case 'terminal_only': - return ( - - - - Terminal Only - - - ) - } - })() - - return ( -
    - {badge} - {children && {children}} -
    - ) + return {children} } export type FreshnessJoinProps = { diff --git a/web/components/mcps/BrandedTerminalJoinView.tsx b/web/components/mcps/BrandedTerminalJoinView.tsx new file mode 100644 index 00000000..c963230c --- /dev/null +++ b/web/components/mcps/BrandedTerminalJoinView.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' + +/** Client-safe rendering for an already-authorized terminal observation. */ +export type TerminalJoinPresentation = + | { state: 'terminal'; terminalAt: string; outcome: string } + | { state: 'current'; freshnessSeconds: number; fingerprint: string } + | { state: 'terminal_only'; message: string } + +export type BrandedTerminalJoinViewProps = { + presentation: TerminalJoinPresentation + className?: string + children?: ReactNode +} + +export function BrandedTerminalJoinView({ presentation, className, children }: BrandedTerminalJoinViewProps) { + const base = 'flex items-center gap-2 text-xs font-mono px-3 py-1.5 rounded-md border' + const colorClass = presentation.state === 'terminal' + ? 'border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400' + : presentation.state === 'current' + ? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300' + : 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300' + const badge = presentation.state === 'terminal' + ? Terminal{presentation.terminalAt} + : presentation.state === 'current' + ? Current + : Terminal Only + return ( +
    + {badge}{children ? {children} : null} +
    + ) +} diff --git a/web/db/index.ts b/web/db/index.ts index 2bffb5bb..12966d30 100644 --- a/web/db/index.ts +++ b/web/db/index.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -6,6 +7,11 @@ import { getRequiredEnv } from '@/lib/env' type ForgeDb = PostgresJsDatabase type PostgresClient = ReturnType +type ForgeTransaction = Parameters[0]>[0] + +// PostgreSQL exports `XXXXXXXX-XXXXXXXX-X` snapshot IDs. Keep every segment +// hexadecimal and bounded before the protected reader embeds it as a literal. +const POSTGRES_SNAPSHOT_ID = /^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{1,8}$/i const globalForDb = globalThis as unknown as { forgeDb: ForgeDb | undefined @@ -46,3 +52,29 @@ export async function closeDb(): Promise { globalForDb.forgeDbClient = undefined globalForDb.forgeDb = undefined } + +/** + * Pins ordinary S5 reads to one PostgreSQL snapshot while a least-privilege + * reader imports that same observation. The snapshot never leaves this + * server-side callback and the exporter remains open until it has completed. + */ +export async function withExportedRepeatableReadSnapshot(input: { + run: (tx: ForgeTransaction, snapshotId: string, databaseUrl: string) => Promise +}): Promise { + const databaseUrl = getRequiredEnv('DATABASE_URL') + const client = postgres(databaseUrl, { max: 1, prepare: true, onnotice: () => {} }) + const database = drizzle(client, { schema }) + try { + return await database.transaction(async (tx) => { + const [{ snapshotId }] = await tx.execute<{ snapshotId: string }>( + sql`select pg_export_snapshot() as "snapshotId"`, + ) + if (typeof snapshotId !== 'string' || !POSTGRES_SNAPSHOT_ID.test(snapshotId)) { + throw new Error('PostgreSQL returned an invalid exported snapshot identifier.') + } + return input.run(tx, snapshotId, databaseUrl) + }, { isolationLevel: 'repeatable read', accessMode: 'read only' }) + } finally { + await client.end({ timeout: 5 }).catch(() => {}) + } +} diff --git a/web/lib/mcps/admission-copy.ts b/web/lib/mcps/admission-copy.ts index 420bbea1..753064e4 100644 --- a/web/lib/mcps/admission-copy.ts +++ b/web/lib/mcps/admission-copy.ts @@ -128,6 +128,84 @@ export type LocalEffectRecoveryRequestIdentity = Readonly<{ evidenceFingerprint: string }> +/** + * The task-detail page receives this compact DTO from the S5 presentation + * route. It is deliberately a wire contract rather than a second recovery + * state machine: the server decides whether an action exists, and the browser + * can only echo its exact endpoint identity with this observation's freshness + * fingerprint. + */ +export type CanonicalMcpOperatorAction = Readonly<{ + action: + | 'review_local_changes' + | 'acknowledge_possible_local_invocation' + | 'retry_local_execution' + | 'decline_local_retry' + | 'acknowledge_possible_submission' + | 'retry_execution' + | 'decline_packet_recovery' + label: string + identity: LocalEffectRecoveryRequestIdentity | PacketRecoveryRequestIdentity +}> + +export type CanonicalMcpRecoveryPresentation = Readonly<{ + workPackageId: string + title: string + headline: string + body: string + badgeText: string + tone: AdmissionPresentation['tone'] + actions: readonly CanonicalMcpOperatorAction[] +}> + +export type CanonicalMcpAdmissionPresentation = Readonly<{ + workPackageId: string + title: string + requiresMcp: boolean + decision: 'approved' | 'denied' | 'unavailable' +}> + +export type CanonicalMcpTerminalPresentation = Readonly<{ + workPackageId: string + title: string + state: 'terminal' | 'unavailable' + outcome: 'succeeded' | 'failed' | null + terminalAt: string | null +}> + +export type CanonicalMcpTaskPresentation = Readonly<{ + schemaVersion: 1 + computedAt: string + freshnessFingerprint: string + taskId: string + localEvidenceAvailable: boolean + admission: readonly CanonicalMcpAdmissionPresentation[] + recoveries: readonly CanonicalMcpRecoveryPresentation[] + terminals: readonly CanonicalMcpTerminalPresentation[] +}> + +/** Browser observations expire quickly so a delayed tab cannot submit stale evidence. */ +export const CANONICAL_MCP_PRESENTATION_MAX_AGE_MS = 30_000 + +export function canonicalMcpPresentationAgeMs( + presentation: Pick, + now = Date.now(), +): number | null { + const computedAt = Date.parse(presentation.computedAt) + if (!Number.isFinite(computedAt) || !Number.isFinite(now)) return null + const age = now - computedAt + // A browser clock behind the server observation cannot safely authorize an action. + return age < 0 ? null : age +} + +export function canonicalMcpPresentationIsFresh( + presentation: Pick, + now = Date.now(), +): boolean { + const age = canonicalMcpPresentationAgeMs(presentation, now) + return age !== null && age <= CANONICAL_MCP_PRESENTATION_MAX_AGE_MS +} + export type PresentationCta = | { kind: 'scroll'; label: string; targetId: string } | { kind: 'link'; label: string; href: string } @@ -1507,3 +1585,128 @@ export function catalogMcpPresentationFromUnknown( if (!isRecord(value) || !isRecord(value.runtime)) return unavailablePresentation() return catalogMcpPresentation(value as CatalogMcpPresentationInput) } + +const CANONICAL_OPERATOR_ACTIONS = new Set([ + 'review_local_changes', + 'acknowledge_possible_local_invocation', + 'retry_local_execution', + 'decline_local_retry', + 'acknowledge_possible_submission', + 'retry_execution', + 'decline_packet_recovery', +]) + +function actionMatchesIdentity(action: CanonicalMcpOperatorAction['action'], schemaVersion: number): boolean { + return schemaVersion === 1 + ? LOCAL_EFFECT_RECOVERY_ACTIONS.includes(action as typeof LOCAL_EFFECT_RECOVERY_ACTIONS[number]) + : schemaVersion === 2 && PACKET_ISSUANCE_RECOVERY_ACTIONS.includes(action as typeof PACKET_ISSUANCE_RECOVERY_ACTIONS[number]) +} + +/** The DTO action name and its evidence family are a single closed contract. */ +export function canonicalMcpOperatorActionIsBound(action: CanonicalMcpOperatorAction): boolean { + return actionMatchesIdentity(action.action, action.identity.schemaVersion) +} + +function canonicalActionFromUnknown(value: unknown): CanonicalMcpOperatorAction | null { + if (!isRecord(value) || !CANONICAL_OPERATOR_ACTIONS.has(value.action as CanonicalMcpOperatorAction['action'])) return null + if (typeof value.label !== 'string' || value.label.length === 0 || value.label.length > 160 || !isRecord(value.identity)) return null + const identity = value.identity + if ( + identity.schemaVersion === 1 && + actionMatchesIdentity(value.action as CanonicalMcpOperatorAction['action'], 1) && + UUID.test(identity.localRunEvidenceId as string) && + FINGERPRINT.test(identity.evidenceFingerprint as string) + ) { + return { + action: value.action as CanonicalMcpOperatorAction['action'], + label: value.label, + identity: { + schemaVersion: 1, + localRunEvidenceId: identity.localRunEvidenceId as string, + evidenceFingerprint: identity.evidenceFingerprint as string, + }, + } + } + if ( + identity.schemaVersion === 2 && + actionMatchesIdentity(value.action as CanonicalMcpOperatorAction['action'], 2) && + UUID.test(identity.priorRuntimeAuditId as string) && + FINGERPRINT.test(identity.markerFingerprint as string) + ) { + return { + action: value.action as CanonicalMcpOperatorAction['action'], + label: value.label, + identity: { + schemaVersion: 2, + priorRuntimeAuditId: identity.priorRuntimeAuditId as string, + markerFingerprint: identity.markerFingerprint as string, + }, + } + } + return null +} + +/** Returns null for any incomplete or mismatched server DTO, so controls fail closed. */ +export function canonicalMcpTaskPresentationFromUnknown(value: unknown): CanonicalMcpTaskPresentation | null { + if (!isRecord(value) || value.schemaVersion !== 1 || !UUID.test(value.taskId as string)) return null + if (typeof value.computedAt !== 'string' || Number.isNaN(Date.parse(value.computedAt))) return null + if (!FINGERPRINT.test(value.freshnessFingerprint as string) || typeof value.localEvidenceAvailable !== 'boolean') return null + if (!Array.isArray(value.admission) || !Array.isArray(value.recoveries) || !Array.isArray(value.terminals)) return null + + const admission: CanonicalMcpAdmissionPresentation[] = [] + for (const item of value.admission) { + if (!isRecord(item) || !UUID.test(item.workPackageId as string) || typeof item.title !== 'string' || typeof item.requiresMcp !== 'boolean') return null + if (!['approved', 'denied', 'unavailable'].includes(item.decision as string)) return null + admission.push({ + workPackageId: item.workPackageId as string, + title: item.title, + requiresMcp: item.requiresMcp, + decision: item.decision as CanonicalMcpAdmissionPresentation['decision'], + }) + } + + const recoveries: CanonicalMcpRecoveryPresentation[] = [] + for (const recovery of value.recoveries) { + if (!isRecord(recovery) || !UUID.test(recovery.workPackageId as string)) return null + if (typeof recovery.title !== 'string' || typeof recovery.headline !== 'string' || typeof recovery.body !== 'string' || typeof recovery.badgeText !== 'string') return null + if (!['neutral', 'positive', 'warning', 'danger'].includes(recovery.tone as string) || !Array.isArray(recovery.actions)) return null + const actions = recovery.actions.map(canonicalActionFromUnknown) + if (actions.some((action) => action === null)) return null + recoveries.push({ + workPackageId: recovery.workPackageId as string, + title: recovery.title, + headline: recovery.headline, + body: recovery.body, + badgeText: recovery.badgeText, + tone: recovery.tone as AdmissionPresentation['tone'], + actions: actions as CanonicalMcpOperatorAction[], + }) + } + + const terminals: CanonicalMcpTerminalPresentation[] = [] + for (const terminal of value.terminals) { + if (!isRecord(terminal) || !UUID.test(terminal.workPackageId as string)) return null + if (typeof terminal.title !== 'string' || !['terminal', 'unavailable'].includes(terminal.state as string)) return null + if (terminal.outcome !== null && terminal.outcome !== 'succeeded' && terminal.outcome !== 'failed') return null + if (terminal.terminalAt !== null && (typeof terminal.terminalAt !== 'string' || Number.isNaN(Date.parse(terminal.terminalAt)))) return null + if (terminal.state === 'terminal' && (terminal.outcome === null || terminal.terminalAt === null)) return null + if (terminal.state === 'unavailable' && (terminal.outcome !== null || terminal.terminalAt !== null)) return null + terminals.push({ + workPackageId: terminal.workPackageId as string, + title: terminal.title, + state: terminal.state as CanonicalMcpTerminalPresentation['state'], + outcome: terminal.outcome as CanonicalMcpTerminalPresentation['outcome'], + terminalAt: terminal.terminalAt as string | null, + }) + } + return { + schemaVersion: 1, + computedAt: value.computedAt, + freshnessFingerprint: value.freshnessFingerprint as string, + taskId: value.taskId as string, + localEvidenceAvailable: value.localEvidenceAvailable, + admission, + recoveries, + terminals, + } +} diff --git a/web/lib/mcps/s5-protected-reader.ts b/web/lib/mcps/s5-protected-reader.ts index fa8c1a32..86c4d0b1 100644 --- a/web/lib/mcps/s5-protected-reader.ts +++ b/web/lib/mcps/s5-protected-reader.ts @@ -20,6 +20,9 @@ import { fixedDatabaseRoleUrl } from '@/lib/mcps/fixed-database-url' // --------------------------------------------------------------------------- export const S5_LOCAL_EVIDENCE_READER_URL_ENV = 'FORGE_LOCAL_RUN_EVIDENCE_READER_DATABASE_URL' +// PostgreSQL exports `XXXXXXXX-XXXXXXXX-X` snapshot IDs. Keep every segment +// hexadecimal and bounded before embedding it as a transaction snapshot literal. +const POSTGRES_SNAPSHOT_ID = /^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{1,8}$/i export type S5ProtectedLocalRunEvidenceRow = Readonly<{ id: string @@ -59,6 +62,19 @@ function readerUrl(): string | null { } } +function sameDatabase(left: string, right: string): boolean { + try { + const [a, b] = [new URL(left), new URL(right)] + return ['postgres:', 'postgresql:'].includes(a.protocol) + && ['postgres:', 'postgresql:'].includes(b.protocol) + && a.hostname === b.hostname + && (a.port || '5432') === (b.port || '5432') + && a.pathname === b.pathname + } catch { + return false + } +} + export function s5LocalEvidenceReaderConfigured(): boolean { return readerUrl() !== null } @@ -71,9 +87,13 @@ export function s5LocalEvidenceReaderConfigured(): boolean { */ export async function readS5ProtectedTerminalSnapshot( taskId: string, + observation?: Readonly<{ snapshotId: string, databaseUrl: string }>, ): Promise { const url = readerUrl() if (!url) return null + if (observation && (!POSTGRES_SNAPSHOT_ID.test(observation.snapshotId) || !sameDatabase(url, observation.databaseUrl))) { + return null + } const sql = postgres(url, { max: 1, prepare: true, @@ -81,7 +101,14 @@ export async function readS5ProtectedTerminalSnapshot( transform: { undefined: null }, }) try { - const [snapshot] = await sql<{ + const snapshot = await sql.begin('isolation level repeatable read read only', async (tx) => { + if (observation) { + // postgres.js cannot parameterize a transaction snapshot literal. The + // exporter generated it locally and this strict validator permits only + // PostgreSQL's hexadecimal snapshot grammar before interpolation. + await tx.unsafe(`set transaction snapshot '${observation.snapshotId}'`) + } + const [result] = await tx<{ evidenceRows: Array<{ id: string workPackageId: string @@ -101,7 +128,7 @@ export async function readS5ProtectedTerminalSnapshot( terminalAt: string | null updatedAt: string }> - }[]>` + }[]>` select coalesce(( select jsonb_agg(jsonb_build_object( @@ -130,7 +157,9 @@ export async function readS5ProtectedTerminalSnapshot( from public.filesystem_mcp_runtime_audits audit where audit.task_id = ${taskId}::uuid ), '[]'::jsonb) as "auditRows" - ` + ` + return result + }) if (!snapshot || !Array.isArray(snapshot.evidenceRows) || !Array.isArray(snapshot.auditRows)) return null return { evidenceRows: snapshot.evidenceRows.map((row) => ({ diff --git a/web/lib/mcps/s5-server-reader.ts b/web/lib/mcps/s5-server-reader.ts index 9b8c72d5..f5fe12d9 100644 --- a/web/lib/mcps/s5-server-reader.ts +++ b/web/lib/mcps/s5-server-reader.ts @@ -2,15 +2,18 @@ import 'server-only' import { createHash } from 'node:crypto' import { and, asc, eq } from 'drizzle-orm' -import { db } from '@/db' +import { withExportedRepeatableReadSnapshot } from '@/db' import { filesystemMcpCurrentDecisionPointers, filesystemMcpGrantApprovals, projectFilesystemCurrentDecisionPointers, projectFilesystemGrantDecisions, + projects, tasks, workPackages, } from '@/db/schema' +import { admitMcpRequirement, readEffectiveGrantState, type EffectiveGrantState } from '@/lib/mcps/admission' +import { parseProjectFilesystemDecisionAuthority } from '@/lib/mcps/filesystem-project-authority' import { readS5ProtectedTerminalSnapshot } from '@/lib/mcps/s5-protected-reader' import { summarizeFilesystemCapabilities } from '@/lib/mcps/filesystem-grants' import { parseFilesystemGrantBlockMetadata } from '@/lib/mcps/filesystem-grant-lifecycle' @@ -31,6 +34,10 @@ import { localEffectRecoveryActionsForDisposition, packetIssuanceRecoveryActionsForDisposition, } from '@/lib/mcps/recovery-action-contract' +import type { + CanonicalMcpOperatorAction, + CanonicalMcpTaskPresentation, +} from '@/lib/mcps/admission-copy' const SHA256 = /^sha256:[0-9a-f]{64}$/ @@ -75,6 +82,15 @@ export type S5PackagePresenter = Readonly<{ blockMetadata: Record | null pointerFingerprint: string pointerVersion: string + effectiveAdmission?: Readonly<{ + phase: 'none' | 'proposed' | 'approved' | 'denied' | 'revoked' | 'not_issued' + source: 'none' | 'package-local' | 'project-level' + status: 'not_issued' | 'approved' | 'denied' + grantMode: 'allow_once' | 'always_allow' | null + consumed: boolean + coveredCapabilities: readonly string[] + revocationReason: string | null + }> }> export type S5RecoveryMarkerPresenter = Readonly<{ @@ -375,21 +391,30 @@ export function normalizeS5TerminalAudit(audit: { export async function readS5AuthoritativeTaskState( taskId: string, userId: string, + /** Fixture-only synchronization point; routes never supply this callback. */ + afterExporterSnapshotEstablished?: () => Promise, ): Promise { - const [task] = await db + return withExportedRepeatableReadSnapshot({ run: async (tx, snapshotId, databaseUrl) => { + // The real PostgreSQL fixture uses this bounded server-only seam to commit a + // competing transition after export and before the protected import. + if (afterExporterSnapshotEstablished) await afterExporterSnapshotEstablished() + const [task] = await tx .select({ id: tasks.id, projectId: tasks.projectId, status: tasks.status, updatedAt: tasks.updatedAt, + projectMcpConfig: projects.mcpConfig, + projectRootBindingRevision: projects.rootBindingRevision, }) .from(tasks) + .innerJoin(projects, eq(projects.id, tasks.projectId)) .where(and(eq(tasks.id, taskId), eq(tasks.submittedBy, userId))) .limit(1) if (!task) throw new S5TaskNotFoundError() const [packageRows, decisions, pointers, projectPointers, projectDecisions, protectedSnapshot] = await Promise.all([ - db.select({ + tx.select({ id: workPackages.id, title: workPackages.title, assignedRole: workPackages.assignedRole, @@ -399,7 +424,7 @@ export async function readS5AuthoritativeTaskState( metadata: workPackages.metadata, updatedAt: workPackages.updatedAt, }).from(workPackages).where(eq(workPackages.taskId, taskId)).orderBy(asc(workPackages.sequence), asc(workPackages.id)), - db.select({ + tx.select({ id: filesystemMcpGrantApprovals.id, taskId: filesystemMcpGrantApprovals.taskId, workPackageId: filesystemMcpGrantApprovals.workPackageId, @@ -412,7 +437,7 @@ export async function readS5AuthoritativeTaskState( createdAt: filesystemMcpGrantApprovals.createdAt, updatedAt: filesystemMcpGrantApprovals.updatedAt, }).from(filesystemMcpGrantApprovals).where(eq(filesystemMcpGrantApprovals.taskId, taskId)).orderBy(asc(filesystemMcpGrantApprovals.createdAt), asc(filesystemMcpGrantApprovals.id)), - db.select({ + tx.select({ taskId: filesystemMcpCurrentDecisionPointers.taskId, workPackageId: filesystemMcpCurrentDecisionPointers.workPackageId, currentDecisionId: filesystemMcpCurrentDecisionPointers.currentDecisionId, @@ -424,9 +449,9 @@ export async function readS5AuthoritativeTaskState( pointerVersion: filesystemMcpCurrentDecisionPointers.pointerVersion, updatedAt: filesystemMcpCurrentDecisionPointers.updatedAt, }).from(filesystemMcpCurrentDecisionPointers).where(eq(filesystemMcpCurrentDecisionPointers.taskId, taskId)), - db.select().from(projectFilesystemCurrentDecisionPointers).where(eq(projectFilesystemCurrentDecisionPointers.projectId, task.projectId)).limit(1), - db.select().from(projectFilesystemGrantDecisions).where(eq(projectFilesystemGrantDecisions.projectId, task.projectId)).orderBy(asc(projectFilesystemGrantDecisions.decisionGeneration)), - readS5ProtectedTerminalSnapshot(taskId), + tx.select().from(projectFilesystemCurrentDecisionPointers).where(eq(projectFilesystemCurrentDecisionPointers.projectId, task.projectId)).limit(1), + tx.select().from(projectFilesystemGrantDecisions).where(eq(projectFilesystemGrantDecisions.projectId, task.projectId)).orderBy(asc(projectFilesystemGrantDecisions.decisionGeneration)), + readS5ProtectedTerminalSnapshot(taskId, { snapshotId, databaseUrl }), ]) // A `null` protected read is "cannot be proven right now", not "no evidence". @@ -448,8 +473,42 @@ export async function readS5AuthoritativeTaskState( decidedAt: decision.createdAt.toISOString(), }) + const projectPointer = projectPointers[0] + const projectDecision = projectPointer?.currentDecisionId + ? projectDecisions.find((decision) => decision.id === projectPointer.currentDecisionId) + : undefined + const exactProjectDecision = projectDecision + && projectPointer.currentDecisionProjectId === task.projectId + && projectDecision.projectId === task.projectId + && projectDecision.grantDecisionRevision === projectPointer.currentDecisionRevision + && projectDecision.rootBindingRevision === projectPointer.currentRootBindingRevision + && projectDecision.decisionFingerprint === projectPointer.currentDecisionFingerprint + && projectDecision.decisionGeneration === projectPointer.currentDecisionGeneration + ? projectDecision + : null + const packages = packageRows.map((pkg): S5PackagePresenter => { - const summary = summarizeFilesystemCapabilities({ mcpRequirements: pkg.mcpRequirements, metadata: pkg.metadata }) + const summary = summarizeFilesystemCapabilities({ + mcpRequirements: pkg.mcpRequirements, + metadata: pkg.metadata, + projectMcpConfig: task.projectMcpConfig, + projectFilesystemDecision: exactProjectDecision ? { + schemaVersion: 2, + decisionId: exactProjectDecision.id, + projectId: exactProjectDecision.projectId, + decision: exactProjectDecision.decision, + capabilities: exactProjectDecision.capabilities, + grantDecisionRevision: exactProjectDecision.grantDecisionRevision.toString(), + rootBindingRevision: exactProjectDecision.rootBindingRevision.toString(), + decisionFingerprint: exactProjectDecision.decisionFingerprint, + decisionGeneration: exactProjectDecision.decisionGeneration.toString(), + decidedAt: exactProjectDecision.decidedAt.toISOString(), + decidedBy: exactProjectDecision.decidedBy, + reason: exactProjectDecision.reason, + revocationReason: exactProjectDecision.revocationReason, + } : undefined, + projectRootBindingRevision: task.projectRootBindingRevision, + }) const pointer = pointerByPackage.get(pkg.id) const current = pointer?.currentDecisionId ? decisionById.get(pointer.currentDecisionId) : undefined const exactCurrent = current @@ -462,6 +521,19 @@ export async function readS5AuthoritativeTaskState( && current.pointerFingerprint === pointer.currentDecisionFingerprint ? current : null + const authority = exactProjectDecision ? parseProjectFilesystemDecisionAuthority({ + schemaVersion: 2, decisionId: exactProjectDecision.id, projectId: exactProjectDecision.projectId, + decision: exactProjectDecision.decision, capabilities: exactProjectDecision.capabilities, + grantDecisionRevision: exactProjectDecision.grantDecisionRevision.toString(), rootBindingRevision: exactProjectDecision.rootBindingRevision.toString(), + decisionFingerprint: exactProjectDecision.decisionFingerprint, decisionGeneration: exactProjectDecision.decisionGeneration.toString(), + decidedAt: exactProjectDecision.decidedAt.toISOString(), decidedBy: exactProjectDecision.decidedBy, + reason: exactProjectDecision.reason, revocationReason: exactProjectDecision.revocationReason, + }) : null + const effective = readEffectiveGrantState({ metadata: pkg.metadata }, { + mcpConfig: task.projectMcpConfig, + filesystemGrantDecision: authority, + rootBindingRevision: task.projectRootBindingRevision, + }, summary.boundedRuntimeRequestedCapabilities) return { workPackageId: pkg.id, title: pkg.title, @@ -475,22 +547,18 @@ export async function readS5AuthoritativeTaskState( blockMetadata: parseFilesystemGrantBlockMetadata(pkg.metadata), pointerFingerprint: pointer?.pointerFingerprint ?? '', pointerVersion: pointer?.pointerVersion.toString() ?? '0', + effectiveAdmission: { + phase: effective.phase, + source: effective.source, + status: effective.status, + grantMode: effective.grantMode ?? null, + consumed: effective.consumed === true, + coveredCapabilities: effective.coveredCapabilities, + revocationReason: effective.revocationReason ?? null, + }, } }) - const projectPointer = projectPointers[0] - const projectDecision = projectPointer?.currentDecisionId - ? projectDecisions.find((decision) => decision.id === projectPointer.currentDecisionId) - : undefined - const exactProjectDecision = projectDecision - && projectPointer.currentDecisionProjectId === task.projectId - && projectDecision.projectId === task.projectId - && projectDecision.grantDecisionRevision === projectPointer.currentDecisionRevision - && projectDecision.rootBindingRevision === projectPointer.currentRootBindingRevision - && projectDecision.decisionFingerprint === projectPointer.currentDecisionFingerprint - && projectDecision.decisionGeneration === projectPointer.currentDecisionGeneration - ? projectDecision - : null const projectGrant = exactProjectDecision ? { id: exactProjectDecision.id, enabled: exactProjectDecision.decision === 'approved', @@ -507,7 +575,13 @@ export async function readS5AuthoritativeTaskState( .filter((pkg) => pkg.status === 'blocked') .flatMap((pkg) => normalizeS5RecoveryMarkers(pkg, evidenceRows, auditRows)) - const terminalPackages = auditRows.map((audit) => normalizeS5TerminalAudit(audit, evidenceRows)) + const terminalStatus = new Set(['completed', 'failed', 'cancelled', 'rejected']) + const terminalPackages = packageRows.flatMap((pkg) => { + if (!terminalStatus.has(pkg.status)) return [] + const audit = auditRows.filter((candidate) => candidate.workPackageId === pkg.id) + .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime() || a.id.localeCompare(b.id)).at(-1) + return audit ? [normalizeS5TerminalAudit(audit, evidenceRows)] : [] + }) const evidenceRecords = evidenceRows.map((evidence): S5LocalEvidencePresenter => ({ id: evidence.id, @@ -548,6 +622,7 @@ export async function readS5AuthoritativeTaskState( terminalPackages, evidenceRecords, } + }}) } // Every projection re-materializes its rows through these explicit field @@ -580,6 +655,10 @@ export function safePackagePresenter(pkg: S5PackagePresenter): S5PackagePresente blockMetadata: pkg.blockMetadata, pointerFingerprint: pkg.pointerFingerprint, pointerVersion: pkg.pointerVersion, + ...(pkg.effectiveAdmission ? { effectiveAdmission: { + ...pkg.effectiveAdmission, + coveredCapabilities: [...pkg.effectiveAdmission.coveredCapabilities], + } } : {}), } } @@ -666,3 +745,139 @@ export function terminalProjection(state: S5AuthoritativeTaskState): S5TerminalP terminalPackages: state.terminalPackages.map(safeTerminalPackagePresenter), } } + +const CANONICAL_ACTION_LABELS: Record = { + review_local_changes: 'I reviewed the local changes', + acknowledge_possible_local_invocation: 'I understand the prior local invocation may have happened', + retry_local_execution: 'Start another local attempt', + decline_local_retry: 'Do not retry — close this package', + acknowledge_possible_submission: 'I understand the prior submission may have happened', + retry_execution: 'Retry packet execution', + decline_packet_recovery: 'Do not retry this package', +} + +function canonicalRecoveryAction(marker: S5RecoveryMarkerPresenter, action: string): CanonicalMcpOperatorAction | null { + if (!(action in CANONICAL_ACTION_LABELS) || marker.evidenceId === null || marker.evidenceFingerprint === null) return null + const operatorAction = action as CanonicalMcpOperatorAction['action'] + if (marker.kind === 'local_effect_recovery' && [ + 'review_local_changes', 'acknowledge_possible_local_invocation', 'retry_local_execution', 'decline_local_retry', + ].includes(operatorAction)) { + return { + action: operatorAction, + label: CANONICAL_ACTION_LABELS[operatorAction], + identity: { schemaVersion: 1, localRunEvidenceId: marker.evidenceId, evidenceFingerprint: marker.evidenceFingerprint }, + } + } + if (marker.kind === 'packet_issuance' && [ + 'acknowledge_possible_submission', 'retry_execution', 'decline_packet_recovery', + ].includes(operatorAction)) { + return { + action: operatorAction, + label: CANONICAL_ACTION_LABELS[operatorAction], + identity: { schemaVersion: 2, priorRuntimeAuditId: marker.evidenceId, markerFingerprint: marker.evidenceFingerprint }, + } + } + return null +} + +const S5_HEALTHY_FILESYSTEM_STATUS = { + mcpId: 'filesystem', displayName: 'Filesystem', description: 'S5 admission projection', + installPath: 'server-owned', installState: 'installed' as const, status: 'healthy' as const, + enabled: true, error: null, checkedAt: '1970-01-01T00:00:00.000Z', +} + +/** Reuse the admission contract so S5 cannot weaken its coverage semantics. */ +export function s5EffectiveAdmissionDecision(pkg: S5PackagePresenter): 'approved' | 'denied' | 'unavailable' { + if (!pkg.effectiveAdmission || pkg.boundedRuntimeRequestedCapabilities.length === 0) return 'unavailable' + const effectiveGrant: EffectiveGrantState = { + phase: pkg.effectiveAdmission.phase, + source: pkg.effectiveAdmission.source, + status: pkg.effectiveAdmission.status, + coveredCapabilities: [...pkg.effectiveAdmission.coveredCapabilities], + ...(pkg.effectiveAdmission.grantMode ? { grantMode: pkg.effectiveAdmission.grantMode } : {}), + ...(pkg.effectiveAdmission.consumed ? { consumed: true } : {}), + ...(pkg.effectiveAdmission.revocationReason ? { revocationReason: pkg.effectiveAdmission.revocationReason as EffectiveGrantState['revocationReason'] } : {}), + } + const decision = admitMcpRequirement({ + mcpId: 'filesystem', agent: pkg.assignedRole, requirement: 'required', + requestedCapabilities: [...pkg.boundedRuntimeRequestedCapabilities], + packageProhibitedKeys: new Set(), status: S5_HEALTHY_FILESYSTEM_STATUS, + hasPromptOnlyContext: false, effectiveGrant, fallback: { action: 'block' }, + }) + if (decision.status === 'allowed' && decision.mode === 'bounded_context_approved') return 'approved' + return pkg.effectiveAdmission.phase === 'denied' || pkg.effectiveAdmission.phase === 'revoked' + ? 'denied' + : 'unavailable' +} + +/** + * The task UI's single S5 DTO. Recovery availability and terminal status are + * joined while the authoritative state is still in memory, preventing the + * browser from combining unrelated admission, recovery, and terminal reads. + */ +export function canonicalTaskPresentationProjection(state: S5AuthoritativeTaskState): CanonicalMcpTaskPresentation { + const packageById = new Map(state.packages.map((pkg) => [pkg.workPackageId, pkg])) + const terminalByPackage = new Map( + state.terminalPackages + .filter((terminal) => packageById.has(terminal.workPackageId)) + .map((terminal) => [terminal.workPackageId, terminal]), + ) + const terminalTask = ['completed', 'failed', 'cancelled', 'rejected'].includes(state.taskStatus) + const recoveries = state.recoveryMarkers.flatMap((marker) => { + const pkg = packageById.get(marker.workPackageId) + if (!pkg) return [] + const terminal = terminalByPackage.get(marker.workPackageId) + const terminalized = terminalTask || terminal?.state === 'terminal' + const actions = !state.localEvidenceAvailable || terminalized || marker.state !== 'current' + ? [] + : marker.allowedActions.map((action) => canonicalRecoveryAction(marker, action)).filter((action): action is CanonicalMcpOperatorAction => action !== null) + const unavailable = marker.state !== 'current' || !state.localEvidenceAvailable + return [{ + workPackageId: pkg.workPackageId, + title: pkg.title, + badgeText: terminalized ? 'Terminal' : unavailable ? 'Status unavailable' : actions.length > 0 ? 'Recovery available' : 'Recovery unavailable', + headline: terminalized + ? 'Package reached a terminal state' + : unavailable + ? 'Recovery state cannot be verified' + : actions.length > 0 + ? 'Operator recovery is available' + : 'Recovery is not available', + body: terminalized + ? 'This package has retained terminal evidence. Forge does not offer a recovery control from this observation.' + : unavailable + ? 'Forge cannot prove the required recovery evidence is current. No operator action is available.' + : actions.length > 0 + ? 'Choose a server-authorized action. Forge will re-check this exact observation before changing the package.' + : 'The current server observation does not authorize an operator recovery action.', + tone: terminalized ? 'neutral' as const : unavailable ? 'danger' as const : actions.length > 0 ? 'warning' as const : 'neutral' as const, + actions, + }] + }) + const terminals = state.terminalPackages.flatMap((terminal) => { + const pkg = packageById.get(terminal.workPackageId) + if (!pkg) return [] + return [{ + workPackageId: terminal.workPackageId, + title: pkg.title, + state: terminal.state, + outcome: terminal.terminalOutcome, + terminalAt: terminal.terminalAt, + }] + }) + return { + schemaVersion: 1, + computedAt: state.computedAt, + freshnessFingerprint: state.freshnessFingerprint, + taskId: state.taskId, + localEvidenceAvailable: state.localEvidenceAvailable, + admission: state.packages.map((pkg) => ({ + workPackageId: pkg.workPackageId, + title: pkg.title, + requiresMcp: pkg.boundedRuntimeRequestedCapabilities.length > 0, + decision: s5EffectiveAdmissionDecision(pkg), + })), + recoveries, + terminals, + } +}