diff --git a/apps/console/AGENTS.md b/apps/console/AGENTS.md index 90275eb1..e40e2568 100644 --- a/apps/console/AGENTS.md +++ b/apps/console/AGENTS.md @@ -14,6 +14,15 @@ the primary CommonPlace product surface. Railway must deploy this app with `railway.console.toml`; the root `railway.toml` belongs to the legacy web service. +Workspace imports require matching path-separator-delimited allowlists on both +services: `CONSOLE_WORKSPACE_ALLOWED_ROOTS` on this Console and the JSON tenant +map `COMMONPLACE_WORKSPACE_TENANT_ALLOWED_ROOTS` on commonplace-api. Each API +map key is the identity-derived tenant and each value is that tenant's array of +owned roots. The API canonicalizes requested directories, rejects overlapping +roots across tenants, and prevents symlink escapes. The legacy +`COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development +fallback only. Production must configure the Console list and tenant map. + ## Composition doctrine One material system. Two structural sources. Bridged, not blended. @@ -76,6 +85,7 @@ asks for inspiration, reference, mood, or direction. |---|---|---| | Split layout, tool window panels | `react-resizable-panels` | split geometry, persisted sizes, 1px `--ij-divider` handles | | Search everywhere, palettes | `cmdk` | command list, filtering, keyboard nav | +| Approval dialog | `cmdk` Dialog | focus-managed, keyboard-selectable one-shot Plan approval and rejection | | Tabular lenses | tablecn structure on `@tanstack/react-table` | record.table sorting, filtering, column model | | Row virtualization | `@tanstack/react-virtual` | large record sets | | Thread and messages | `@assistant-ui/react` 0.12 | message list, streaming, message state | @@ -97,6 +107,8 @@ asks for inspiration, reference, mood, or direction. | Agent plan | `@assistant-ui/react` plus 21st.dev `isaiahbjork/agent-plan` structure extraction | in-thread plan rows, tool labels, and run status | | Graph canvas | `@xyflow/react` (React Flow) over `@dagrejs/dagre` (layered ranking) | the proactivity graph, per SPEC-PROACTIVITY-GRAPH-WIRING: dagre computes the layered join topology (roots rank first, sinks last), React Flow owns pan, zoom, selection, and edge routing; nodes and edges are register-styled (base CSS only, `--xy-*` mapped to the register in app.css), dynamic-imported so the sentence and card altitudes load no graph bundle | | Graph node and sentence card building blocks | jalco-ui `commit-graph` and `repo-card` (ui.justinlevine.me), structure extraction | the commit-entry node (a response is a stack of agent-action steps a person builds) and the RepoCard sentence card; the jalco layouts are reproduced and every shadcn token re-skinned to the register | +| Goal Stack canvas | `@xyflow/react` plus `@dagrejs/dagre`, `cmdk`, and `@dnd-kit/core` | DAG canvas and progress edges, named left-to-right layout, searchable capability palette and approval dialog, deferred-affordance drag and drop | +| Workspace substrate | `@tanstack/react-virtual`, CodeMirror 6 merge, `cmdk`, and Radix Popover | semantic workspace entity tree, virtual rows, local-history revision diff, project Find, readiness detail | glide-data-grid is the escalation path for spreadsheet-scale grids only and is not used in this round. diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index d8a71f74..b80b51b7 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -14,6 +14,15 @@ the primary CommonPlace product surface. Railway must deploy this app with `railway.console.toml`; the root `railway.toml` belongs to the legacy web service. +Workspace imports require matching path-separator-delimited allowlists on both +services: `CONSOLE_WORKSPACE_ALLOWED_ROOTS` on this Console and the JSON tenant +map `COMMONPLACE_WORKSPACE_TENANT_ALLOWED_ROOTS` on commonplace-api. Each API +map key is the identity-derived tenant and each value is that tenant's array of +owned roots. The API canonicalizes requested directories, rejects overlapping +roots across tenants, and prevents symlink escapes. The legacy +`COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development +fallback only. Production must configure the Console list and tenant map. + ## Composition doctrine One material system. Two structural sources. Bridged, not blended. @@ -70,6 +79,7 @@ gap: add the row, with a named source, before writing code. |---|---|---| | Split layout, tool window panels | `react-resizable-panels` | split geometry, persisted sizes, 1px `--ij-divider` handles | | Search everywhere, palettes | `cmdk` | command list, filtering, keyboard nav | +| Approval dialog | `cmdk` Dialog | focus-managed, keyboard-selectable one-shot Plan approval and rejection | | Tabular lenses | tablecn structure on `@tanstack/react-table` | record.table sorting, filtering, column model | | Row virtualization | `@tanstack/react-virtual` | large record sets | | Thread and messages | `@assistant-ui/react` 0.12 | message list, streaming, message state | @@ -90,6 +100,8 @@ gap: add the row, with a named source, before writing code. | Context graph | D3 | deterministic ego graph layout and relation geometry | | Agent plan | `@assistant-ui/react` plus 21st.dev `isaiahbjork/agent-plan` structure extraction | in-thread plan rows, tool labels, and run status | | Graph canvas | `@xyflow/react` (React Flow) over `@dagrejs/dagre` (layered ranking) | the proactivity graph, per SPEC-PROACTIVITY-GRAPH-WIRING: dagre computes the layered join topology (roots rank first, sinks last), React Flow owns pan, zoom, selection, and edge routing; nodes and edges are register-styled (base CSS only, `--xy-*` mapped to the register in app.css), dynamic-imported so the sentence and card altitudes load no graph bundle | +| Goal Stack canvas | `@xyflow/react` plus `@dagrejs/dagre`, `cmdk`, and `@dnd-kit/core` | DAG canvas and progress edges, named left-to-right layout, searchable capability palette and approval dialog, deferred-affordance drag and drop | +| Workspace substrate | `@tanstack/react-virtual`, CodeMirror 6 merge, `cmdk`, and Radix Popover | semantic workspace entity tree, virtual rows, local-history revision diff, project Find, readiness detail | | Graph node and sentence card building blocks | jalco-ui `commit-graph` and `repo-card` (ui.justinlevine.me), structure extraction | the commit-entry node (a response is a stack of agent-action steps a person builds) and the RepoCard sentence card; the jalco layouts are reproduced and every shadcn token re-skinned to the register | glide-data-grid is the escalation path for spreadsheet-scale grids only and is diff --git a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-darwin.png b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-darwin.png index ef05196e..c32d5a43 100644 Binary files a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-darwin.png and b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-darwin.png differ diff --git a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-linux.png b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-linux.png index e5d97a3d..e950f094 100644 Binary files a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-linux.png and b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1280-light-linux.png differ diff --git a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-darwin.png b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-darwin.png index f599a47f..93433693 100644 Binary files a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-darwin.png and b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-darwin.png differ diff --git a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-linux.png b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-linux.png index 28a47a29..f0795446 100644 Binary files a/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-linux.png and b/apps/console/e2e/appearance.spec.ts-snapshots/workspace-1440-light-linux.png differ diff --git a/apps/console/e2e/cards.spec.ts b/apps/console/e2e/cards.spec.ts index 62566781..53f25a7f 100644 --- a/apps/console/e2e/cards.spec.ts +++ b/apps/console/e2e/cards.spec.ts @@ -34,8 +34,8 @@ test.describe('cards, actions, mentions', () => { test('the surface rail is the primary nav: far-left, switches screens', async ({ page }) => { const rail = page.locator('[data-surface-rail]'); await expect(rail).toBeVisible(); - // The exact five primary surfaces form an APG radio group. - await expect(rail.locator('[data-surface-nav]')).toHaveCount(5); + // The exact six primary surfaces form an APG radio group. + await expect(rail.locator('[data-surface-nav]')).toHaveCount(6); await expect(rail.locator('[data-surface-nav="console-chat"]')).toHaveAttribute( 'aria-checked', 'true', diff --git a/apps/console/e2e/console-ia.spec.ts b/apps/console/e2e/console-ia.spec.ts index b3ae5ebf..e9a8dca2 100644 --- a/apps/console/e2e/console-ia.spec.ts +++ b/apps/console/e2e/console-ia.spec.ts @@ -22,12 +22,12 @@ async function openSurface(page: Page, id: string) { test.describe('Console information architecture', () => { test.beforeEach(async ({ page }) => freshLoad(page)); - test('separates five surface radios from three companion toggles', async ({ page }) => { + test('separates six surface radios from three companion toggles', async ({ page }) => { await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', 'console-chat'); const surfaces = page.getByRole('radiogroup', { name: 'Surfaces' }).getByRole('radio'); - await expect(surfaces).toHaveCount(5); + await expect(surfaces).toHaveCount(6); expect(await surfaces.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('aria-label')))).toEqual([ - 'Chat surface', 'Workspace surface', 'Index surface', 'Documents surface', 'Cards surface', + 'Chat surface', 'Workspace surface', 'Goal Stack surface', 'Index surface', 'Documents surface', 'Cards surface', ]); await expect(surfaces.first()).toHaveAttribute('aria-checked', 'true'); const companions = page.locator('[data-companion-nav]'); @@ -40,9 +40,10 @@ test.describe('Console information architecture', () => { await expect(page.locator('[data-companion-nav="files"]')).toHaveAttribute('aria-pressed', 'true'); for (const [shortcut, surfaceId] of [ ['2', 'console-workspace'], - ['3', 'console-index'], - ['4', 'console-docs'], - ['5', 'console-cards'], + ['3', 'console-goals'], + ['4', 'console-index'], + ['5', 'console-docs'], + ['6', 'console-cards'], ['1', 'console-chat'], ] as const) { await page.keyboard.press(`Alt+${shortcut}`); @@ -188,12 +189,12 @@ test.describe('Console information architecture', () => { expect((bounds?.x ?? 390) + (bounds?.width ?? 0)).toBeLessThanOrEqual(390); }); - test('seeds Workspace with document, code, and compact Thread but no table rail', async ({ page }) => { + test('seeds Workspace with the substrate, reference tabs, and compact Thread', async ({ page }) => { await openSurface(page, 'console-workspace'); + await expect(page.locator('[data-workspace-substrate]')).toBeVisible(); await expect(page.getByRole('tab', { name: 'Console brief' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'surface-tree.ts' })).toBeVisible(); await expect(page.locator('[data-composer-density="compact"]')).toBeVisible(); - await expect(page.locator('.galley p').first()).toHaveCSS('font-size', '15px'); await expect(page.locator('tbody')).toHaveCount(0); }); diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-empty-darwin.png b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-empty-darwin.png index dca42ca5..4a1d0a06 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-empty-darwin.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-empty-darwin.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-plan-darwin.png b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-plan-darwin.png index 09b497df..44262a81 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-plan-darwin.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-plan-darwin.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-streaming-darwin.png b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-streaming-darwin.png index dffdc14e..0d389caa 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/chat-streaming-darwin.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/chat-streaming-darwin.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-darwin.png b/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-darwin.png index 6e5dedaf..85985f40 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-darwin.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-darwin.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-linux.png b/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-linux.png index 5e62c281..00b7c5ba 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-linux.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/files-projection-linux.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-darwin.png b/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-darwin.png index dba08a45..f85e56b8 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-darwin.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-darwin.png differ diff --git a/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-linux.png b/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-linux.png index dba08a45..7ee7771b 100644 Binary files a/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-linux.png and b/apps/console/e2e/console-ia.spec.ts-snapshots/stripe-groups-linux.png differ diff --git a/apps/console/e2e/workspace-goal-stack.spec.ts b/apps/console/e2e/workspace-goal-stack.spec.ts new file mode 100644 index 00000000..c75a32e7 --- /dev/null +++ b/apps/console/e2e/workspace-goal-stack.spec.ts @@ -0,0 +1,409 @@ +// SOURCING: @playwright/test. These oracles cover the canonical V2 Console +// routes for the workspace substrate and Goal Stack; upstream responses are +// intercepted at the same-origin boundaries so the real views, graph layout, +// drag-and-drop contract, and revision diff render in the browser. + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const SURFACE_KEY = 'commonplace.console.surface.v1'; + +async function freshLoad(page: Page) { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + await page.evaluate((key) => { + localStorage.removeItem(key); + localStorage.removeItem('commonplace.console.workspace.project.v1'); + }, SURFACE_KEY); + await page.reload(); + await page.waitForSelector('[data-shell]'); + await page.waitForTimeout(600); +} + +async function openSurface(page: Page, id: string) { + await page.locator(`[data-surface-nav="${id}"]`).click(); + await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', id); +} + +test.describe('V2 workspace substrate and Goal Stack', () => { + test('imports a typed project, reports readiness, and renders exact local history bytes', async ({ page }) => { + let created = false; + let findCalls = 0; + let addedRoot = ''; + let restoreGeneration: number | null = null; + await page.route('**/api/workspace', async (route) => { + const body = route.request().postDataJSON() as { query?: string; variables?: Record }; + const query = body.query ?? ''; + const variables = body.variables ?? {}; + if (query.includes('CreateWorkspaceProject')) { + created = true; + return json(route, { data: { createProject: projectReceipt } }); + } + if (query.includes('AddWorkspaceContentRoot')) { + addedRoot = String(variables.rootPath); + return json(route, { data: { addContentRoot: { ...projectReceipt, rootPath: addedRoot } } }); + } + if (query.includes('WorkspaceSurface')) { + return json(route, { data: { projectTree, readiness: findCalls === 0 ? buildingReadiness : readyReadiness } }); + } + if (query.includes('WorkspaceProjectFind')) { + const degraded = findCalls === 0; + findCalls += 1; + return json(route, { data: { search: [projectSearchHit(degraded)] } }); + } + if (query.includes('WorkspaceFileHistory')) { + return json(route, { data: { fileHistory } }); + } + if (query.includes('RestoreWorkspaceRevision')) { + restoreGeneration = Number(variables.generation); + return json(route, { data: { restoreRevision: restoredFileHistory } }); + } + return json(route, { data: { readiness: created ? readyReadiness : buildingReadiness } }); + }); + + await freshLoad(page); + await openSurface(page, 'console-workspace'); + await expect(page.locator('[data-workspace-substrate]')).toBeVisible(); + await expect(page.locator('[data-readiness="building"]')).toHaveText('Building'); + + await page.getByLabel('Project name').fill('CommonPlace V2'); + await page.getByLabel('Directory path').fill('/workspace/commonplace'); + await page.getByRole('button', { name: 'Create project' }).click(); + + await expect(page.locator('[data-readiness="building"]')).toHaveText('Building'); + await expect(page.getByRole('treeitem', { name: /src/ })).toBeVisible(); + const excluded = page.getByRole('treeitem', { name: /node_modules/ }); + await expect(excluded).toHaveCSS('opacity', '0.48'); + + await page.locator('input[aria-label="Find in project"]').fill('main'); + await page.getByRole('button', { name: 'Find' }).click(); + await expect(page.locator('[data-find-degraded]')).toContainText('trigram'); + await expect(page.getByText('inside project')).toBeVisible(); + + await expect(page.locator('[data-readiness="ready"]')).toHaveText('Ready', { timeout: 4_000 }); + await expect(page.locator('[data-find-degraded]')).toHaveCount(0); + + await page.getByLabel('Directory path').fill('/workspace/commonplace/packages'); + await page.getByRole('button', { name: 'Add content root' }).click(); + await expect.poll(() => addedRoot).toBe('/workspace/commonplace/packages'); + + await page.getByLabel('File history path').fill('/workspace/commonplace/src/main.ts'); + await page.getByRole('button', { name: 'History' }).click(); + await expect(page.getByText('content-root-imported')).toBeVisible(); + await expect(page.getByText('editor-save')).toBeVisible(); + await expect(page.locator('[data-workspace-history-diff]')).toBeVisible(); + await expect(page.locator('.cm-mergeView')).toBeVisible(); + await expect(page).toHaveScreenshot('workspace-substrate-v2-1440-dark.png', { fullPage: true }); + + await page.getByRole('button', { name: 'Restore' }).last().click(); + await expect.poll(() => restoreGeneration).toBe(1); + await expect(page.getByText('restored-generation-1')).toBeVisible(); + }); + + test('renders the canonical plan DAG and queues a destructive affordance through DnD', async ({ page }) => { + const actions: Record[] = []; + let approvalRecorded = false; + await page.route('**/api/harness/plan**', async (route) => { + if (route.request().method() === 'POST') { + const action = route.request().postDataJSON() as Record; + actions.push(action); + if (action.action === 'approval_decision') approvalRecorded = true; + return json(route, { ok: true }); + } + return json(route, { + snapshot: planSnapshot(approvalRecorded), + events: { rows: [] }, + capabilities: capabilityManifest, + cursor: 7, + }); + }); + + await freshLoad(page); + await openSurface(page, 'console-goals'); + await page.locator('input[aria-label="Plan id"]').fill('plan-v2'); + await page.getByRole('button', { name: 'Open' }).click(); + + await expect(page.locator('[data-plan-task="task-index"]')).toBeVisible(); + await expect(page.locator('[data-plan-task="task-release"]')).toBeVisible(); + const runningEdges = page.locator('[data-goal-edge="running"]'); + await expect.poll(async () => { + const offset = await runningEdges.first().locator('.goal-edge-progress').getAttribute('stroke-dashoffset').catch(() => null); + return offset !== null && Math.abs(Number(offset) - 0.42) < 0.001; + }).toBe(true); + await expect(page.getByText('Delete generated cache')).toBeVisible(); + await expect(page.getByText('destructive').first()).toBeVisible(); + await expect(page).toHaveScreenshot('goal-stack-v2-1440-dark.png', { fullPage: true }); + + const source = page.getByText('Delete generated cache').locator('..').locator('..'); + const target = page.locator('[data-plan-task="task-release"]'); + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + expect(sourceBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + await page.mouse.move(sourceBox!.x + sourceBox!.width / 2, sourceBox!.y + sourceBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + targetBox!.height / 2, { steps: 12 }); + await page.mouse.up(); + await expect.poll(() => actions.at(-1)).toMatchObject({ + planId: 'plan-v2', + action: 'queue_affordance', + taskId: 'task-release', + affordanceRef: 'filesystem:delete-cache', + }); + + await expect(page.locator('[data-plan-permission]')).toContainText('Approval required'); + await page.getByText('Allow once', { exact: true }).click(); + await expect.poll(() => actions.at(-1)).toMatchObject({ + planId: 'plan-v2', + action: 'approval_decision', + taskId: 'task-release', + decision: 'allow', + }); + await expect(page.locator('[data-plan-permission]')).toHaveCount(0, { timeout: 4_000 }); + await expect(page.getByText('/workspace/commonplace/src/main.ts')).toBeVisible(); + + const superseded = page.locator('[data-plan-task="task-old-release"]'); + await expect(superseded).toBeVisible(); + await expect(superseded).toHaveCSS('opacity', '0.46'); + await page.getByRole('button', { name: 'Hide prior generations' }).click(); + await expect(superseded).toHaveCount(0); + await page.getByRole('button', { name: 'Show prior generations' }).click(); + await expect(superseded).toBeVisible(); + await superseded.click(); + await page.getByRole('button', { name: 'Replan subtree' }).click(); + await expect.poll(() => actions.at(-1)).toMatchObject({ + planId: 'plan-v2', + action: 'replan_subtree', + taskId: 'task-old-release', + }); + }); +}); + +test('live V2 routes reach GraphQL workspace and Harness Plan together', async ({ page }) => { + const projectId = process.env.CONSOLE_LIVE_WORKSPACE_PROJECT_ID; + const planId = process.env.CONSOLE_LIVE_PLAN_ID; + test.skip(!projectId || !planId, 'Set live workspace and Plan ids to run the deployed substrate oracle.'); + await freshLoad(page); + await page.evaluate((value) => localStorage.setItem('commonplace.console.workspace.project.v1', value), projectId!); + await page.reload(); + await openSurface(page, 'console-workspace'); + await expect(page.locator('[data-workspace-substrate]')).toBeVisible(); + await expect(page.locator('[data-readiness]')).toBeVisible(); + await openSurface(page, 'console-goals'); + await page.locator('input[aria-label="Plan id"]').fill(planId!); + await page.getByRole('button', { name: 'Open' }).click(); + await expect(page.locator('[data-plan-task]').first()).toBeVisible(); + await expect(page.locator('[data-plan-stream="live"]')).toBeVisible(); +}); + +async function json(route: Route, body: unknown) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); +} + +const projectReceipt = { + projectId: 'project-v2', + rootId: 'root-v2', + rootPath: '/workspace/commonplace', + generation: 2, +}; + +const buildingReadiness = { + generation: 0, + capabilities: [ + { capability: 'find', state: 'Building', missing: ['trigram'] }, + { capability: 'ask', state: 'Building', missing: ['vector'] }, + { capability: 'project_tree', state: 'Ready', missing: [] }, + ], +}; + +const readyReadiness = { + generation: 2, + capabilities: [ + { capability: 'find', state: 'Ready', missing: [] }, + { capability: 'ask', state: 'Ready', missing: [] }, + { capability: 'project_tree', state: 'Ready', missing: [] }, + ], +}; + +const projectTree = { + projectId: 'project-v2', + generation: 2, + roots: [{ + id: 'root-v2', + kind: 'ContentRoot', + name: 'commonplace', + path: '/workspace/commonplace', + excluded: false, + children: [ + { + id: 'folder-src', + kind: 'Folder', + name: 'src', + path: '/workspace/commonplace/src', + excluded: false, + children: [{ + id: 'file-main', + kind: 'SourceRoot', + name: 'application sources', + path: '/workspace/commonplace/src', + excluded: false, + children: [], + }], + }, + { + id: 'folder-node-modules', + kind: 'ExcludedFolder', + name: 'node_modules', + path: '/workspace/commonplace/node_modules', + excluded: true, + children: [], + }, + ], + }], +}; + +const fileHistory = { + path: '/workspace/commonplace/src/main.ts', + revisions: [ + { + generation: 1, + hash: 'hash-one', + label: 'content-root-imported', + timestampMs: 1_721_300_000_000, + content: 'export const version = 1;\n', + }, + { + generation: 2, + hash: 'hash-two', + label: 'editor-save', + timestampMs: 1_721_300_001_000, + content: 'export const version = 2;\n', + }, + ], +}; + +const restoredFileHistory = { + ...fileHistory, + revisions: [ + ...fileHistory.revisions, + { + generation: 3, + hash: 'hash-three', + label: 'restored-generation-1', + timestampMs: 1_721_300_002_000, + content: 'export const version = 1;\n', + }, + ], +}; + +function projectSearchHit(degraded: boolean) { + return { + item: { + id: 'item-main', + kind: 'file', + title: 'main.ts', + path: '/workspace/commonplace/src/main.ts', + }, + score: 0.92, + originalScore: 0.61, + insideProject: true, + degraded, + missingIndexes: degraded ? ['trigram'] : [], + }; +} + +function planSnapshot(approved: boolean) { + return { + plan_id: 'plan-v2', + graph_version: 7, + plan: { + id: 'plan-v2', + title: 'Ship the V2 workspace substrate', + objective: 'Connect CommonPlace V2 to the canonical workspace and Plan substrate.', + status: 'active', + project_id: 'project-v2', + acceptance_criteria: [{ id: 'F4', text: 'Affordances can be queued from the manifest.' }], + }, + progress: { done: 1, total: 3 }, + tasks: [ + { + id: 'task-index', + alias: 'index', + title: 'Build the workspace indexes', + description: 'Materialize search and symbol readiness.', + kind: 'regular', + lifecycle_status: 'verified', + dependencies: [], + serves: ['F4'], + acceptance_criteria: ['The trigram index is ready.'], + queued_affordances: [], + admission_requirement: 'admitted', + claim_holder: 'codex-runtime', + generation_at_start: 1, + changed_events: [], + }, + { + id: 'task-wire', + alias: 'wire', + title: 'Wire the V2 substrate', + description: 'Connect the canonical graph to the V2 product surface.', + kind: 'regular', + lifecycle_status: 'running', + dependencies: ['task-index'], + serves: ['F4'], + acceptance_criteria: ['The running edge updates within one poll.'], + queued_affordances: [], + admission_requirement: 'admitted', + claim_holder: 'codex-runtime', + generation_at_start: 2, + changed_events: [], + }, + { + id: 'task-release', + alias: 'release', + title: 'Release the V2 route', + description: 'Validate and publish the primary product surface.', + kind: 'verify', + lifecycle_status: 'pending', + dependencies: ['task-wire'], + serves: ['F4'], + acceptance_criteria: ['The V2 route renders the canonical graph.'], + queued_affordances: [{ + ref: 'filesystem:delete-cache', + config: {}, + annotations: ['destructive'], + }], + admission_requirement: approved ? 'admitted' : 'require_approval', + approval_receipt: approved ? 'approval:plan-v2:task-release' : null, + generation_at_start: 2, + changed_events: [{ path: '/workspace/commonplace/src/main.ts', generation: 2 }], + }, + { + id: 'task-old-release', + alias: 'old-release', + title: 'Retired release attempt', + description: 'A prior generation retained for replay and bounded replan.', + kind: 'regular', + lifecycle_status: 'superseded', + dependencies: ['task-wire'], + serves: ['F4'], + acceptance_criteria: ['Superseded generations remain inspectable.'], + queued_affordances: [], + admission_requirement: 'admitted', + superseded_by: ['task-release'], + changed_events: [], + }, + ], + }; +} + +const capabilityManifest = { + capabilities: [{ + id: 'filesystem:delete-cache', + title: 'Delete generated cache', + description: 'Remove a generated cache directory before rebuilding.', + server_origin: 'theorem-workspace', + tool_name: 'delete_cache', + annotations: ['destructive'], + }], +}; diff --git a/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-darwin.png b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-darwin.png new file mode 100644 index 00000000..bdec58f4 Binary files /dev/null and b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-darwin.png differ diff --git a/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-linux.png b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-linux.png new file mode 100644 index 00000000..ce62e0da Binary files /dev/null and b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/goal-stack-v2-1440-dark-linux.png differ diff --git a/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-darwin.png b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-darwin.png new file mode 100644 index 00000000..585d2986 Binary files /dev/null and b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-darwin.png differ diff --git a/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-linux.png b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-linux.png new file mode 100644 index 00000000..5f0cc908 Binary files /dev/null and b/apps/console/e2e/workspace-goal-stack.spec.ts-snapshots/workspace-substrate-v2-1440-dark-linux.png differ diff --git a/apps/console/package.json b/apps/console/package.json index e11dec6c..03368d38 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev --port 3010", - "build": "next build", - "build:railway": "RAILWAY_STANDALONE=1 next build && node scripts/prepare-railway-standalone.mjs", + "dev": "next dev --webpack --port 3010", + "build": "next build --webpack", + "build:railway": "RAILWAY_STANDALONE=1 next build --webpack && node scripts/prepare-railway-standalone.mjs", "start:railway": "node scripts/start-railway.mjs", "start": "next start", "lint": "eslint src scripts next.config.ts", @@ -37,7 +37,9 @@ "@commonplace/theorem-acp": "file:../../packages/theorem-acp", "@dagrejs/dagre": "^3.0.0", "@dagrejs/graphlib": "^4.0.1", + "@dnd-kit/core": "^6.3.1", "@lezer/highlight": "^1.2.1", + "@radix-ui/react-popover": "^1.1.19", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.12", "@travis-gilbert/markdown-theory": "^0.1.2", diff --git a/apps/console/src/app/api/harness/plan/route.ts b/apps/console/src/app/api/harness/plan/route.ts new file mode 100644 index 00000000..c874226f --- /dev/null +++ b/apps/console/src/app/api/harness/plan/route.ts @@ -0,0 +1,168 @@ +import { planToProgrammableGraph } from '@commonplace/theorem-acp/plan-program'; +import { + normalizePlanSnapshot, + planIsComplete, +} from '@commonplace/theorem-acp/plan-state'; +import { callHarnessMcp } from '@/lib/server/harness-mcp'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const planId = validText(url.searchParams.get('id')); + if (!planId) return Response.json({ error: 'plan_id_required' }, { status: 400 }); + const cursor = validCursor(url.searchParams.get('cursor')); + const includeManifest = url.searchParams.get('manifest') !== '0'; + + const snapshot = await callHarnessMcp('plan', { action: 'inspect', plan_id: planId }); + if (!snapshot.ok) return snapshot.response; + const events = await callHarnessMcp('plan', { + action: 'what_changed', + plan_id: planId, + anchor: `graph:${cursor}`, + }); + const manifest = includeManifest + ? await callHarnessMcp('plan', { action: 'capability_manifest', plan_id: planId }) + : null; + + return Response.json({ + schema: 'commonplace.plan-canvas-poll/1', + snapshot: snapshot.data, + events: events.ok ? events.data : { rows: [] }, + capabilities: manifest?.ok ? manifest.data : { capabilities: [] }, + cursor: Math.max(cursor, graphVersion(events.ok ? events.data : null)), + degraded: { events: !events.ok, capabilities: includeManifest && !manifest?.ok }, + }); +} + +export async function POST(request: Request): Promise { + const body = record(await request.json().catch(() => null)); + if (!body) return Response.json({ error: 'json_object_required' }, { status: 400 }); + const planId = validText(body.planId ?? body.plan_id); + const action = validText(body.action); + if (!planId || !action) { + return Response.json({ error: 'plan_id_and_action_required' }, { status: 400 }); + } + if (action === 'save_as_program') return saveAsProgram(planId); + + const taskId = validText(body.taskId ?? body.task_id); + let input: Record; + switch (action) { + case 'queue_affordance': { + const ref = validText(body.affordanceRef ?? body.affordance_ref); + if (!taskId || !ref) return Response.json({ error: 'task_and_affordance_required' }, { status: 400 }); + input = { + action: 'update', + plan_id: planId, + changes: [{ task_id: taskId, queue_affordance: { ref, config: body.config ?? {} } }], + }; + break; + } + case 'remove_affordance': { + const ref = validText(body.affordanceRef ?? body.affordance_ref); + if (!taskId || !ref) return Response.json({ error: 'task_and_affordance_required' }, { status: 400 }); + input = { + action: 'update', + plan_id: planId, + changes: [{ task_id: taskId, remove_affordance: ref }], + }; + break; + } + case 'approval_decision': { + const decision = validText(body.decision); + if (!taskId || (decision !== 'allow' && decision !== 'reject')) { + return Response.json({ error: 'task_and_approval_decision_required' }, { status: 400 }); + } + input = { + action: 'update', + plan_id: planId, + changes: [{ task_id: taskId, approval_decision: decision }], + }; + break; + } + case 'revert_task': + if (!taskId) return Response.json({ error: 'task_required' }, { status: 400 }); + input = { + action: 'update', + plan_id: planId, + changes: [{ task_id: taskId, revert_task_file_changes: true }], + }; + break; + case 'replan_subtree': + if (!taskId) return Response.json({ error: 'task_required' }, { status: 400 }); + input = { + action: 'replan_subtree', + plan_id: planId, + task_id: taskId, + reason: validText(body.reason) ?? 'Requested from the CommonPlace Goal Stack', + }; + break; + default: + return Response.json({ error: 'unsupported_plan_action' }, { status: 400 }); + } + + const result = await callHarnessMcp('plan', input); + if (!result.ok) return result.response; + return refusalResponse(result.data) ?? Response.json({ ok: true, result: result.data }); +} + +async function saveAsProgram(planId: string): Promise { + const inspected = await callHarnessMcp('plan', { action: 'inspect', plan_id: planId }); + if (!inspected.ok) return inspected.response; + const snapshot = normalizePlanSnapshot(inspected.data); + if (!snapshot) return Response.json({ error: 'plan_projection_invalid' }, { status: 502 }); + if (!planIsComplete(snapshot)) { + return Response.json({ error: 'plan_not_complete' }, { status: 409 }); + } + const program = { + ...planToProgrammableGraph(snapshot), + tenant_id: inspected.principal.tenant, + }; + const result = await callHarnessMcp('programmable_graph_apply', { + action: 'materialize', + program, + }); + if (!result.ok) return result.response; + return refusalResponse(result.data) ?? Response.json({ ok: true, result: result.data }); +} + +function refusalResponse(data: Record): Response | null { + const refused = data.refused === true || data.status === 'refused'; + if (!refused) return null; + return Response.json({ + error: 'plan_action_refused', + rule: validText(data.rule) ?? validText(data.code) ?? 'unspecified', + detail: validText(data.detail) ?? validText(data.reason) ?? 'The Plan substrate refused this transition.', + receiptId: validText(data.receipt_id ?? data.receiptId), + }, { status: 409 }); +} + +function graphVersion(value: unknown): number { + const root = record(value); + const rows = Array.isArray(root?.rows) ? root.rows : []; + return rows.reduce((maximum, row) => { + const item = record(row); + const candidate = item?.graph_version ?? item?.graphVersion; + return typeof candidate === 'number' && Number.isFinite(candidate) + ? Math.max(maximum, candidate) + : maximum; + }, 0); +} + +function validCursor(value: unknown): number { + if (typeof value !== 'string' || !/^\d+$/.test(value)) return 0; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : 0; +} + +function validText(value: unknown): string | null { + return typeof value === 'string' && value.trim() && value.length <= 512 + ? value.trim() + : null; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/apps/console/src/app/api/workspace/route.ts b/apps/console/src/app/api/workspace/route.ts new file mode 100644 index 00000000..c609c68b --- /dev/null +++ b/apps/console/src/app/api/workspace/route.ts @@ -0,0 +1,101 @@ +import path from 'node:path'; +import { forward } from '@/app/api/objects/_upstream'; + +export const dynamic = 'force-dynamic'; + +const NODE_FIELDS = ` + id kind name path excluded + children { + id kind name path excluded + children { + id kind name path excluded + children { + id kind name path excluded + children { id kind name path excluded } + } + } + } +`; + +const OPERATIONS: Readonly> = { + WorkspaceSurface: `query WorkspaceSurface($projectId: String!) { + projectTree(projectId: $projectId) { projectId generation roots { ${NODE_FIELDS} } } + readiness { generation capabilities { capability state missing } } + }`, + WorkspaceReadiness: 'query WorkspaceReadiness { readiness { generation capabilities { capability state missing } } }', + WorkspaceProjectFind: `query WorkspaceProjectFind($query: String!, $projectId: String!) { + search(query: $query, k: 12, projectId: $projectId) { + item { id kind title path } + score originalScore insideProject degraded missingIndexes + } + }`, + WorkspaceFileHistory: `query WorkspaceFileHistory($path: String!) { + fileHistory(path: $path) { path revisions { generation hash label timestampMs content } } + }`, + CreateWorkspaceProject: `mutation CreateWorkspaceProject($name: String!, $rootPath: String!) { + createProject(name: $name, rootPath: $rootPath) { projectId rootId rootPath generation } + }`, + AddWorkspaceContentRoot: `mutation AddWorkspaceContentRoot($projectId: String!, $rootPath: String!) { + addContentRoot(projectId: $projectId, rootPath: $rootPath) { projectId rootId rootPath generation } + }`, + RestoreWorkspaceRevision: `mutation RestoreWorkspaceRevision($path: String!, $generation: Int!) { + restoreRevision(path: $path, generation: $generation) { + path revisions { generation hash label timestampMs content } + } + }`, +}; + +export async function POST(request: Request): Promise { + const body = record(await request.json().catch(() => null)); + const query = typeof body?.query === 'string' ? body.query : ''; + const operation = /\b(?:query|mutation)\s+([A-Za-z][A-Za-z0-9_]*)\b/.exec(query)?.[1]; + const canonical = operation ? OPERATIONS[operation] : undefined; + if (!operation || !canonical) return Response.json({ error: 'workspace_operation_not_allowed' }, { status: 400 }); + const variables = record(body?.variables) ?? {}; + const variableError = validateVariables(operation, variables); + if (variableError) return variableError; + return forward('/graphql', { + method: 'POST', + body: JSON.stringify({ operationName: operation, query: canonical, variables }), + }); +} + +function validateVariables(operation: string, variables: Record): Response | null { + const bounded = (name: string, maximum = 512) => { + const value = variables[name]; + return typeof value === 'string' && value.trim() && value.length <= maximum; + }; + if (operation === 'WorkspaceReadiness') return null; + if (operation === 'WorkspaceSurface' && bounded('projectId')) return null; + if (operation === 'WorkspaceProjectFind' && bounded('projectId') && bounded('query', 4_096)) return null; + if (operation === 'WorkspaceFileHistory' && allowedWorkspacePath(variables.path)) return null; + if (operation === 'RestoreWorkspaceRevision' + && allowedWorkspacePath(variables.path) + && Number.isSafeInteger(variables.generation) + && Number(variables.generation) >= 0) return null; + if (operation === 'CreateWorkspaceProject' + && bounded('name') + && allowedWorkspacePath(variables.rootPath)) return null; + if (operation === 'AddWorkspaceContentRoot' + && bounded('projectId') + && allowedWorkspacePath(variables.rootPath)) return null; + return Response.json({ error: 'workspace_variables_invalid' }, { status: 400 }); +} + +function allowedWorkspacePath(value: unknown): boolean { + if (typeof value !== 'string' || !path.isAbsolute(value) || value.length > 4_096 || value.includes('\0')) return false; + const allowed = (process.env.CONSOLE_WORKSPACE_ALLOWED_ROOTS ?? '') + .split(path.delimiter) + .map((root) => root.trim()) + .filter(Boolean) + .map((root) => path.resolve(root)); + if (allowed.length === 0) return process.env.NODE_ENV !== 'production'; + const candidate = path.resolve(value); + return allowed.some((root) => candidate === root || candidate.startsWith(`${root}${path.sep}`)); +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/apps/console/src/components/ConsoleApp.tsx b/apps/console/src/components/ConsoleApp.tsx index 0ef3008b..89c298d1 100644 --- a/apps/console/src/components/ConsoleApp.tsx +++ b/apps/console/src/components/ConsoleApp.tsx @@ -38,6 +38,7 @@ function convertMessage(message: ThreadMessage): ThreadMessageLike { id: message.id, role: message.role, content: message.parts.map((part) => ({ type: 'text' as const, text: part.text })), + metadata: { custom: message.degradation ? { degradation: message.degradation } : {} }, }; } diff --git a/apps/console/src/components/shell/IntuiShell.tsx b/apps/console/src/components/shell/IntuiShell.tsx index 10fb44db..effd1652 100644 --- a/apps/console/src/components/shell/IntuiShell.tsx +++ b/apps/console/src/components/shell/IntuiShell.tsx @@ -51,6 +51,7 @@ const SURFACE_ICONS: Record = { cards: IconCards, model: IconModel, review: IconInspector, + goals: IconModel, }; /** Region icon slugs carried on the surface object; the glyphs stay in the @@ -319,7 +320,7 @@ export function IntuiShell({ host }: { host: ConsoleBlockHost }) { [compact, host, regions.left, regions.right], ); - // Alt+1..5 switches the primary surface radio group. Alt+Shift+1..3 + // Alt+1..6 switches the primary surface radio group. Alt+Shift+1..3 // toggles the companion group for the active surface. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { diff --git a/apps/console/src/components/shell/SearchField.tsx b/apps/console/src/components/shell/SearchField.tsx index 02c52a49..96ac1925 100644 --- a/apps/console/src/components/shell/SearchField.tsx +++ b/apps/console/src/components/shell/SearchField.tsx @@ -214,7 +214,7 @@ export function SearchPanel({ host }: { host: ConsoleBlockHost }) { return ( { + onOpenChange={(nextOpen: boolean) => { if (!nextOpen) close(); }} label="Search" diff --git a/apps/console/src/lib/console-host.test.ts b/apps/console/src/lib/console-host.test.ts index 070fc179..5cf857fc 100644 --- a/apps/console/src/lib/console-host.test.ts +++ b/apps/console/src/lib/console-host.test.ts @@ -48,6 +48,7 @@ describe('ConsoleBlockHost', () => { 'console-cards', 'console-chat', 'console-docs', + 'console-goals', 'console-index', 'console-proactivity', 'console-review', @@ -58,7 +59,7 @@ describe('ConsoleBlockHost', () => { .filter((surface) => typeof surface.properties.stripe_order === 'number') .sort((a, b) => Number(a.properties.stripe_order) - Number(b.properties.stripe_order)) .map((surface) => surface.properties.name)).toEqual([ - 'Chat', 'Workspace', 'Index', 'Documents', 'Cards', + 'Chat', 'Workspace', 'Goal Stack', 'Index', 'Documents', 'Cards', ]); const workspace = buildSurfaceTree('console-workspace', set.objects); expect(workspace!.children.map((child) => child.object.id)).toEqual([ @@ -87,7 +88,7 @@ describe('ConsoleBlockHost', () => { const left = set.objects.find((object) => object.id === 'workspace.region-files')!; const editor = set.objects.find((object) => object.id === 'region-editor')!; expect(left.relations?.[CONTAINS_EDGE]).toEqual(['vi-code', 'workspace.region-files.view']); - expect(editor.relations?.[CONTAINS_EDGE]).toEqual(['vi-brief']); + expect(editor.relations?.[CONTAINS_EDGE]).toEqual(['workspace.vi-substrate', 'vi-brief']); }); it('notifies layout subscribers on update', async () => { diff --git a/apps/console/src/lib/console-host.ts b/apps/console/src/lib/console-host.ts index 7494cb83..b665a643 100644 --- a/apps/console/src/lib/console-host.ts +++ b/apps/console/src/lib/console-host.ts @@ -200,6 +200,24 @@ export class ConsoleBlockHost implements BlockHost { added = true; } } + // IA revision 3 moves Workspace onto the durable substrate renderer and + // inserts Goal Stack into the primary stripe. Preserve person-sized + // panels while migrating only the seeded navigation and tab membership. + const workspaceEditor = this.layout.get('region-editor'); + if (workspaceEditor && !workspaceEditor.children.includes('workspace.vi-substrate')) { + workspaceEditor.children.unshift('workspace.vi-substrate'); + workspaceEditor.properties.active_tab = 'workspace.vi-substrate'; + workspaceEditor.properties.seed_revision = 3; + added = true; + } + for (const seeded of seed) { + if (seeded.type !== 'surface' || typeof seeded.properties.stripe_order !== 'number') continue; + const current = this.layout.get(seeded.id); + if (current && current.properties.stripe_order !== seeded.properties.stripe_order) { + current.properties.stripe_order = seeded.properties.stripe_order; + added = true; + } + } if (added) this.persistLayout(); } } diff --git a/apps/console/src/lib/goal-stack-contract.test.ts b/apps/console/src/lib/goal-stack-contract.test.ts new file mode 100644 index 00000000..8ccbe926 --- /dev/null +++ b/apps/console/src/lib/goal-stack-contract.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + applyPlanEvent, + normalizeCapabilities, + normalizePlanSnapshot, + planIsComplete, +} from '@commonplace/theorem-acp/plan-state'; +import { edgeProgress } from '@/views/goal-stack/plan-layout'; + +describe('Goal Stack shared projection', () => { + it('normalizes the canonical plan and folds lifecycle events', () => { + const snapshot = normalizePlanSnapshot({ + plan_id: 'plan:fixture', + plan: { + id: 'plan:fixture', + title: 'Fixture goal', + objective: 'Prove the substrate', + acceptance_criteria: [{ criterion_id: 'c1', statement: 'The task verifies' }], + }, + tasks: [{ + id: 'task:verify', + title: 'Verify it', + kind: 'verify', + status: 'blocked', + serves: ['c1'], + claim_holder: 'github:owner', + changed_paths: ['/repo/README.md'], + changed_events: [{ + generation: 4, + event: { kind: 'moved', from: '/repo/old.ts', to: '/repo/new.ts' }, + }], + }], + }); + expect(snapshot?.tasks[0]).toMatchObject({ + kind: 'verify', + status: 'blocked', + serves: ['c1'], + claimHolder: 'github:owner', + changedEvents: [ + { path: '/repo/old.ts', generation: 4 }, + { path: '/repo/new.ts', generation: 4 }, + ], + }); + const next = applyPlanEvent(snapshot!, { + id: 'event:1', + transition: 'task_completed', + nodeIds: ['task:verify'], + actor: 'codex', + graphVersion: 4, + at: '2026-07-18T00:00:00Z', + detail: { status: 'verified' }, + }); + expect(next.tasks[0].status).toBe('verified'); + expect(planIsComplete(next)).toBe(true); + expect(edgeProgress('verified')).toBe(1); + }); + + it('preserves capability annotations for admission-aware drag and drop', () => { + const capabilities = normalizeCapabilities({ + capabilities: [{ + id: 'github:delete_file', + title: 'Delete file', + description: 'Delete one file', + server_origin: 'github', + annotations: ['destructive'], + }], + }); + expect(capabilities).toEqual([ + expect.objectContaining({ + id: 'github:delete_file', + annotations: { readOnly: false, destructive: true }, + }), + ]); + }); +}); diff --git a/apps/console/src/lib/harness-mcp-core.ts b/apps/console/src/lib/harness-mcp-core.ts new file mode 100644 index 00000000..470998ff --- /dev/null +++ b/apps/console/src/lib/harness-mcp-core.ts @@ -0,0 +1,13 @@ +import type { HarnessPrincipal } from '@/lib/harness-principal-core'; + +export function identityBoundArguments( + argumentsValue: Record, + principal: HarnessPrincipal, +): Record { + return { + ...argumentsValue, + tenant: principal.tenant, + tenant_slug: principal.tenant, + actor: principal.harnessIdentity, + }; +} diff --git a/apps/console/src/lib/harness-mcp.test.ts b/apps/console/src/lib/harness-mcp.test.ts new file mode 100644 index 00000000..5dee7450 --- /dev/null +++ b/apps/console/src/lib/harness-mcp.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { identityBoundArguments } from '@/lib/harness-mcp-core'; + +describe('identity-bound Harness MCP calls', () => { + it('overwrites browser-supplied tenant and actor with the admitted principal', () => { + expect(identityBoundArguments({ + action: 'inspect', + actor: 'attacker', + tenant: 'Other', + tenant_slug: 'Other', + }, { + tenant: 'Travis-Gilbert', + githubLogin: 'Travis-Gilbert', + harnessIdentity: 'github:owner', + })).toMatchObject({ + tenant: 'Travis-Gilbert', + tenant_slug: 'Travis-Gilbert', + actor: 'github:owner', + }); + }); +}); diff --git a/apps/console/src/lib/plan-route.test.ts b/apps/console/src/lib/plan-route.test.ts new file mode 100644 index 00000000..76615825 --- /dev/null +++ b/apps/console/src/lib/plan-route.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { callHarnessMcpMock } = vi.hoisted(() => ({ callHarnessMcpMock: vi.fn() })); +vi.mock('@/lib/server/harness-mcp', () => ({ callHarnessMcp: callHarnessMcpMock })); + +import { POST } from '@/app/api/harness/plan/route'; + +const principal = { + tenant: 'Travis-Gilbert', + githubLogin: 'Travis-Gilbert', + harnessIdentity: 'github:owner', +}; + +describe('Plan mutation route', () => { + afterEach(() => callHarnessMcpMock.mockReset()); + + it('maps a typed Plan refusal to actionable HTTP 409 detail', async () => { + callHarnessMcpMock.mockResolvedValue({ + ok: true, + principal, + data: { + refused: true, + rule: 'approval_required', + detail: 'A destructive affordance requires a receipt.', + receipt_id: 'refusal:1', + }, + }); + const response = await POST(new Request('https://console.test/api/harness/plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planId: 'plan:1', action: 'approval_decision', taskId: 'task:1', decision: 'allow' }), + })); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: 'plan_action_refused', + rule: 'approval_required', + detail: 'A destructive affordance requires a receipt.', + receiptId: 'refusal:1', + }); + }); +}); diff --git a/apps/console/src/lib/server/harness-mcp.ts b/apps/console/src/lib/server/harness-mcp.ts new file mode 100644 index 00000000..07d0d30d --- /dev/null +++ b/apps/console/src/lib/server/harness-mcp.ts @@ -0,0 +1,114 @@ +import { + principalTenantHeaders, + resolveHarnessPrincipal, + type HarnessPrincipal, +} from '@/lib/server/harness-principal'; +import { identityBoundArguments } from '@/lib/harness-mcp-core'; +import { startHarnessRequestTimeout } from '@/lib/server/harness-timeout'; + +export type HarnessMcpResult = + | { ok: true; data: Record; principal: HarnessPrincipal } + | { ok: false; response: Response }; + +export async function callHarnessMcp( + name: string, + argumentsValue: Record, +): Promise { + const resolution = await resolveHarnessPrincipal(); + if (!resolution.ok) return { ok: false, response: resolution.response }; + const base = process.env.CONSOLE_HARNESS_URL; + if (!base) { + return { + ok: false, + response: Response.json({ error: 'console_harness_unconfigured' }, { status: 404 }), + }; + } + const endpoint = `${base.replace(/\/(?:mcp)?\/?$/, '')}/mcp`; + const timeout = startHarnessRequestTimeout(); + try { + const upstream = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...principalTenantHeaders(resolution.principal), + ...(process.env.CONSOLE_HARNESS_TOKEN + ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } + : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `${name}-${Date.now()}`, + method: 'tools/call', + params: { + name, + arguments: identityBoundArguments(argumentsValue, resolution.principal), + }, + }), + cache: 'no-store', + signal: timeout.signal, + }); + const payload = (await upstream.json().catch(() => null)) as Record | null; + if (!upstream.ok) { + return { + ok: false, + response: Response.json( + { error: 'harness_mcp_failed', status: upstream.status }, + { status: upstream.status }, + ), + }; + } + const rpcError = record(payload?.error); + if (rpcError) { + return { + ok: false, + response: Response.json( + { error: 'harness_mcp_refused', detail: rpcError.message }, + { status: 502 }, + ), + }; + } + const data = normalizeResult(payload?.result); + if (!data) { + return { + ok: false, + response: Response.json({ error: 'harness_mcp_invalid_result' }, { status: 502 }), + }; + } + return { ok: true, data, principal: resolution.principal }; + } catch { + return { + ok: false, + response: Response.json( + { error: timeout.didTimeout() ? 'harness_mcp_timeout' : 'harness_mcp_unreachable' }, + { status: timeout.didTimeout() ? 504 : 502 }, + ), + }; + } finally { + timeout.clear(); + } +} + +function normalizeResult(value: unknown): Record | null { + const result = record(value); + if (!result || result.isError === true) return null; + const structured = record(result.structuredContent); + if (structured && Object.keys(structured).length > 0) return structured; + const content = Array.isArray(result.content) ? result.content : []; + for (const entry of content) { + const text = record(entry)?.text; + if (typeof text !== 'string') continue; + try { + const parsed = record(JSON.parse(text)); + if (parsed) return parsed; + } catch { + return null; + } + } + return null; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/apps/console/src/lib/thread-readiness.test.ts b/apps/console/src/lib/thread-readiness.test.ts new file mode 100644 index 00000000..e68c562e --- /dev/null +++ b/apps/console/src/lib/thread-readiness.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { askDegradation } from '@/lib/thread-store'; + +describe('Composer ask readiness', () => { + it('labels only ask results whose required indexes are building', () => { + expect(askDegradation({ + generation: 4, + capabilities: [ + { capability: 'find', state: 'Building', missing: ['trigram'] }, + { capability: 'ask', state: 'Building', missing: ['trigram', 'vector'] }, + ], + })).toEqual({ degraded: true, missingIndexes: ['trigram', 'vector'] }); + expect(askDegradation({ + generation: 4, + capabilities: [{ capability: 'ask', state: 'Ready', missing: [] }], + })).toBeUndefined(); + }); +}); diff --git a/apps/console/src/lib/thread-store.test.ts b/apps/console/src/lib/thread-store.test.ts index e4c78bc2..a84266ef 100644 --- a/apps/console/src/lib/thread-store.test.ts +++ b/apps/console/src/lib/thread-store.test.ts @@ -77,7 +77,7 @@ describe('thread store', () => { vi.stubGlobal('fetch', fetchSpy); useThreadStore.getState().setMode('web'); await useThreadStore.getState().send('Research this change'); - const init = fetchSpy.mock.calls[0]?.[1]; - expect(JSON.parse(String(init?.body))).toMatchObject({ capability: { kind: 'web' } }); + const payloads = fetchSpy.mock.calls.map(([, init]) => JSON.parse(String(init?.body)) as Record); + expect(payloads).toContainEqual(expect.objectContaining({ capability: { kind: 'web' } })); }); }); diff --git a/apps/console/src/lib/thread-store.ts b/apps/console/src/lib/thread-store.ts index 4595bd3d..58c4eb1b 100644 --- a/apps/console/src/lib/thread-store.ts +++ b/apps/console/src/lib/thread-store.ts @@ -9,6 +9,10 @@ import { create } from 'zustand'; import { createParser, type EventSourceMessage } from 'eventsource-parser'; +import { + fetchWorkspaceReadiness, + type WorkspaceReadiness, +} from '@commonplace/theorem-acp/workspace-state'; export interface ThreadTextPart { readonly type: 'text'; @@ -19,6 +23,12 @@ export interface ThreadMessage { id: string; role: 'user' | 'assistant'; parts: ThreadTextPart[]; + degradation?: ThreadDegradation; +} + +export interface ThreadDegradation { + degraded: true; + missingIndexes: string[]; } export interface AgentPlanStep { @@ -121,6 +131,13 @@ function planOf(data: string): AgentPlanStep[] | null { } } +export function askDegradation(readiness: WorkspaceReadiness): ThreadDegradation | undefined { + const missing = readiness.capabilities + .filter((capability) => capability.capability === 'ask') + .flatMap((capability) => capability.missing); + return missing.length ? { degraded: true, missingIndexes: [...new Set(missing)] } : undefined; +} + export const useThreadStore = create((set, get) => ({ messages: [], isRunning: false, @@ -167,6 +184,18 @@ export const useThreadStore = create((set, get) => ({ abort, plan: [], })); + void fetchWorkspaceReadiness() + .then((readiness) => { + const degradation = askDegradation(readiness); + if (!degradation) return; + set((state) => ({ + messages: state.messages.map((message) => + message.id === assistantMessage.id ? { ...message, degradation } : message), + })); + }) + .catch(() => { + // The answer transport remains usable, but no readiness claim is made. + }); // Text deltas buffer in a local accumulator and flush once per animation // frame so re-render cost stays flat during fast streams (the streaming diff --git a/apps/console/src/lib/workspace-route.test.ts b/apps/console/src/lib/workspace-route.test.ts new file mode 100644 index 00000000..fd57aecf --- /dev/null +++ b/apps/console/src/lib/workspace-route.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { forwardMock } = vi.hoisted(() => ({ forwardMock: vi.fn() })); +vi.mock('@/app/api/objects/_upstream', () => ({ forward: forwardMock })); + +import { POST } from '@/app/api/workspace/route'; + +function request(query: string, variables: Record = {}) { + return new Request('https://console.test/api/workspace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); +} + +describe('workspace same-origin route', () => { + afterEach(() => { + forwardMock.mockReset(); + vi.unstubAllEnvs(); + }); + + it('rejects documents outside the workspace operation allowlist', async () => { + const response = await POST(request('mutation DeleteEverything { deleteAll }')); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: 'workspace_operation_not_allowed' }); + expect(forwardMock).not.toHaveBeenCalled(); + }); + + it('rebuilds an allowed operation server-side and drops injected fields', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('CONSOLE_WORKSPACE_ALLOWED_ROOTS', '/srv/workspaces'); + forwardMock.mockResolvedValue(new Response('{}')); + const response = await POST(request( + 'mutation CreateWorkspaceProject($name:String!,$rootPath:String!){ createProject(name:$name,rootPath:$rootPath){projectId} deleteAll }', + { name: 'Fixture', rootPath: '/srv/workspaces/fixture' }, + )); + expect(response.status).toBe(200); + const forwarded = forwardMock.mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(forwarded.body)) as { operationName: string; query: string }; + expect(body.operationName).toBe('CreateWorkspaceProject'); + expect(body.query).toContain('createProject'); + expect(body.query).not.toContain('deleteAll'); + }); + + it('refuses production paths outside the configured root', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('CONSOLE_WORKSPACE_ALLOWED_ROOTS', '/srv/workspaces'); + const response = await POST(request( + 'mutation AddWorkspaceContentRoot($projectId:String!,$rootPath:String!){ addContentRoot(projectId:$projectId,rootPath:$rootPath){projectId} }', + { projectId: 'project:fixture', rootPath: '/etc' }, + )); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: 'workspace_variables_invalid' }); + expect(forwardMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/console/src/lib/workspace-seed.ts b/apps/console/src/lib/workspace-seed.ts index 53efaec8..1381fa80 100644 --- a/apps/console/src/lib/workspace-seed.ts +++ b/apps/console/src/lib/workspace-seed.ts @@ -124,7 +124,7 @@ function companionIds(prefix: string): string[] { return [`${prefix}.region-files`, `${prefix}.region-context`, `${prefix}.region-thread`]; } -/** The durable IA seed: five primary surfaces in stripe order, plus secondary +/** The durable IA seed: six primary surfaces in stripe order, plus secondary * layouts available through the layout switcher and Command mode. */ export function seedLayout(): ObjectRef[] { return [ @@ -143,8 +143,12 @@ export function seedLayout(): ObjectRef[] { name: 'Workspace', kind: 'workspace', role: 'surface', stripe_order: 1, active: false, seed_revision: 2, }, ['region-editor', ...companionIds('workspace')]), layoutObject('region-editor', 'region', { - kind: 'editor', size: 72, active_tab: 'vi-brief', seed_revision: 2, - }, ['vi-brief', 'vi-code']), + kind: 'editor', size: 72, active_tab: 'workspace.vi-substrate', seed_revision: 3, + }, ['workspace.vi-substrate', 'vi-brief', 'vi-code']), + layoutObject('workspace.vi-substrate', 'view-instance', { + descriptor_id: 'workspace.substrate', title: 'Workspace substrate', + query: { types: ['surface-tool'] } as unknown as JsonValue, + }), layoutObject('vi-brief', 'view-instance', { descriptor_id: 'markdown.doc', title: 'Console brief', query: { types: ['doc'], where: { kind: 'eq', field: 'slug', value: 'console-brief' } } as unknown as JsonValue, @@ -155,8 +159,20 @@ export function seedLayout(): ObjectRef[] { }), ...companionSeeds('workspace', true), + layoutObject('console-goals', 'surface', { + name: 'Goal Stack', kind: 'goals', role: 'surface', stripe_order: 2, active: false, seed_revision: 3, + }, ['goals.region-editor', ...companionIds('goals')]), + layoutObject('goals.region-editor', 'region', { + kind: 'editor', size: 100, active_tab: 'goals.vi-stack', seed_revision: 3, + }, ['goals.vi-stack']), + layoutObject('goals.vi-stack', 'view-instance', { + descriptor_id: 'goal.stack', title: 'Goal Stack', + query: { types: ['surface-tool'] } as unknown as JsonValue, + }), + ...companionSeeds('goals'), + layoutObject('console-index', 'surface', { - name: 'Index', kind: 'index', role: 'surface', stripe_order: 2, active: false, seed_revision: 2, + name: 'Index', kind: 'index', role: 'surface', stripe_order: 3, active: false, seed_revision: 2, }, ['index.region-rail', 'index.region-editor', ...companionIds('index')]), ...registerToolWindow({ id: 'index.region-rail', title: 'Destinations', icon: 'rail', side: 'left', size: 22, @@ -172,7 +188,7 @@ export function seedLayout(): ObjectRef[] { ...companionSeeds('index'), layoutObject('console-docs', 'surface', { - name: 'Documents', kind: 'documents', role: 'surface', stripe_order: 3, active: false, seed_revision: 2, + name: 'Documents', kind: 'documents', role: 'surface', stripe_order: 4, active: false, seed_revision: 2, }, ['docs.region-list', 'docs.region-editor', ...companionIds('docs')]), ...registerToolWindow({ id: 'docs.region-list', title: 'Documents', icon: 'docs', side: 'left', size: 22, @@ -188,7 +204,7 @@ export function seedLayout(): ObjectRef[] { ...companionSeeds('docs'), layoutObject('console-cards', 'surface', { - name: 'Cards', kind: 'cards', role: 'surface', stripe_order: 4, active: false, seed_revision: 2, + name: 'Cards', kind: 'cards', role: 'surface', stripe_order: 5, active: false, seed_revision: 2, }, ['cards.region-editor', ...companionIds('cards')]), layoutObject('cards.region-editor', 'region', { kind: 'editor', size: 100, active_tab: 'cards.vi-grid', seed_revision: 2, diff --git a/apps/console/src/lib/workspace-state.test.ts b/apps/console/src/lib/workspace-state.test.ts new file mode 100644 index 00000000..4b9bcfee --- /dev/null +++ b/apps/console/src/lib/workspace-state.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + fetchWorkspaceSurface, + readinessIsBuilding, + workspaceTreeRows, + type ProjectTree, +} from '@commonplace/theorem-acp/workspace-state'; + +const TREE: ProjectTree = { + projectId: 'project:fixture', + generation: 3, + roots: [{ + id: 'project:fixture', + kind: 'project', + name: 'Fixture', + path: null, + excluded: false, + children: [{ + id: 'root:fixture', + kind: 'content_root', + name: 'fixture', + path: '/tmp/fixture', + excluded: false, + children: [{ + id: 'excluded:target', + kind: 'excluded', + name: 'target', + path: '/tmp/fixture/target', + excluded: true, + children: [], + }], + }], + }], +}; + +describe('workspace substrate projection', () => { + it('renders typed hierarchy only when its parents are expanded', () => { + const collapsed = workspaceTreeRows(TREE, new Set()); + expect(collapsed.rows.map((row) => row.id)).toEqual(['project:fixture']); + const expanded = workspaceTreeRows(TREE, new Set(['project:fixture', 'root:fixture'])); + expect(expanded.rows.map((row) => [row.id, row.depth, row.node.excluded])).toEqual([ + ['project:fixture', 1, false], + ['root:fixture', 2, false], + ['excluded:target', 3, true], + ]); + }); + + it('keeps Building honest and sends the semantic GraphQL query', async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ + data: { + projectTree: TREE, + readiness: { + generation: 3, + capabilities: [{ capability: 'find', state: 'building', missing: ['trigram'] }], + }, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const surface = await fetchWorkspaceSurface('project:fixture', { fetchImpl }); + expect(readinessIsBuilding(surface.readiness)).toBe(true); + expect(String(fetchImpl.mock.calls[0][1]?.body)).toContain('projectTree'); + expect(String(fetchImpl.mock.calls[0][1]?.body)).toContain('readiness'); + }); +}); diff --git a/apps/console/src/motion/motion-tokens.ts b/apps/console/src/motion/motion-tokens.ts index a9989317..1acba5cc 100644 --- a/apps/console/src/motion/motion-tokens.ts +++ b/apps/console/src/motion/motion-tokens.ts @@ -135,6 +135,12 @@ export const INTERACTION_INVENTORY = [ spec: 'CSS keyframes in this register only, GROUND-quiet; one ambient max per viewport shared with the ground', reducedMotion: 'static two-tone bar', }, + { + trigger: 'Goal Stack task runs', + effect: 'the completed segment of the inbound React Flow edge marches toward the task', + spec: 'CSS keyframes in the motion register only; edge geometry and node positions remain still', + reducedMotion: 'solid completed segment with no dash movement', + }, { trigger: 'Presence mark states', effect: 'glyph constellation cycling (idle, composing), condensation with accent commit flash (acting), settle within one frame (interrupted)', diff --git a/apps/console/src/motion/motion.css b/apps/console/src/motion/motion.css index 74c021d0..6004be60 100644 --- a/apps/console/src/motion/motion.css +++ b/apps/console/src/motion/motion.css @@ -12,6 +12,20 @@ } } +@keyframes goal-edge-march { + to { + stroke-dashoffset: -0.08; + } +} + +.goal-edge-running { + animation: goal-edge-march 0.9s linear infinite; +} + +.goal-edge-progress { + transition: stroke-dashoffset 0.22s var(--ij-ease); +} + .ij-progress-indeterminate { position: relative; overflow: hidden; @@ -33,4 +47,12 @@ width: 100%; opacity: 0.6; } + + .goal-edge-running { + animation: none; + } + + .goal-edge-progress { + transition: none; + } } diff --git a/apps/console/src/views/ThreadView.tsx b/apps/console/src/views/ThreadView.tsx index e0cfa784..31cb3ebf 100644 --- a/apps/console/src/views/ThreadView.tsx +++ b/apps/console/src/views/ThreadView.tsx @@ -7,7 +7,7 @@ import { createContext, useContext, useEffect, useState } from 'react'; import type { BlockHost, ObjectRef } from '@commonplace/block-view/types'; -import { MessagePrimitive, ThreadPrimitive } from '@assistant-ui/react'; +import { MessagePrimitive, ThreadPrimitive, useMessage } from '@assistant-ui/react'; import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'; import { motion } from 'motion/react'; import { Composer } from '@/components/composer/Composer'; @@ -48,10 +48,18 @@ function MarkdownText() { } function AssistantMessage() { + const degradation = useMessage((message) => message.metadata.custom?.degradation) as + | { degraded: true; missingIndexes: string[] } + | undefined; return (
+ {degradation?.degraded ? ( + + degraded: {degradation.missingIndexes.join(', ')} + + ) : null}
diff --git a/apps/console/src/views/goal-stack/GoalStackView.tsx b/apps/console/src/views/goal-stack/GoalStackView.tsx new file mode 100644 index 00000000..d7c02fff --- /dev/null +++ b/apps/console/src/views/goal-stack/GoalStackView.tsx @@ -0,0 +1,228 @@ +'use client'; + +// SOURCING: @xyflow/react for the plan canvas, @dagrejs/dagre for materialized DAG +// layout, cmdk for the capability palette, and @dnd-kit/core for deferred +// affordance injection. Live state is the shared theorem-acp projection. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core'; +import { Background, BackgroundVariant, Controls, ReactFlow, type EdgeTypes, type NodeTypes } from '@xyflow/react'; +import type { ViewRenderProps } from '@commonplace/block-view/types'; +import { + planIsComplete, + subscribePlanState, + type PlanCapability, + type PlanCanvasSnapshot, + type PlanPollPayload, + type PlanSubscriptionStatus, +} from '@commonplace/theorem-acp/plan-state'; +import { layoutGoalPlan, type GoalFlowEdge, type GoalFlowNode } from './plan-layout'; +import { ProgressEdge } from './ProgressEdge'; +import { PlanTaskNode } from './PlanTaskNode'; +import { ToolPalette } from './ToolPalette'; +import { NodeInspector } from './NodeInspector'; +import { PlanPermissionPrompt } from './PlanPermissionPrompt'; + +const NODE_TYPES: NodeTypes = { goalTask: PlanTaskNode }; +const EDGE_TYPES: EdgeTypes = { goalProgress: ProgressEdge }; + +export function GoalStackView(_props: ViewRenderProps) { + const [planInput, setPlanInput] = useState(''); + const [planId, setPlanId] = useState(''); + const [snapshot, setSnapshot] = useState(null); + const [capabilities, setCapabilities] = useState([]); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [hideSuperseded, setHideSuperseded] = useState(false); + const [streamStatus, setStreamStatus] = useState('stopped'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [dragged, setDragged] = useState(null); + const manifestLoadedRef = useRef(false); + + useEffect(() => { + if (!planId) return; + manifestLoadedRef.current = false; + return subscribePlanState({ + intervalMs: 1_500, + load: async (cursor, signal) => { + const response = await fetch( + `/api/harness/plan?id=${encodeURIComponent(planId)}&cursor=${cursor}&manifest=${manifestLoadedRef.current ? 0 : 1}`, + { signal, cache: 'no-store' }, + ); + const body = await response.json().catch(() => null) as PlanPollPayload | null; + if (!response.ok || !body) throw new Error(`Plan request failed with ${response.status}.`); + const degraded = body.degraded !== null && typeof body.degraded === 'object' + ? body.degraded as Record + : null; + if (degraded?.capabilities !== true) manifestLoadedRef.current = true; + return body; + }, + onState: (next) => { + setSnapshot(next.snapshot); + setCapabilities((current) => next.capabilities.length ? next.capabilities : current); + setSelectedTaskId((current) => current && next.snapshot.tasks.some((task) => task.id === current) + ? current + : next.snapshot.tasks.find((task) => task.status === 'running' || task.status === 'failed')?.id + ?? next.snapshot.tasks.find((task) => task.status !== 'superseded')?.id + ?? null); + setError(null); + }, + onStatus: setStreamStatus, + onError: (nextError) => setError(nextError.message), + }); + }, [planId]); + + useEffect(() => { + if (!snapshot) return; + let active = true; + void layoutGoalPlan(snapshot, hideSuperseded).then((layout) => { + if (!active) return; + setNodes(layout.nodes); + setEdges(layout.edges); + }); + return () => { active = false; }; + }, [hideSuperseded, snapshot]); + + const selectedTask = snapshot?.tasks.find((task) => task.id === selectedTaskId) ?? null; + const complete = snapshot ? planIsComplete(snapshot) : false; + + const mutate = useCallback(async (action: string, details: Record) => { + if (!planId) return; + setBusy(true); + setError(null); + try { + const response = await fetch('/api/harness/plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planId, action, ...details }), + }); + const body = await response.json().catch(() => null) as Record | null; + if (!response.ok) { + const detail = [body?.detail, body?.rule, body?.receiptId].filter((value) => typeof value === 'string').join(' · '); + throw new Error(detail || String(body?.error ?? `Plan action failed with ${response.status}.`)); + } + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : String(actionError)); + } finally { + setBusy(false); + } + }, [planId]); + + const onDragStart = (event: DragStartEvent) => { + setDragged((event.active.data.current?.capability as PlanCapability | undefined) ?? null); + }; + const onDragEnd = (event: DragEndEvent) => { + const capability = event.active.data.current?.capability as PlanCapability | undefined; + const taskId = event.over?.data.current?.taskId as string | undefined; + setDragged(null); + if (!capability || !taskId) return; + setSelectedTaskId(taskId); + void mutate('queue_affordance', { taskId, affordanceRef: capability.id, config: {} }); + }; + + const progress = useMemo(() => snapshot?.progress ?? { done: 0, total: 0 }, [snapshot]); + return ( + setDragged(null)}> +
+
+
+
Goal Stack
+

{snapshot?.title ?? 'Open a plan'}

+
+
{ + event.preventDefault(); + if (planInput.trim()) setPlanId(planInput.trim()); + }} + > + setPlanInput(event.target.value)} + placeholder="Plan id" + aria-label="Plan id" + className="h-ij-control min-w-64 rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 font-ij-mono focus:outline-2 focus:outline-ij-accent" + /> + +
+ {streamStatus} +
+ + {snapshot ? ( +
+ {progress.done} of {progress.total} verified + {snapshot.objective} + + +
+ ) : null} + {error ?
{error}
: null} + +
+ +
+ {snapshot ? ( + ({ ...node, selected: node.id === selectedTaskId }))} + edges={edges} + nodeTypes={NODE_TYPES} + edgeTypes={EDGE_TYPES} + fitView + fitViewOptions={{ padding: 0.16, minZoom: 0.4, maxZoom: 1 }} + minZoom={0.2} + maxZoom={1.6} + nodesDraggable={false} + nodesConnectable={false} + onNodeClick={(_event, node) => setSelectedTaskId(node.id)} + onPaneClick={() => setSelectedTaskId(null)} + proOptions={{ hideAttribution: true }} + > + + + + ) : ( +
+ Enter a Plan id to subscribe to its canonical task graph. +
+ )} +
+ +
+
+ + {dragged ? ( +
+ {dragged.title} +
+ ) : null} +
+ { + if (selectedTask) void mutate('approval_decision', { taskId: selectedTask.id, decision }); + }} + /> +
+ ); +} diff --git a/apps/console/src/views/goal-stack/NodeInspector.tsx b/apps/console/src/views/goal-stack/NodeInspector.tsx new file mode 100644 index 00000000..38e7f4c3 --- /dev/null +++ b/apps/console/src/views/goal-stack/NodeInspector.tsx @@ -0,0 +1,96 @@ +'use client'; + +// SOURCING: @commonplace/block-view descriptor panel contract. Mutations go +// through the identity-bound Plan route and never edit the projection locally. + +import type { PlanTask } from '@commonplace/theorem-acp/plan-state'; + +export function NodeInspector({ + task, + busy, + mutate, +}: { + task: PlanTask | null; + busy: boolean; + mutate: (action: string, details: Record) => void; +}) { + if (!task) return
Select a plan node.
; + return ( +
+
{task.kind} task
+

{task.title}

+

{task.description}

+
+ + + + +
+ +
+ Queued affordances +
    + {task.queuedAffordances.map((affordance) => ( +
  • + {affordance.ref} + {affordance.annotations.destructive ? destructive : null} + +
  • + ))} +
+
+ + {task.admissionRequirement === 'require_approval' && !task.approvalReceipt ? ( +
+ Approval required before this node can be claimed. +
+ ) : null} + + {task.changedEvents.length ? ( +
+ Changed paths +
    + {task.changedEvents.map((event) =>
  • {event.path}
  • )} +
+
+ ) : null} + + {task.status === 'failed' || task.status === 'blocked' || task.status === 'superseded' ? ( +
+ + +
+ ) : null} +
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/console/src/views/goal-stack/PlanPermissionPrompt.tsx b/apps/console/src/views/goal-stack/PlanPermissionPrompt.tsx new file mode 100644 index 00000000..c907e6f8 --- /dev/null +++ b/apps/console/src/views/goal-stack/PlanPermissionPrompt.tsx @@ -0,0 +1,58 @@ +'use client'; + +// SOURCING: cmdk Dialog and Command items provide the installed modal, +// focus-management, and keyboard-selection primitive. The decision is sent to +// the canonical Plan approval path; this component never fabricates receipts. + +import { Command } from 'cmdk'; +import type { PlanTask } from '@commonplace/theorem-acp/plan-state'; + +export function PlanPermissionPrompt({ + task, + busy, + decide, +}: { + task: PlanTask | null; + busy: boolean; + decide: (decision: 'allow' | 'reject') => void; +}) { + const open = task?.admissionRequirement === 'require_approval' && !task.approvalReceipt; + return ( + undefined} + label="Approval required" + shouldFilter={false} + overlayClassName="fixed inset-0 z-50 bg-ij-ground opacity-75" + contentClassName="fixed inset-x-0 top-1/3 z-50 mx-auto w-144 max-w-full outline-none" + > +
+
+
Approval required
+

{task?.title}

+
+
+ A destructive annotated affordance is queued on this node. Approval is one-shot and is recorded by the Plan substrate. +
+ + decide('allow')} + className="flex h-ij-control cursor-default items-center justify-center rounded-ij-arc bg-ij-accent px-3 text-ij-ink-bright data-[disabled=true]:opacity-50 data-[selected=true]:bg-ij-accent-hover" + > + Allow once + + decide('reject')} + className="flex h-ij-control cursor-default items-center justify-center rounded-ij-arc border border-ij-control-border px-3 data-[disabled=true]:opacity-50 data-[selected=true]:bg-ij-hover-surface" + > + Reject + + +
+
+ ); +} diff --git a/apps/console/src/views/goal-stack/PlanTaskNode.tsx b/apps/console/src/views/goal-stack/PlanTaskNode.tsx new file mode 100644 index 00000000..e3078464 --- /dev/null +++ b/apps/console/src/views/goal-stack/PlanTaskNode.tsx @@ -0,0 +1,60 @@ +'use client'; + +// SOURCING: @xyflow/react node contract and @dnd-kit/core drop target. + +import { useDroppable } from '@dnd-kit/core'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; +import type { GoalFlowNode } from './plan-layout'; + +export function PlanTaskNode({ data, selected }: NodeProps) { + const task = data.task; + const { isOver, setNodeRef } = useDroppable({ + id: `goal-task:${task.id}`, + data: { taskId: task.id }, + disabled: task.status !== 'pending', + }); + const failed = task.status === 'failed'; + const blocked = task.status === 'blocked'; + const verified = task.status === 'verified'; + return ( +
+ +
+ {task.kind} + {task.status} +
+
+ {task.title} +

{task.description}

+
+ {task.serves.slice(0, 3).map((criterion) => ( + + {criterion} + + ))} + {task.queuedAffordances.length ? ( + + {task.queuedAffordances.length} tools + + ) : null} +
+
+ +
+ ); +} diff --git a/apps/console/src/views/goal-stack/ProgressEdge.tsx b/apps/console/src/views/goal-stack/ProgressEdge.tsx new file mode 100644 index 00000000..03673be2 --- /dev/null +++ b/apps/console/src/views/goal-stack/ProgressEdge.tsx @@ -0,0 +1,61 @@ +'use client'; + +// SOURCING: @xyflow/react custom edge contract. Both paths use the exact same +// Bezier geometry; the second path reveals progress through normalized length. + +import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react'; +import { useId } from 'react'; +import type { GoalFlowEdge } from './plan-layout'; + +export function ProgressEdge(props: EdgeProps) { + const maskId = useId(); + const [path] = getBezierPath(props); + const progress = Math.min(1, Math.max(0, props.data?.progress ?? 0)); + const state = props.data?.state ?? 'pending'; + const stroke = state === 'failed' + ? 'var(--ij-error)' + : state === 'blocked' + ? 'var(--ij-warn)' + : state === 'verified' + ? 'var(--ij-ok)' + : 'var(--ij-accent)'; + return ( + + + + + + + + + {state === 'running' ? ( + + ) : null} + + ); +} diff --git a/apps/console/src/views/goal-stack/ToolPalette.tsx b/apps/console/src/views/goal-stack/ToolPalette.tsx new file mode 100644 index 00000000..ad1d9b46 --- /dev/null +++ b/apps/console/src/views/goal-stack/ToolPalette.tsx @@ -0,0 +1,66 @@ +'use client'; + +// SOURCING: cmdk for search and keyboard navigation, @dnd-kit/core for the +// affordance drag source. Annotations come from the capability manifest. + +import { useDraggable } from '@dnd-kit/core'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'; +import type { PlanCapability } from '@commonplace/theorem-acp/plan-state'; + +export function ToolPalette({ capabilities }: { capabilities: readonly PlanCapability[] }) { + return ( + +
+ +
+ + No capability matches. + + {capabilities.map((capability) => ( + + ))} + + +
+ ); +} + +function DraggableCapability({ capability }: { capability: PlanCapability }) { + const { + attributes, + isDragging, + listeners, + setNodeRef, + } = useDraggable({ + id: `goal-capability:${capability.id}`, + data: { capability }, + }); + return ( + + + {capability.title} + {capability.serverOrigin} + + {capability.description} + + + {capability.annotations.readOnly ? 'read-only' : 'writes'} + + {capability.annotations.destructive ? ( + destructive + ) : null} + + + ); +} diff --git a/apps/console/src/views/goal-stack/plan-layout.ts b/apps/console/src/views/goal-stack/plan-layout.ts new file mode 100644 index 00000000..67f7d35c --- /dev/null +++ b/apps/console/src/views/goal-stack/plan-layout.ts @@ -0,0 +1,110 @@ +// SOURCING: @dagrejs/dagre for left-to-right DAG coordinates and @xyflow/react for the +// canvas projection. The canonical graph remains the Harness Plan substrate. + +import type { Edge, Node } from '@xyflow/react'; +import { Graph, layout } from '@dagrejs/dagre'; +import type { + PlanCanvasSnapshot, + PlanTask, + PlanTaskStatus, +} from '@commonplace/theorem-acp/plan-state'; + +export type GoalEdgeState = 'pending' | 'claimed' | 'running' | 'verified' | 'blocked' | 'failed' | 'superseded'; + +export interface GoalEdgeData extends Record { + progress: number; + state: GoalEdgeState; + relation: 'dependency' | 'supersedes'; +} + +export interface GoalNodeData extends Record { + task: PlanTask; +} + +export type GoalFlowNode = Node; +export type GoalFlowEdge = Edge; + +const NODE_WIDTH = 232; +const NODE_HEIGHT = 104; + +export async function layoutGoalPlan( + snapshot: PlanCanvasSnapshot, + hideSuperseded: boolean, +): Promise<{ nodes: GoalFlowNode[]; edges: GoalFlowEdge[] }> { + const tasks = hideSuperseded + ? snapshot.tasks.filter((task) => task.status !== 'superseded') + : snapshot.tasks; + const visible = new Set(tasks.map((task) => task.id)); + const edgePairs = tasks.flatMap((task) => [ + ...task.dependencies + .filter((source) => visible.has(source)) + .map((source) => ({ source, target: task.id, relation: 'dependency' as const })), + ...task.supersedes + .filter((source) => visible.has(source)) + .map((source) => ({ source, target: task.id, relation: 'supersedes' as const })), + ]); + const graph = new Graph() + .setGraph({ rankdir: 'LR', nodesep: 32, edgesep: 16, ranksep: 80, marginx: 16, marginy: 16 }) + .setDefaultEdgeLabel(() => ({})); + tasks.forEach((task) => graph.setNode(task.id, { width: NODE_WIDTH, height: NODE_HEIGHT })); + edgePairs.forEach((edge) => graph.setEdge(edge.source, edge.target)); + layout(graph); + const positions = new Map( + tasks.map((task) => { + const node = graph.node(task.id); + return [task.id, { + x: (node?.x ?? NODE_WIDTH / 2) - NODE_WIDTH / 2, + y: (node?.y ?? NODE_HEIGHT / 2) - NODE_HEIGHT / 2, + }] as const; + }), + ); + return { + nodes: tasks.map((task) => ({ + id: task.id, + type: 'goalTask', + position: positions.get(task.id) ?? { x: 0, y: 0 }, + width: NODE_WIDTH, + height: NODE_HEIGHT, + draggable: false, + connectable: false, + data: { task }, + })), + edges: edgePairs.map((edge) => { + const task = tasks.find((candidate) => candidate.id === edge.target); + const state = edge.relation === 'supersedes' + ? 'superseded' + : edgeState(task?.status ?? 'pending'); + return { + id: `${edge.relation}:${edge.source}:${edge.target}`, + source: edge.source, + target: edge.target, + type: 'goalProgress', + data: { + progress: edgeProgress(edge.relation === 'supersedes' ? 'superseded' : task?.status ?? 'pending'), + state, + relation: edge.relation, + }, + }; + }), + }; +} + +export function edgeProgress(status: PlanTaskStatus): number { + if (status === 'claimed') return 0.18; + if (status === 'running') return 0.58; + if (status === 'patch_proposed') return 0.76; + if (status === 'verifying') return 0.88; + if (status === 'verified' || status === 'failed' || status === 'superseded') return 1; + if (status === 'blocked') return 1; + return 0; +} + +function edgeState(status: PlanTaskStatus): GoalEdgeState { + if (status === 'claimed') return 'claimed'; + if (status === 'running' || status === 'patch_proposed' || status === 'verifying') return 'running'; + if (status === 'verified') return 'verified'; + if (status === 'failed') return 'failed'; + if (status === 'blocked') return 'blocked'; + if (status === 'superseded') return 'superseded'; + return 'pending'; +} diff --git a/apps/console/src/views/registry.tsx b/apps/console/src/views/registry.tsx index 974b791f..fab65259 100644 --- a/apps/console/src/views/registry.tsx +++ b/apps/console/src/views/registry.tsx @@ -19,6 +19,8 @@ import { AccountView } from './AccountView'; import { FilesView } from './FilesView'; import { ContextView } from './ContextView'; import { ProactivityView } from './ProactivityView'; +import { WorkspaceSubstrateView } from './workspace/WorkspaceSubstrateView'; +import { GoalStackView } from './goal-stack/GoalStackView'; function ThreadRender(props: ViewRenderProps) { return ; @@ -238,6 +240,36 @@ const PROACTIVITY: ViewDescriptor = { render: ProactivityView, }; +const WORKSPACE_SUBSTRATE: ViewDescriptor = { + id: 'workspace.substrate', + name: 'Workspace', + accepts: {}, + emits: ['select', 'open', 'update'], + renderer: 'workspace.substrate', + source: { + package: '@tanstack/react-virtual', + component: 'useVirtualizer', + mode: 'wrap', + regime: 'css-vars', + }, + render: WorkspaceSubstrateView, +}; + +const GOAL_STACK: ViewDescriptor = { + id: 'goal.stack', + name: 'Goal Stack', + accepts: {}, + emits: ['select', 'invoke_tool', 'update'], + renderer: 'goal.stack', + source: { + package: '@xyflow/react', + component: 'ReactFlow', + mode: 'wrap', + regime: 'css-vars', + }, + render: GoalStackView, +}; + const APPEARANCE: ViewDescriptor = { id: 'settings.appearance', name: 'Appearance', @@ -283,6 +315,8 @@ export const CONSOLE_VIEW_REGISTRY = createViewRegistry([ HUNK_REVIEW, APPEARANCE, PROACTIVITY, + WORKSPACE_SUBSTRATE, + GOAL_STACK, ACCOUNT, ]); diff --git a/apps/console/src/views/workspace/WorkspaceHistoryDiff.tsx b/apps/console/src/views/workspace/WorkspaceHistoryDiff.tsx new file mode 100644 index 00000000..c3264eec --- /dev/null +++ b/apps/console/src/views/workspace/WorkspaceHistoryDiff.tsx @@ -0,0 +1,54 @@ +'use client'; + +// SOURCING: @codemirror/merge. The workspace history contract supplies the +// exact bytes for two revisions; CodeMirror owns the inline diff surface. + +import { useEffect, useRef } from 'react'; +import { MergeView } from '@codemirror/merge'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import type { FileRevision } from '@commonplace/theorem-acp/workspace-state'; +import { useAppearance } from '@/lib/appearance-store'; +import { intuiEditorExtensions } from '@/views/cm-register-theme'; + +export function WorkspaceHistoryDiff({ + before, + after, +}: { + before: FileRevision; + after: FileRevision; +}) { + const parentRef = useRef(null); + const { resolvedMode } = useAppearance(); + + useEffect(() => { + if (!parentRef.current || before.content === null || after.content === null) return; + const extensions = [ + ...intuiEditorExtensions(resolvedMode), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ]; + const view = new MergeView({ + a: { doc: before.content, extensions }, + b: { doc: after.content, extensions }, + parent: parentRef.current, + orientation: 'a-b', + highlightChanges: true, + gutter: true, + collapseUnchanged: { margin: 2, minSize: 6 }, + }); + return () => view.destroy(); + }, [after, before, resolvedMode]); + + if (before.content === null || after.content === null) { + return

Diff unavailable for a binary or oversized revision.

; + } + + return ( +
+ ); +} diff --git a/apps/console/src/views/workspace/WorkspaceSubstrateView.tsx b/apps/console/src/views/workspace/WorkspaceSubstrateView.tsx new file mode 100644 index 00000000..15f705ae --- /dev/null +++ b/apps/console/src/views/workspace/WorkspaceSubstrateView.tsx @@ -0,0 +1,554 @@ +'use client'; + +// SOURCING: @tanstack/react-virtual for the semantic workspace tree and +// @codemirror/merge for revision diffs. The browser supplies a path input; +// the desktop shell can replace that field with its native directory picker. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Command } from 'cmdk'; +import * as Popover from '@radix-ui/react-popover'; +import type { ViewRenderProps } from '@commonplace/block-view/types'; +import { + addWorkspaceContentRoot, + createWorkspaceProject, + fetchFileHistory, + fetchWorkspaceReadiness, + fetchWorkspaceSurface, + findInWorkspaceProject, + readinessIsBuilding, + restoreWorkspaceRevision, + workspaceTreeRows, + type FileHistory, + type WorkspaceReadiness, + type WorkspaceSearchHit, + type WorkspaceSurfaceSnapshot, + type WorkspaceTreeNode, +} from '@commonplace/theorem-acp/workspace-state'; +import { WorkspaceHistoryDiff } from './WorkspaceHistoryDiff'; + +const POLL_MS = 1_500; +const PROJECT_STORAGE_KEY = 'commonplace.console.workspace.project.v1'; + +export function WorkspaceSubstrateView(_props: ViewRenderProps) { + const [projectId, setProjectId] = useState(''); + const [projectInput, setProjectInput] = useState(''); + const [projectName, setProjectName] = useState(''); + const [rootPath, setRootPath] = useState(''); + const [surface, setSurface] = useState(null); + const [readiness, setReadiness] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [selected, setSelected] = useState(null); + const [history, setHistory] = useState(null); + const [historyPath, setHistoryPath] = useState(''); + const [findQuery, setFindQuery] = useState(''); + const [findHits, setFindHits] = useState([]); + const [findBusy, setFindBusy] = useState(false); + const [activeRowId, setActiveRowId] = useState(null); + const [selectedGenerations, setSelectedGenerations] = useState([]); + const [status, setStatus] = useState<'connecting' | 'live' | 'reconnecting'>('connecting'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const scrollRef = useRef(null); + const rowRefs = useRef(new Map()); + const lastFindRef = useRef<{ query: string; projectId: string } | null>(null); + const readinessSignatureRef = useRef(''); + + useEffect(() => { + const stored = window.localStorage.getItem(PROJECT_STORAGE_KEY); + if (stored) setProjectId(stored); + }, []); + + useEffect(() => { + if (projectId) window.localStorage.setItem(PROJECT_STORAGE_KEY, projectId); + }, [projectId]); + + useEffect(() => { + const controller = new AbortController(); + let stopped = false; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const next = projectId + ? await fetchWorkspaceSurface(projectId, { signal: controller.signal }) + : null; + const nextReadiness = next?.readiness + ?? await fetchWorkspaceReadiness({ signal: controller.signal }); + if (stopped) return; + setSurface(next); + setReadiness(nextReadiness); + if (next && expanded.size === 0) { + setExpanded(new Set(next.tree.roots.map((root) => root.id))); + } + setStatus('live'); + setError(null); + } catch (pollError) { + if (stopped || controller.signal.aborted) return; + setStatus('reconnecting'); + setError(pollError instanceof Error ? pollError.message : String(pollError)); + } finally { + if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); + } + }; + void poll(); + return () => { + stopped = true; + controller.abort(); + if (timer) clearTimeout(timer); + }; + }, [expanded.size, projectId]); + + const projection = useMemo( + () => surface ? workspaceTreeRows(surface.tree, expanded) : { rows: [], nodeById: new Map() }, + [expanded, surface], + ); + const virtualizer = useVirtualizer({ + count: projection.rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 24, + overscan: 12, + }); + const effectiveActiveRowId = projection.rows.some((row) => row.id === activeRowId) + ? activeRowId + : projection.rows[0]?.id; + const focusRow = (id: string) => { + const index = projection.rows.findIndex((row) => row.id === id); + if (index < 0) return; + setActiveRowId(id); + virtualizer.scrollToIndex(index, { align: 'auto' }); + requestAnimationFrame(() => requestAnimationFrame(() => rowRefs.current.get(id)?.focus())); + }; + const toggleRow = (id: string) => setExpanded((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const selectedRevisions = useMemo( + () => history?.revisions + .filter((revision) => selectedGenerations.includes(revision.generation)) + .sort((left, right) => left.generation - right.generation) + .slice(-2) ?? [], + [history, selectedGenerations], + ); + + const importRoot = useCallback(async () => { + if (!rootPath.trim() || (!projectId && !projectName.trim())) return; + setBusy(true); + setError(null); + try { + const receipt = projectId + ? await addWorkspaceContentRoot(projectId, rootPath.trim()) + : await createWorkspaceProject(projectName.trim(), rootPath.trim()); + setProjectId(receipt.projectId); + setRootPath(''); + } catch (importError) { + setError(importError instanceof Error ? importError.message : String(importError)); + } finally { + setBusy(false); + } + }, [projectId, projectName, rootPath]); + + const loadHistory = useCallback(async (path: string) => { + setBusy(true); + setError(null); + try { + const next = await fetchFileHistory(path); + setHistory(next); + setSelectedGenerations(next.revisions.slice(-2).map((revision) => revision.generation)); + } catch (historyError) { + setError(historyError instanceof Error ? historyError.message : String(historyError)); + } finally { + setBusy(false); + } + }, []); + + const restore = useCallback(async (generation: number) => { + if (!history) return; + setBusy(true); + setError(null); + try { + const next = await restoreWorkspaceRevision(history.path, generation); + setHistory(next); + setSelectedGenerations(next.revisions.slice(-2).map((revision) => revision.generation)); + } catch (restoreError) { + setError(restoreError instanceof Error ? restoreError.message : String(restoreError)); + } finally { + setBusy(false); + } + }, [history]); + + const executeFind = useCallback(async (query: string, activeProjectId: string) => { + setFindBusy(true); + setError(null); + try { + setFindHits(await findInWorkspaceProject(query, activeProjectId)); + } catch (findError) { + setError(findError instanceof Error ? findError.message : String(findError)); + setFindHits([]); + } finally { + setFindBusy(false); + } + }, []); + + const runFind = useCallback(async () => { + const query = findQuery.trim(); + if (!projectId || !query) return; + lastFindRef.current = { query, projectId }; + await executeFind(query, projectId); + }, [executeFind, findQuery, projectId]); + + const readinessSignature = readiness?.capabilities + .map((capability) => `${capability.capability}:${capability.state}:${capability.missing.join(',')}`) + .join('|') ?? ''; + useEffect(() => { + const previous = readinessSignatureRef.current; + readinessSignatureRef.current = readinessSignature; + if (!previous || previous === readinessSignature || !lastFindRef.current) return; + const active = lastFindRef.current; + void executeFind(active.query, active.projectId); + }, [executeFind, readinessSignature]); + + const building = readinessIsBuilding(readiness); + return ( +
+
+
+
Workspace substrate
+

+ {projectId || 'Import a project'} +

+
+ + {status} + + {projectId ? ( + + ) : null} + + + + + + + Readiness by capability +
    + {readiness?.capabilities.map((capability) => ( +
  • + {capability.capability} + + {capability.missing.length ? capability.missing.join(', ') : capability.state} + +
  • + ))} +
+ +
+
+
+
+ +
+ {!projectId ? ( + <> + setProjectInput(event.target.value)} + placeholder="Existing project id" + aria-label="Existing project id" + className="h-ij-control rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 font-ij-mono text-ij-ink focus:outline-2 focus:outline-ij-accent" + /> + + setProjectName(event.target.value)} + placeholder="New project name" + aria-label="Project name" + className="h-ij-control rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 text-ij-ink focus:outline-2 focus:outline-ij-accent" + /> + + ) : null} + setRootPath(event.target.value)} + placeholder="Absolute directory path" + aria-label="Directory path" + className="h-ij-control min-w-80 flex-1 rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 font-ij-mono text-ij-ink focus:outline-2 focus:outline-ij-accent" + /> + +
+ + {projectId ? ( + +
{ + event.preventDefault(); + void runFind(); + }} + > + + + + {findHits.length ? ( + + {findHits.map((hit) => ( + + {hit.item.title} + {hit.item.kind} + {hit.insideProject === true ? inside project : null} + {hit.score.toFixed(3)} + {hit.degraded ? ( + + degraded: {hit.missingIndexes.join(', ')} + + ) : null} + + ))} + + ) : null} +
+ ) : null} + + {error ?
{error}
: null} + +
+
+
+ Project model +
Generation {surface?.tree.generation ?? readiness?.generation ?? 0}
+
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = projection.rows[virtualRow.index]; + const open = expanded.has(row.id); + return ( + + ); + })} +
+
+
+ +
+

Entity contract

+ {selected ? ( +
+ + + + + +
+ ) :

Select a typed workspace entity.

} +
+ Project membrane +

Inside results are boosted. Outside results remain visible above the nonzero floor.

+
+
+ Readiness by capability +
    + {readiness?.capabilities.map((capability) => ( +
  • + {capability.capability} + + {capability.missing.length ? capability.missing.join(', ') : capability.state} + +
  • + ))} +
+
+
+ +
+
+ Local history +
{history?.path ?? 'Select a file'}
+
{ + event.preventDefault(); + if (historyPath.trim()) void loadHistory(historyPath.trim()); + }} + > + setHistoryPath(event.target.value)} + aria-label="File history path" + placeholder="Absolute file path" + className="h-ij-control min-w-0 flex-1 rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 font-ij-mono focus:outline-2 focus:outline-ij-accent" + /> + +
+
+
+
    + {history?.revisions.slice().reverse().map((revision) => ( +
  1. + + +
  2. + ))} +
+ {selectedRevisions.length === 2 && selectedRevisions.every((revision) => revision.content !== null) ? ( +
+ +
+ ) : selectedRevisions.length === 2 ? ( +

Diff unavailable for a binary or oversized revision.

+ ) : null} +
+
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/packages/theorem-acp/package.json b/packages/theorem-acp/package.json index 9c127edb..ad2decf1 100644 --- a/packages/theorem-acp/package.json +++ b/packages/theorem-acp/package.json @@ -11,7 +11,10 @@ "./hosted-client": "./src/hosted-client.ts", "./session-manager": "./src/session-manager.ts", "./bridge": "./src/bridge.ts", - "./identity": "./src/identity.ts" + "./identity": "./src/identity.ts", + "./plan-state": "./src/plan-state.ts", + "./plan-program": "./src/plan-program.ts", + "./workspace-state": "./src/workspace-state.ts" }, "scripts": { "check": "npx --yes -p typescript@5.7.3 tsc --noEmit" diff --git a/packages/theorem-acp/src/plan-program.ts b/packages/theorem-acp/src/plan-program.ts new file mode 100644 index 00000000..91806fd9 --- /dev/null +++ b/packages/theorem-acp/src/plan-program.ts @@ -0,0 +1,122 @@ +import type { PlanCanvasSnapshot, PlanTask } from './plan-state'; + +const SHAPE_ID = 'theorem.plan-task.v1'; + +export interface ProgrammableGraphDefinition { + tenant_id?: string; + name: string; + intent: string; + trigger: { kind: 'coordination_stream'; stream: string }; + budget: { max_invocations: number; window_seconds: number; max_cost_microunits: number }; + approval: { mode: 'require_side_effects'; grant_ids: string[] }; + nodes: Array>; + edges: Array>; + metadata: Record; +} + +export function planToProgrammableGraph(snapshot: PlanCanvasSnapshot): ProgrammableGraphDefinition { + const tasks = snapshot.tasks.filter((task) => task.status !== 'superseded'); + const taskIds = new Set(tasks.map((task) => task.id)); + const dependents = new Map(); + for (const task of tasks) { + for (const dependency of task.dependencies) { + if (!taskIds.has(dependency)) continue; + dependents.set(dependency, [...(dependents.get(dependency) ?? []), task.id]); + } + } + const terminals = tasks.filter((task) => (dependents.get(task.id)?.length ?? 0) === 0); + const nodes = tasks.map((task) => programNode(task, dependents.get(task.id) ?? [])); + nodes.push({ + id: 'plan-output', + block_id: 'block:plan-output', + contract: blockContract('contract:plan-output', 'Plan output', 'Materialize the completed plan output.', false), + inputs: terminals.map((task) => ({ id: portId('from', task.id), shape_id: SHAPE_ID })), + outputs: [], + kind: 'sink', + sink_id: 'tenant-graph', + asserts: false, + }); + + const edges: Array> = []; + for (const task of tasks) { + for (const dependency of task.dependencies) { + if (!taskIds.has(dependency)) continue; + edges.push({ + id: `plan-edge:${dependency}:${task.id}`, + from_node: dependency, + from_port: portId('to', task.id), + to_node: task.id, + to_port: portId('from', dependency), + }); + } + } + for (const task of terminals) { + edges.push({ + id: `plan-edge:${task.id}:plan-output`, + from_node: task.id, + from_port: 'result', + to_node: 'plan-output', + to_port: portId('from', task.id), + }); + } + + return { + name: snapshot.title, + intent: snapshot.objective, + trigger: { kind: 'coordination_stream', stream: `plan:${snapshot.planId}` }, + budget: { + max_invocations: Math.max(1, tasks.length * 4), + window_seconds: 3_600, + max_cost_microunits: Math.max(50_000, tasks.length * 10_000), + }, + approval: { mode: 'require_side_effects', grant_ids: [] }, + nodes, + edges, + metadata: { + source_register: 'plan_canvas', + source_plan_id: snapshot.planId, + source_schema: snapshot.schema, + }, + }; +} + +function programNode(task: PlanTask, dependentIds: string[]): Record { + const destructive = task.queuedAffordances.some((affordance) => affordance.annotations.destructive); + const base = { + id: task.id, + block_id: `block:plan-task:${task.alias}`, + contract: blockContract(`contract:plan-task:${task.alias}`, task.title, task.description, destructive), + inputs: task.dependencies.map((dependency) => ({ id: portId('from', dependency), shape_id: SHAPE_ID })), + outputs: [ + ...dependentIds.map((dependent) => ({ id: portId('to', dependent), shape_id: SHAPE_ID })), + ...(dependentIds.length === 0 ? [{ id: 'result', shape_id: SHAPE_ID }] : []), + ], + }; + if (task.kind === 'verify' || task.kind === 'review') { + return { ...base, kind: 'verify', verifier_id: `plan:${task.kind}` }; + } + return { + ...base, + kind: 'stochastic', + affordance_id: task.queuedAffordances[0]?.ref ?? `plan-task:${task.alias}`, + }; +} + +function blockContract(id: string, name: string, description: string, hasSideEffects: boolean) { + return { + id, + name, + version: '1.0.0', + description, + capabilities: [{ + name: 'run', + description: `Run ${name}`, + input_schema: { type: 'object' }, + has_side_effects: hasSideEffects, + }], + }; +} + +function portId(direction: 'from' | 'to', taskId: string): string { + return `${direction}-${taskId.replace(/[^a-zA-Z0-9_-]+/g, '-')}`; +} diff --git a/packages/theorem-acp/src/plan-state.ts b/packages/theorem-acp/src/plan-state.ts new file mode 100644 index 00000000..f65a110f --- /dev/null +++ b/packages/theorem-acp/src/plan-state.ts @@ -0,0 +1,483 @@ +/** + * Versioned, framework-free projection used by the CommonPlace Plan Canvas. + * + * The canonical plan remains the Harness Plan substrate. This module only + * normalizes `plan inspect`/`plan what_changed` payloads and folds live events + * so web, console, and desktop shells can share one projection contract. + */ + +export const PLAN_CANVAS_SCHEMA = 'commonplace.plan-canvas/1' as const; + +export type PlanTaskKind = 'regular' | 'compact' | 'review' | 'verify'; +export type PlanTaskStatus = + | 'pending' + | 'claimed' + | 'running' + | 'patch_proposed' + | 'verifying' + | 'verified' + | 'blocked' + | 'failed' + | 'superseded'; + +export type PlanCriterion = { + id: string; + statement: string; +}; + +export type AffordanceAnnotations = { + readOnly: boolean; + destructive: boolean; +}; + +export type PlanCapability = { + id: string; + title: string; + description: string; + serverOrigin: string; + toolName: string; + annotations: AffordanceAnnotations; +}; + +export type QueuedAffordance = { + ref: string; + config: unknown; + annotations: AffordanceAnnotations; +}; + +export type PlanChangedEvent = { + path: string; + generation: number | null; + at: string | null; +}; + +export type PlanTask = { + id: string; + alias: string; + title: string; + description: string; + kind: PlanTaskKind; + status: PlanTaskStatus; + dependencies: string[]; + serves: string[]; + acceptanceCriteria: string[]; + queuedAffordances: QueuedAffordance[]; + admissionRequirement: 'admitted' | 'require_approval'; + approvalReceipt: string | null; + claimHolder: string | null; + generationAtStart: number | null; + generationAtEnd: number | null; + supersedes: string[]; + supersededBy: string[]; + changedEvents: PlanChangedEvent[]; + proofStatus: string | null; +}; + +export type PlanCanvasSnapshot = { + schema: typeof PLAN_CANVAS_SCHEMA; + planId: string; + title: string; + objective: string; + status: string; + projectId: string | null; + criteria: PlanCriterion[]; + tasks: PlanTask[]; + progress: { done: number; total: number }; + streamCursor: number; + events: PlanCanvasEvent[]; +}; + +export type PlanCanvasEvent = { + id: string; + transition: string; + nodeIds: string[]; + actor: string | null; + graphVersion: number; + at: string | null; + detail: Record; +}; + +export type PlanPollPayload = { + snapshot: unknown; + events?: unknown; + capabilities?: unknown; + cursor?: unknown; + degraded?: unknown; +}; + +export type PlanPollResult = { + snapshot: PlanCanvasSnapshot; + capabilities: PlanCapability[]; +}; + +export type PlanSubscriptionStatus = 'connecting' | 'live' | 'reconnecting' | 'stopped'; + +export type PlanSubscriptionOptions = { + intervalMs?: number; + load: (cursor: number, signal: AbortSignal) => Promise; + onState: (result: PlanPollResult) => void; + onStatus?: (status: PlanSubscriptionStatus) => void; + onError?: (error: Error) => void; +}; + +/** + * Poll the plan stream without overlapping requests. The Plan substrate's + * graph-version cursor makes polling replay-safe: a delayed response cannot + * silently skip the transitions written while it was in flight. + */ +export function subscribePlanState(options: PlanSubscriptionOptions): () => void { + const intervalMs = Math.max(250, options.intervalMs ?? 1_500); + const controller = new AbortController(); + let stopped = false; + let timer: ReturnType | undefined; + let cursor = 0; + let current: PlanCanvasSnapshot | null = null; + + const schedule = () => { + if (stopped) return; + timer = setTimeout(() => void poll(), intervalMs); + }; + + const poll = async () => { + options.onStatus?.(current ? 'live' : 'connecting'); + try { + const payload = await options.load(cursor, controller.signal); + if (stopped) return; + const next = normalizePlanPoll(payload, current); + current = next.snapshot; + cursor = Math.max(cursor, next.snapshot.streamCursor); + options.onState(next); + options.onStatus?.('live'); + } catch (error) { + if (stopped || controller.signal.aborted) return; + options.onStatus?.('reconnecting'); + options.onError?.(error instanceof Error ? error : new Error(String(error))); + } finally { + schedule(); + } + }; + + void poll(); + return () => { + stopped = true; + controller.abort(); + if (timer) clearTimeout(timer); + options.onStatus?.('stopped'); + }; +} + +export function normalizePlanPoll( + payload: PlanPollPayload, + previous: PlanCanvasSnapshot | null = null, +): PlanPollResult { + const events = normalizePlanEvents(payload.events); + const inspected = normalizePlanSnapshot(payload.snapshot); + const folded = previous ? events.reduce(applyPlanEvent, previous) : null; + const snapshot = inspected ?? folded; + if (!snapshot) throw new Error('Plan stream response did not contain a usable snapshot.'); + + const cursor = Math.max( + snapshot.streamCursor, + number(payload.cursor) ?? 0, + ...events.map((event) => event.graphVersion), + ); + return { + snapshot: { + ...snapshot, + streamCursor: cursor, + events: mergeEvents(snapshot.events, events), + }, + capabilities: normalizeCapabilities(payload.capabilities), + }; +} + +export function normalizePlanSnapshot(value: unknown): PlanCanvasSnapshot | null { + const root = record(value); + if (!root) return null; + const plan = record(root.plan) ?? record(record(root.data)?.plan); + const planId = text(root.plan_id) ?? text(root.planId) ?? text(plan?.id); + if (!planId || !plan) return null; + const tasks = array(root.tasks ?? record(root.data)?.tasks) + .map(normalizeTask) + .filter(nonNullable); + const rawProgress = record(root.progress); + const criteria = array(plan.acceptance_criteria ?? plan.acceptanceCriteria) + .map((criterion, index) => normalizeCriterion(criterion, index)) + .filter(nonNullable); + const done = number(rawProgress?.done) ?? tasks.filter((task) => task.status === 'verified').length; + const total = number(rawProgress?.total) ?? tasks.filter((task) => task.status !== 'superseded').length; + const streamCursor = Math.max( + number(root.graph_version) ?? 0, + number(root.graphVersion) ?? 0, + ...array(root.events).map((event) => number(record(event)?.graph_version) ?? 0), + ); + + return { + schema: PLAN_CANVAS_SCHEMA, + planId, + title: text(plan.title) ?? planId, + objective: text(plan.objective) ?? '', + status: text(plan.status) ?? (done === total && total > 0 ? 'completed' : 'active'), + projectId: text(plan.project_id) ?? text(plan.projectId) ?? null, + criteria, + tasks, + progress: { done, total }, + streamCursor, + events: normalizePlanEvents(root.events), + }; +} + +export function normalizeCapabilities(value: unknown): PlanCapability[] { + const root = record(value); + const values = array(root?.results ?? root?.capabilities ?? value); + const seen = new Set(); + const capabilities: PlanCapability[] = []; + for (const raw of values) { + const item = record(raw); + if (!item) continue; + const id = text(item.id) ?? text(item.affordance_id) ?? text(item.affordanceId); + if (!id || seen.has(id)) continue; + seen.add(id); + const annotations = normalizeAnnotations(item.annotations ?? item.permissions, item.writeback_policy ?? item.writebackPolicy); + capabilities.push({ + id, + title: text(item.title) ?? text(item.name) ?? text(item.tool_name) ?? id, + description: text(item.description) ?? text(item.one_line_description) ?? '', + serverOrigin: text(item.server_origin) ?? text(item.server_id) ?? 'Theorem', + toolName: text(item.tool_name) ?? id.split(':').at(-1) ?? id, + annotations, + }); + } + return capabilities; +} + +export function normalizePlanEvents(value: unknown): PlanCanvasEvent[] { + const root = record(value); + return array(root?.rows ?? root?.events ?? value) + .map((raw, index) => { + const item = record(raw); + if (!item) return null; + const detail = record(item.detail) ?? record(item.payload) ?? {}; + const graphVersion = number(item.graph_version) ?? number(item.graphVersion) ?? 0; + const transition = text(item.transition) ?? text(item.type) ?? 'plan_changed'; + return { + id: text(item.event_id) ?? text(item.eventId) ?? `${transition}:${graphVersion}:${index}`, + transition, + nodeIds: strings(item.node_ids ?? item.nodeIds ?? item.task_ids ?? item.taskIds), + actor: text(item.actor) ?? text(item.head) ?? null, + graphVersion, + at: text(item.created_at) ?? text(item.createdAt) ?? text(item.at) ?? null, + detail, + } satisfies PlanCanvasEvent; + }) + .filter(nonNullable) + .sort((left, right) => left.graphVersion - right.graphVersion || left.id.localeCompare(right.id)); +} + +export function applyPlanEvent(snapshot: PlanCanvasSnapshot, event: PlanCanvasEvent): PlanCanvasSnapshot { + const status = statusForTransition(event.transition, event.detail); + const changed = changedEvents(event.detail, event.at); + const affected = new Set(event.nodeIds); + const tasks = snapshot.tasks.map((task) => { + if (!affected.has(task.id) && !affected.has(task.alias)) return task; + return { + ...task, + ...(status ? { status } : {}), + ...(event.actor && status === 'claimed' ? { claimHolder: event.actor } : {}), + ...(changed.length ? { changedEvents: mergeChangedEvents(task.changedEvents, changed) } : {}), + }; + }); + const done = tasks.filter((task) => task.status === 'verified').length; + const total = tasks.filter((task) => task.status !== 'superseded').length; + return { + ...snapshot, + tasks, + progress: { done, total }, + streamCursor: Math.max(snapshot.streamCursor, event.graphVersion), + events: mergeEvents(snapshot.events, [event]), + }; +} + +export function planIsComplete(snapshot: PlanCanvasSnapshot): boolean { + const current = snapshot.tasks.filter((task) => task.status !== 'superseded'); + return current.length > 0 && current.every((task) => task.status === 'verified'); +} + +function normalizeTask(value: unknown): PlanTask | null { + const item = record(value); + if (!item) return null; + const id = text(item.id) ?? text(item.task_id) ?? text(item.taskId); + if (!id) return null; + const queuedAffordances = array(item.queued_affordances ?? item.queuedAffordances) + .map((raw) => { + const affordance = record(raw); + const ref = text(affordance?.ref) ?? text(affordance?.affordance_ref) ?? text(affordance?.affordanceRef); + if (!ref) return null; + return { + ref, + config: affordance?.config ?? {}, + annotations: normalizeAnnotations(affordance?.annotations), + } satisfies QueuedAffordance; + }) + .filter(nonNullable); + const claim = record(item.claim); + const admission = snake(text(item.admission_requirement) ?? text(item.admissionRequirement) ?? 'admitted'); + return { + id, + alias: text(item.alias) ?? id, + title: text(item.title) ?? text(item.goal) ?? id, + description: text(item.description) ?? text(item.goal) ?? '', + kind: normalizeKind(item.kind ?? item.task_kind ?? item.taskKind), + status: normalizeStatus(item.lifecycle_status ?? item.lifecycleStatus ?? item.status), + dependencies: strings(item.dependencies ?? item.depends_on ?? item.dependsOn), + serves: strings(item.serves ?? item.criterion_ids ?? item.criterionIds), + acceptanceCriteria: strings(item.acceptance_criteria ?? item.acceptanceCriteria), + queuedAffordances, + admissionRequirement: admission === 'require_approval' ? 'require_approval' : 'admitted', + approvalReceipt: text(item.approval_receipt) ?? text(item.approvalReceipt) ?? null, + claimHolder: text(item.claim_holder) ?? text(item.claimHolder) ?? text(claim?.owner) ?? null, + generationAtStart: number(item.generation_at_start ?? item.generationAtStart), + generationAtEnd: number(item.generation_at_end ?? item.generationAtEnd), + supersedes: strings(item.supersedes), + supersededBy: strings(item.superseded_by ?? item.supersededBy), + changedEvents: changedEvents( + item.changed_events ?? item.changedEvents ?? item.changed_paths ?? item.changedPaths, + null, + ), + proofStatus: text(item.proof_status) ?? text(item.proofStatus) ?? null, + }; +} + +function normalizeCriterion(value: unknown, index: number): PlanCriterion | null { + if (typeof value === 'string') return { id: `criterion-${index + 1}`, statement: value }; + const item = record(value); + if (!item) return null; + const id = text(item.id) ?? text(item.criterion_id) ?? text(item.criterionId) ?? `criterion-${index + 1}`; + const statement = text(item.text) ?? text(item.statement); + return statement ? { id, statement } : null; +} + +function normalizeKind(value: unknown): PlanTaskKind { + const kind = snake(text(value) ?? 'regular'); + return kind === 'compact' || kind === 'review' || kind === 'verify' ? kind : 'regular'; +} + +function normalizeStatus(value: unknown): PlanTaskStatus { + switch (snake(text(value) ?? 'pending')) { + case 'working': + case 'in_progress': + case 'running': + return 'running'; + case 'claimed': + return 'claimed'; + case 'patch_proposed': + return 'patch_proposed'; + case 'verifying': + return 'verifying'; + case 'done': + case 'accepted': + case 'complete': + case 'completed': + case 'verified': + return 'verified'; + case 'failed': + case 'rejected': + return 'failed'; + case 'blocked': + return 'blocked'; + case 'superseded': + return 'superseded'; + default: + return 'pending'; + } +} + +function statusForTransition(transition: string, detail: Record): PlanTaskStatus | null { + const explicit = text(detail.status) ?? text(detail.state); + if (explicit) return normalizeStatus(explicit); + const value = snake(transition); + if (value.includes('supersed')) return 'superseded'; + if (value.includes('task_started') || value.includes('turn_started') || value.includes('in_progress')) return 'running'; + if (value.includes('claim')) return 'claimed'; + if (value.includes('patch_proposed')) return 'patch_proposed'; + if (value.includes('verify') && !value.includes('verified')) return 'verifying'; + if (value.includes('completed') || value === 'done' || value.includes('verified')) return 'verified'; + if (value.includes('failed') || value.includes('aborted')) return 'failed'; + if (value.includes('pending') || value.includes('lease_expired')) return 'pending'; + return null; +} + +function normalizeAnnotations(value: unknown, writebackPolicy?: unknown): AffordanceAnnotations { + const item = record(value); + const tokens = strings(value).map(snake); + const writeback = snake(text(writebackPolicy) ?? ''); + return { + readOnly: item?.read_only === true + || item?.readOnly === true + || tokens.includes('read_only') + || writeback === 'none' + || writeback === 'read_only', + destructive: item?.destructive === true + || tokens.includes('destructive') + || writeback === 'destructive', + }; +} + +function changedEvents(value: unknown, fallbackAt: string | null): PlanChangedEvent[] { + const root = record(value); + return array(root?.changed_events ?? root?.changedEvents ?? root?.paths ?? value) + .flatMap((raw) => { + if (typeof raw === 'string') return [{ path: raw, generation: null, at: fallbackAt }]; + const item = record(raw); + const event = record(item?.event) ?? item; + const paths = [text(event?.path), text(event?.from), text(event?.to)].filter(nonNullable); + const generation = number(item?.generation) ?? number(event?.generation); + const at = text(item?.at) ?? text(item?.created_at) ?? text(event?.at) ?? fallbackAt; + return [...new Set(paths)].map((path) => ({ path, generation, at } satisfies PlanChangedEvent)); + }) + .filter(nonNullable); +} + +function mergeChangedEvents(current: PlanChangedEvent[], incoming: PlanChangedEvent[]): PlanChangedEvent[] { + const byKey = new Map(current.map((event) => [`${event.path}:${event.generation ?? ''}`, event])); + for (const event of incoming) byKey.set(`${event.path}:${event.generation ?? ''}`, event); + return [...byKey.values()]; +} + +function mergeEvents(current: PlanCanvasEvent[], incoming: PlanCanvasEvent[]): PlanCanvasEvent[] { + const byId = new Map(current.map((event) => [event.id, event])); + for (const event of incoming) byId.set(event.id, event); + return [...byId.values()] + .sort((left, right) => left.graphVersion - right.graphVersion || left.id.localeCompare(right.id)) + .slice(-100); +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function array(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function strings(value: unknown): string[] { + return array(value).map(text).filter(nonNullable); +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function number(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function snake(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[\s-]+/g, '_').toLowerCase(); +} + +function nonNullable(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/packages/theorem-acp/src/workspace-state.ts b/packages/theorem-acp/src/workspace-state.ts new file mode 100644 index 00000000..97b0a2ad --- /dev/null +++ b/packages/theorem-acp/src/workspace-state.ts @@ -0,0 +1,253 @@ +/** Framework-free CommonPlace projection over the workspace GraphQL contract. */ + +export const WORKSPACE_SUBSTRATE_SCHEMA = 'commonplace.workspace-substrate/1' as const; + +export interface WorkspaceTreeNode { + id: string; + kind: string; + name: string; + path: string | null; + excluded: boolean; + children: WorkspaceTreeNode[]; +} + +export interface ProjectTree { + projectId: string; + generation: number; + roots: WorkspaceTreeNode[]; +} + +export interface ReadinessCapability { + capability: string; + state: string; + missing: string[]; +} + +export interface WorkspaceReadiness { + generation: number; + capabilities: ReadinessCapability[]; +} + +export interface FileRevision { + generation: number; + hash: string; + label: string | null; + timestampMs: number; + content: string | null; +} + +export interface FileHistory { + path: string; + revisions: FileRevision[]; +} + +export interface ProjectImportReceipt { + projectId: string; + rootId: string; + rootPath: string; + generation: number; +} + +export interface WorkspaceSearchHit { + item: { + id: string; + kind: string; + title: string; + path: string | null; + }; + score: number; + originalScore: number; + insideProject: boolean | null; + degraded: boolean; + missingIndexes: string[]; +} + +export interface WorkspaceSurfaceSnapshot { + schema: typeof WORKSPACE_SUBSTRATE_SCHEMA; + tree: ProjectTree; + readiness: WorkspaceReadiness; +} + +export interface WorkspaceTreeProjection { + rows: WorkspaceTreeRow[]; + nodeById: ReadonlyMap; +} + +export interface WorkspaceTreeRow { + id: string; + node: WorkspaceTreeNode; + depth: number; + expandable: boolean; +} + +export type WorkspaceGraphqlClientOptions = { + endpoint?: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}; + +const NODE_FIELDS = ` + id kind name path excluded + children { + id kind name path excluded + children { + id kind name path excluded + children { + id kind name path excluded + children { id kind name path excluded } + } + } + } +`; + +export async function fetchWorkspaceSurface( + projectId: string, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ projectTree: ProjectTree; readiness: WorkspaceReadiness }>( + `query WorkspaceSurface($projectId: String!) { + projectTree(projectId: $projectId) { projectId generation roots { ${NODE_FIELDS} } } + readiness { generation capabilities { capability state missing } } + }`, + { projectId }, + options, + ); + return { schema: WORKSPACE_SUBSTRATE_SCHEMA, tree: data.projectTree, readiness: data.readiness }; +} + +export async function fetchWorkspaceReadiness( + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ readiness: WorkspaceReadiness }>( + 'query WorkspaceReadiness { readiness { generation capabilities { capability state missing } } }', + {}, + options, + ); + return data.readiness; +} + +export async function findInWorkspaceProject( + query: string, + projectId: string, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ search: WorkspaceSearchHit[] }>( + `query WorkspaceProjectFind($query: String!, $projectId: String!) { + search(query: $query, k: 12, projectId: $projectId) { + item { id kind title path } + score originalScore insideProject degraded missingIndexes + } + }`, + { query, projectId }, + options, + ); + return data.search; +} + +export async function fetchFileHistory( + path: string, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ fileHistory: FileHistory }>( + `query WorkspaceFileHistory($path: String!) { + fileHistory(path: $path) { path revisions { generation hash label timestampMs content } } + }`, + { path }, + options, + ); + return data.fileHistory; +} + +export async function createWorkspaceProject( + name: string, + rootPath: string, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ createProject: ProjectImportReceipt }>( + `mutation CreateWorkspaceProject($name: String!, $rootPath: String!) { + createProject(name: $name, rootPath: $rootPath) { projectId rootId rootPath generation } + }`, + { name, rootPath }, + options, + ); + return data.createProject; +} + +export async function addWorkspaceContentRoot( + projectId: string, + rootPath: string, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ addContentRoot: ProjectImportReceipt }>( + `mutation AddWorkspaceContentRoot($projectId: String!, $rootPath: String!) { + addContentRoot(projectId: $projectId, rootPath: $rootPath) { projectId rootId rootPath generation } + }`, + { projectId, rootPath }, + options, + ); + return data.addContentRoot; +} + +export async function restoreWorkspaceRevision( + path: string, + generation: number, + options: WorkspaceGraphqlClientOptions = {}, +): Promise { + const data = await graphql<{ restoreRevision: FileHistory }>( + `mutation RestoreWorkspaceRevision($path: String!, $generation: Int!) { + restoreRevision(path: $path, generation: $generation) { + path revisions { generation hash label timestampMs content } + } + }`, + { path, generation }, + options, + ); + return data.restoreRevision; +} + +export function workspaceTreeRows( + tree: ProjectTree, + expanded: ReadonlySet, +): WorkspaceTreeProjection { + const rows: WorkspaceTreeRow[] = []; + const nodeById = new Map(); + const visit = (node: WorkspaceTreeNode, depth: number) => { + nodeById.set(node.id, node); + rows.push({ id: node.id, node, depth, expandable: node.children.length > 0 }); + if (node.children.length > 0 && expanded.has(node.id)) { + node.children.forEach((child) => visit(child, depth + 1)); + } + }; + tree.roots.forEach((root) => visit(root, 1)); + return { rows, nodeById }; +} + +export function readinessIsBuilding(readiness: WorkspaceReadiness | null): boolean { + if (!readiness) return true; + return readiness.capabilities.some( + (item) => item.state.toLowerCase() !== 'ready' || item.missing.length > 0, + ); +} + +async function graphql( + query: string, + variables: Record, + options: WorkspaceGraphqlClientOptions, +): Promise { + const response = await (options.fetchImpl ?? fetch)(options.endpoint ?? '/api/workspace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + cache: 'no-store', + signal: options.signal, + }); + const body = (await response.json().catch(() => null)) as { + data?: T; + errors?: Array<{ message?: string }>; + } | null; + const error = body?.errors?.map((item) => item.message).filter(Boolean).join('; '); + if (!response.ok || !body?.data || error) { + throw new Error(error || `Workspace GraphQL request failed with ${response.status}.`); + } + return body.data; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26024738..ea4fbcaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,9 +94,15 @@ importers: '@dagrejs/graphlib': specifier: ^4.0.1 version: 4.0.1 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@lezer/highlight': specifier: ^1.2.1 version: 1.2.3 + '@radix-ui/react-popover': + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -15318,9 +15324,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': {}