diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb2297f73..3b3d8df3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,6 +91,10 @@ jobs: working-directory: apps/web run: pnpm test + - name: Run v2 unit tests + working-directory: apps/v2 + run: pnpm test + - name: Type-check vscode extension working-directory: apps/vscode run: pnpm check-types diff --git a/.gitignore b/.gitignore index f552e10c2..ad05dd52f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,10 +43,13 @@ apps/vscode/out/ *.vsix *.tsbuildinfo test-results/ +playwright-report/ # Dashboard build output (copied into codev for publishing) packages/codev/dashboard-dist/ apps/web/dist/ +packages/codev/v2-dist/ +apps/v2/dist/ # three.js vendored at build time from the `three` devDependency (copy-three.mjs); # copied into templates/vendor/ and shipped in the npm tarball, not committed. diff --git a/apps/v2/__tests__/SiteView.test.tsx b/apps/v2/__tests__/SiteView.test.tsx new file mode 100644 index 000000000..75cd9acc3 --- /dev/null +++ b/apps/v2/__tests__/SiteView.test.tsx @@ -0,0 +1,221 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Page } from '../src/App.js'; +import { initialAppState, type AppState } from '../src/lib/stream.js'; +import type { ClientNode } from '../src/lib/validate.js'; + +function node(over: Partial & Pick): ClientNode { + return { + parentId: null, + name: over.id, + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + buckets: Array.from({ length: 20 }, () => 0), + ...over, + }; +} + +function liveState(): AppState { + const s = initialAppState(); + s.connection = 'live'; + s.bootstrap = 'scoped'; + s.reducer.nodes = new Map([ + ['workspace:/a', node({ id: 'workspace:/a', kind: 'workspace', name: 'alpha' })], + ['architect:1', node({ id: 'architect:1', kind: 'architect', parentId: 'workspace:/a', name: 'arch' })], + [ + 'builder:1', + node({ id: 'builder:1', kind: 'builder', parentId: 'workspace:/a', name: 'b1', status: 'running' }), + ], + [ + 'builder:2', + node({ + id: 'builder:2', + kind: 'builder', + parentId: 'workspace:/a', + name: 'b2', + status: 'gate-waiting', + }), + ], + ]); + s.reducer.counts = { workspaces: 22, builders: { total: 58, byStatus: { running: 10 } }, gateWaiting: 3 }; + return s; +} + +describe('SiteView display (scenarios 6, 7, 21)', () => { + afterEach(() => cleanup()); + it('renders unknown status as the raw string, not RUN', () => { + const s = liveState(); + s.reducer.nodes.set( + 'builder:x', + node({ id: 'builder:x', kind: 'builder', parentId: 'workspace:/a', name: 'bx', status: 'reticulating' }), + ); + render(); + expect(screen.getByText('reticulating')).toBeTruthy(); + expect(screen.queryByText('reticulating')?.className).toContain('stamp-unknown'); + }); + + it('keeps a live sibling when one workspace is dark', () => { + const s = liveState(); + s.reducer.darkPaths.set('workspace:/gone', { reason: 'unreadable', at: 't0' }); + render(); + expect(screen.getByText('alpha')).toBeTruthy(); + expect(screen.getByText('gone')).toBeTruthy(); + expect(screen.getByText(/unreadable/)).toBeTruthy(); + const dark = document.querySelector('[data-dark="true"]'); + expect(dark?.className).toContain('dim-sub'); + expect(document.querySelector('[data-id="workspace:/a"]')?.className).not.toContain('dim-sub'); + }); + + it('nodes: [] is the empty-site copy, not the unreachable banner', () => { + const s = initialAppState(); + s.connection = 'live'; + s.bootstrap = 'empty'; + render(); + expect(screen.getByTestId('empty-site').textContent).toMatch(/No workspaces/); + expect(screen.queryByTestId('unreachable')).toBeNull(); + }); + + it('unreachable is a connection banner and not the empty-site copy', () => { + const s = initialAppState(); + s.connection = 'unreachable'; + s.connectionWhy = 'transport'; + render(); + expect(screen.getByTestId('unreachable').textContent).toMatch(/Cannot reach Tower/); + expect(screen.queryByTestId('empty-site')).toBeNull(); + }); + + it('http mismatch is the mismatch page, not an empty tree', () => { + const s = liveState(); + s.httpMismatch = { status: 400 }; + render(); + expect(screen.getByTestId('mismatch').textContent).toMatch(/HTTP 400/); + expect(screen.queryByTestId('empty-site')).toBeNull(); + expect(screen.queryByText('alpha')).toBeNull(); + }); + + it('nodes: [] plus one dark is a dark plot from the id (scenario 21)', () => { + const s = initialAppState(); + s.connection = 'live'; + s.bootstrap = 'scoped'; + s.reducer.darkPaths.set('workspace:/tmp/gone', { reason: 'unknown', at: 't1' }); + render(); + expect(screen.queryByTestId('empty-site')).toBeNull(); + expect(screen.getByText('gone')).toBeTruthy(); + expect(screen.getByText(/unknown/)).toBeTruthy(); + }); + + it('puts counts in the footer as machine totals, not a tree rollup', () => { + render(); + const foot = screen.getByTestId('machine-totals'); + expect(foot.textContent).toMatch(/Machine totals/); + expect(foot.textContent).toMatch(/22 workspaces/); + expect(foot.textContent).toMatch(/58 builders/); + expect(foot.textContent).not.toMatch(/drawn|this tree|shown/i); + }); + + it('sits the builder under the workspace beside the architect', () => { + const { container } = render(); + const ws = container.querySelector('[data-kind="workspace"]'); + const kinds = [...(ws?.querySelectorAll('[data-kind]') ?? [])].map((el) => el.getAttribute('data-kind')); + expect(kinds).toContain('architect'); + expect(kinds).toContain('builder'); + const arch = ws?.querySelector('[data-kind="architect"]'); + expect(arch?.querySelector('[data-kind="builder"]')).toBeNull(); + }); + + it('nests an architect-parented builder inside that architect', () => { + const s = liveState(); + s.reducer.nodes.set( + 'builder:nested', + node({ id: 'builder:nested', kind: 'builder', parentId: 'architect:1', name: 'nested' }), + ); + const { container } = render(); + const arch = container.querySelector('[data-kind="architect"][data-id="architect:1"]'); + expect(arch?.querySelector('[data-id="builder:nested"]')).toBeTruthy(); + const wsLevel = container.querySelector('[data-kind="workspace"] > .stake-list'); + expect(wsLevel?.querySelector('[data-id="builder:nested"]')).toBeNull(); + }); + + it('renders an unresolvable parent at machine level, labelled', () => { + const s = liveState(); + s.reducer.nodes.set( + 'builder:lost', + node({ id: 'builder:lost', kind: 'builder', parentId: 'workspace:/missing', name: 'lost' }), + ); + render(); + const box = screen.getByTestId('unattached'); + expect(box.textContent).toMatch(/parent not in tree/); + expect(box.querySelector('[data-id="builder:lost"]')).toBeTruthy(); + expect(document.querySelector('[data-id="workspace:/a"] [data-id="builder:lost"]')).toBeNull(); + }); + + it('renders workspace name and status as separately readable text', () => { + const { container } = render(); + const header = container.querySelector('[data-id="workspace:/a"] .ws-plot-name'); + expect(header?.querySelector('.ws-plot-label')?.textContent).toBe('alpha'); + expect(header?.querySelector('.stamp-run')?.textContent).toBe('RUN'); + expect(header?.querySelector('.ws-plot-label')?.textContent).not.toContain('RUN'); + }); + + it('renders workspace held mail beside name and status, not concatenated', () => { + const s = liveState(); + s.reducer.nodes.set( + 'workspace:/a', + node({ id: 'workspace:/a', kind: 'workspace', name: 'alpha', flags: { heldMail: true } }), + ); + const { container } = render(); + const header = container.querySelector('[data-id="workspace:/a"] .ws-plot-name'); + expect(header?.querySelector('.ws-plot-label')?.textContent).toBe('alpha'); + expect(header?.querySelector('.held-mail')?.textContent).toBe('mail'); + expect(header?.querySelector('.stamp-run')?.textContent).toBe('RUN'); + expect(header?.querySelector('.ws-plot-label')?.textContent).not.toMatch(/mail|RUN/); + }); + + it('dims an offline workspace and shows architect heldMail plus status', () => { + const s = liveState(); + s.reducer.nodes.set( + 'workspace:/a', + node({ id: 'workspace:/a', kind: 'workspace', name: 'alpha', status: 'offline' }), + ); + s.reducer.nodes.set( + 'architect:1', + node({ + id: 'architect:1', + kind: 'architect', + parentId: 'workspace:/a', + name: 'arch', + status: 'offline', + flags: { heldMail: true }, + }), + ); + const { container } = render(); + expect(container.querySelector('[data-id="workspace:/a"]')?.className).toContain('dim-sub'); + expect(container.querySelector('[data-kind="architect"]')?.className).toContain('dim-sub'); + expect(container.querySelector('[data-kind="architect"] .held-mail')).toBeTruthy(); + expect(container.querySelector('[data-kind="architect"] .stamp-offline')).toBeTruthy(); + }); + + it('keeps machine totals on an empty snapshot that still has counts', () => { + const s = initialAppState(); + s.connection = 'live'; + s.bootstrap = 'scoped'; + s.reducer.counts = { workspaces: 22, builders: { total: 58, byStatus: {} }, gateWaiting: 3 }; + render(); + expect(screen.getByTestId('empty-site')).toBeTruthy(); + expect(screen.getByTestId('machine-totals').textContent).toMatch(/Machine totals/); + expect(screen.getByTestId('machine-totals').textContent).toMatch(/22 workspaces/); + }); + + it('uses rust only on gate-waiting treatment', () => { + const { container } = render(); + const rust = [...container.querySelectorAll('.stamp-gate, .needs-attn')]; + expect(rust.length).toBeGreaterThan(0); + expect(container.querySelector('.stamp-gate')?.textContent).toBe('GATE'); + expect(container.querySelectorAll('.needs-attn')).toHaveLength(1); + expect(container.querySelector('.machine-footer')?.className).not.toContain('stamp-gate'); + expect(container.innerHTML).not.toMatch(/#gate-rail|Find node|Add machine|#terminal-bank/); + expect(container.querySelector('#gate-rail')).toBeNull(); + expect(container.querySelector('#terminal-bank')).toBeNull(); + }); +}); diff --git a/apps/v2/__tests__/Sparkline.test.tsx b/apps/v2/__tests__/Sparkline.test.tsx new file mode 100644 index 000000000..3e5792ac3 --- /dev/null +++ b/apps/v2/__tests__/Sparkline.test.tsx @@ -0,0 +1,16 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Sparkline } from '../src/components/Sparkline.js'; + +describe('Sparkline', () => { + afterEach(() => cleanup()); + it('renders 20 bars', () => { + const { container } = render( i)} />); + expect(container.querySelectorAll('.spark i')).toHaveLength(20); + }); + + it('renders a flat trace when values are missing', () => { + const { container } = render(); + expect(container.querySelectorAll('.spark i')).toHaveLength(20); + }); +}); diff --git a/apps/v2/__tests__/StatusStamp.test.tsx b/apps/v2/__tests__/StatusStamp.test.tsx new file mode 100644 index 000000000..454908ea4 --- /dev/null +++ b/apps/v2/__tests__/StatusStamp.test.tsx @@ -0,0 +1,28 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StatusStamp } from '../src/components/StatusStamp.js'; + +describe('StatusStamp (scenario 6)', () => { + afterEach(() => cleanup()); + it('renders GATE in rust and not RUN', () => { + const { container } = render(); + expect(screen.getByText('GATE')).toBeTruthy(); + expect(container.querySelector('.stamp-gate')).toBeTruthy(); + expect(container.querySelector('.stamp-run')).toBeNull(); + }); + + it('renders STALLED in ochre', () => { + const { container } = render(); + expect(screen.getByText('STALLED')).toBeTruthy(); + expect(container.querySelector('.stamp-stalled')).toBeTruthy(); + }); + + it('renders an unknown status as the raw string, not RUN', () => { + const { container } = render(); + expect(screen.getByText('reticulating')).toBeTruthy(); + expect(container.querySelector('.stamp-run')).toBeNull(); + expect(container.querySelector('.stamp-gate')).toBeNull(); + expect(container.querySelector('.stamp-stalled')).toBeNull(); + expect(container.querySelector('.stamp-unknown')).toBeTruthy(); + }); +}); diff --git a/apps/v2/__tests__/bootstrap.test.ts b/apps/v2/__tests__/bootstrap.test.ts new file mode 100644 index 000000000..7f2dbf3ff --- /dev/null +++ b/apps/v2/__tests__/bootstrap.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { + fetchWorkspacesOnce, + parseWorkspacesBody, + runBootstrap, + type BootstrapEnd, + type BootstrapOnce, +} from '../src/lib/bootstrap.js'; + +function jsonRes(status: number, body: unknown): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function clock() { + const queue: Array<{ ms: number; cb: () => void }> = []; + return { + backoff(ms: number, cb: () => void) { + queue.push({ ms, cb }); + return queue.length; + }, + flush() { + const next = queue.shift(); + next?.cb(); + }, + get pending() { + return queue.map((q) => q.ms); + }, + }; +} + +async function pump(pred: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (pred()) return; + await Promise.resolve(); + } + throw new Error('pump exhausted'); +} + +describe('parseWorkspacesBody', () => { + it('200 with workspaces yields scoped paths', () => { + const r = parseWorkspacesBody(JSON.stringify({ + workspaces: [{ path: '/a', name: 'a', active: true, proxyUrl: null, terminals: 0 }], + })); + expect(r).toEqual({ kind: 'scoped', paths: ['/a'] }); + }); + + it('200 with [] is empty (scenario 14 / 17)', () => { + expect(parseWorkspacesBody(JSON.stringify({ workspaces: [] }))).toEqual({ kind: 'empty' }); + }); + + it.each([ + ['not-json', 'invalid-json'], + ['{}', 'bad-body'], + [JSON.stringify({ workspaces: null }), 'bad-body'], + [JSON.stringify({ workspaces: 'nope' }), 'bad-body'], + [JSON.stringify({ workspaces: [{}] }), 'bad-body'], + [JSON.stringify({ workspaces: [{ path: 42 }] }), 'bad-body'], + [JSON.stringify({ workspaces: [{ path: '' }] }), 'bad-body'], + ] as const)('unreadable body %s is mismatch (scenarios 26 / 32)', (body, how) => { + const r = parseWorkspacesBody(body); + expect(r.kind).toBe('mismatch'); + if (r.kind === 'mismatch') expect(r.mismatch.how).toBe(how); + }); +}); + +describe('fetchWorkspacesOnce (scenario 17)', () => { + it('401 is unreachable auth', async () => { + const r = await fetchWorkspacesOnce(async () => jsonRes(401, {}), 'k'); + expect(r).toEqual({ kind: 'unreachable', why: 'auth' }); + }); + + it('500 is unreachable transport', async () => { + const r = await fetchWorkspacesOnce(async () => jsonRes(500, {}), 'k'); + expect(r).toEqual({ kind: 'unreachable', why: 'transport' }); + }); + + it('thrown fetch is unreachable transport', async () => { + const r = await fetchWorkspacesOnce(async () => { + throw new TypeError('network'); + }, 'k'); + expect(r).toEqual({ kind: 'unreachable', why: 'transport' }); + }); + + it('200 + [] is empty', async () => { + const r = await fetchWorkspacesOnce(async () => jsonRes(200, { workspaces: [] }), 'k'); + expect(r).toEqual({ kind: 'empty' }); + }); + + it('200 whose body cannot be read is mismatch, not unreachable', async () => { + const r = await fetchWorkspacesOnce(async () => { + return { + status: 200, + text: async () => { + throw new TypeError('failed to read body'); + }, + } as Response; + }, 'k'); + expect(r.kind).toBe('mismatch'); + }); +}); + +describe('runBootstrap retry policy (scenarios 25, 26, 32, 38)', () => { + it('500 then 200 is two requests and scoped (scenario 25 / 38)', async () => { + const urls: string[] = []; + let n = 0; + const c = clock(); + const pending = runBootstrap({ + fetch: async (input) => { + urls.push(String(input)); + n += 1; + if (n === 1) return jsonRes(500, {}); + return jsonRes(200, { workspaces: [{ path: '/a' }] }); + }, + key: 'k', + reconnectBackoff: c.backoff, + }); + await pump(() => c.pending.length > 0); + expect(n).toBe(1); + expect(c.pending).toEqual([1000]); + c.flush(); + const end = await pending; + expect(end).toEqual({ kind: 'scoped', paths: ['/a'] }); + expect(n).toBe(2); + expect(urls).toEqual(['/api/workspaces', '/api/workspaces']); + }); + + it('unreadable 200 retries once then stops in mismatch (scenario 26 / 38)', async () => { + let n = 0; + const c = clock(); + const seen: BootstrapOnce['kind'][] = []; + const pending = runBootstrap({ + fetch: async () => { + n += 1; + return jsonRes(200, {}); + }, + key: 'k', + reconnectBackoff: c.backoff, + onMismatch: () => seen.push('mismatch'), + onUnreachable: () => seen.push('unreachable'), + }); + await pump(() => seen.length > 0); + expect(n).toBe(1); + expect(seen).toEqual(['mismatch']); + c.flush(); + const end: BootstrapEnd = await pending; + expect(end.kind).toBe('mismatch'); + expect(n).toBe(2); + expect(seen).toEqual(['mismatch', 'mismatch']); + expect(c.pending).toEqual([]); + }); + + it.each([ + 'not-json', + '{}', + JSON.stringify({ workspaces: null }), + JSON.stringify({ workspaces: 'nope' }), + JSON.stringify({ workspaces: [{}] }), + JSON.stringify({ workspaces: [{ path: 42 }] }), + JSON.stringify({ workspaces: [{ path: '' }] }), + ])('body %s is never empty and never unreachable', async (body) => { + let n = 0; + const c = clock(); + let unreachable = 0; + const pending = runBootstrap({ + fetch: async () => { + n += 1; + return new Response(body, { status: 200 }); + }, + key: 'k', + reconnectBackoff: c.backoff, + onUnreachable: () => { + unreachable += 1; + }, + }); + await pump(() => c.pending.length > 0); + c.flush(); + const end = await pending; + expect(end.kind).toBe('mismatch'); + expect(n).toBe(2); + expect(unreachable).toBe(0); + }); + + it('500 retries on backoff indefinitely until a 200 (scenario 38)', async () => { + let n = 0; + const c = clock(); + const pending = runBootstrap({ + fetch: async () => { + n += 1; + if (n < 3) return jsonRes(500, {}); + return jsonRes(200, { workspaces: [{ path: '/z' }] }); + }, + key: 'k', + reconnectBackoff: c.backoff, + }); + await pump(() => c.pending.length > 0); + expect(c.pending).toEqual([1000]); + c.flush(); + await pump(() => c.pending.length > 0); + expect(c.pending).toEqual([2000]); + c.flush(); + const end = await pending; + expect(end).toEqual({ kind: 'scoped', paths: ['/z'] }); + expect(n).toBe(3); + }); +}); diff --git a/apps/v2/__tests__/encode-scope.test.ts b/apps/v2/__tests__/encode-scope.test.ts new file mode 100644 index 000000000..c2c013da9 --- /dev/null +++ b/apps/v2/__tests__/encode-scope.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { encodeScope } from '../src/lib/encode-scope.js'; + +describe('encodeScope (scenario 18)', () => { + it('joins per-path encodings with a literal comma', () => { + const got = encodeScope(['/a,b', '/c']); + expect(got).toBe(`${encodeURIComponent('/a,b')},${encodeURIComponent('/c')}`); + expect(got).not.toBe(encodeURIComponent(['/a,b', '/c'].join(','))); + expect(got).toBe('%2Fa%2Cb,%2Fc'); + expect(encodeURIComponent(['/a,b', '/c'].join(','))).toBe('%2Fa%2Cb%2C%2Fc'); + }); + + it('round-trips through parseScope split-then-decode', () => { + const paths = ['/tmp/ws-a', '/tmp/ws-b']; + const encoded = encodeScope(paths); + const decoded = encoded.split(',').map((p) => decodeURIComponent(p)); + expect(decoded).toEqual(paths); + const wrong = encodeURIComponent(paths.join(',')); + expect(wrong.split(',').map((p) => decodeURIComponent(p))).toEqual([paths.join(',')]); + }); +}); diff --git a/apps/v2/__tests__/no-polling.test.ts b/apps/v2/__tests__/no-polling.test.ts new file mode 100644 index 000000000..9e7752b48 --- /dev/null +++ b/apps/v2/__tests__/no-polling.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src'); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const p = path.join(dir, name); + if (statSync(p).isDirectory()) out.push(...walk(p)); + else if (/\.[cm]?tsx?$/.test(p)) out.push(p); + } + return out; +} + +describe('no polling (scenario 9)', () => { + it('has zero setInterval and one setTimeout at reconnectBackoff', () => { + const files = walk(srcRoot); + const intervalHits: string[] = []; + const timeoutHits: string[] = []; + for (const f of files) { + const text = readFileSync(f, 'utf8'); + if (/\bsetInterval\b/.test(text)) intervalHits.push(f); + if (/\bsetTimeout\b/.test(text)) timeoutHits.push(f); + } + expect(intervalHits).toEqual([]); + expect(timeoutHits).toHaveLength(1); + expect(timeoutHits[0].endsWith(`${path.sep}stream.ts`)).toBe(true); + const text = readFileSync(timeoutHits[0], 'utf8'); + expect(text).toMatch(/function reconnectBackoff\(/); + expect(text).toMatch(/return setTimeout\(cb, ms\)/); + }); +}); diff --git a/apps/v2/__tests__/reducer.test.ts b/apps/v2/__tests__/reducer.test.ts new file mode 100644 index 000000000..e0961a28c --- /dev/null +++ b/apps/v2/__tests__/reducer.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; +import { + applyFrame, + applyUnknown, + initialReducerState, + serialise, + type ReducerState, +} from '../src/lib/reducer.js'; +import { TRACE_LEN, type ClientNode } from '../src/lib/validate.js'; + +const COUNTS = { workspaces: 22, builders: { total: 58, byStatus: { running: 10 } }, gateWaiting: 3 }; + +function ws(id: string, name = id): ClientNode { + return { id, kind: 'workspace', parentId: null, name, status: 'running', flags: { heldMail: false }, lastDataAt: null }; +} +function arch(id: string, parentId: string): ClientNode { + return { id, kind: 'architect', parentId, name: id, status: 'running', flags: { heldMail: false }, lastDataAt: null }; +} +function bld(id: string, parentId: string, extra: Partial = {}): ClientNode { + return { id, kind: 'builder', parentId, name: id, status: 'running', flags: { heldMail: false }, lastDataAt: null, ...extra }; +} + +function snap(over: Record = {}) { + return JSON.stringify({ + seq: 0, + type: 'snapshot', + streamId: 's1', + resumed: false, + nodes: [ws('workspace:/a', 'a'), arch('architect:/a#main', 'workspace:/a'), bld('builder:/a#one', 'workspace:/a', { buckets: Array(TRACE_LEN).fill(1) })], + counts: COUNTS, + ...over, + }); +} + +function run(raws: string[], start = initialReducerState()): ReducerState { + let s = start; + for (const r of raws) s = applyFrame(s, r).state; + return s; +} + +describe('D1 table (scenario 1)', () => { + it('snapshot replaces nodes and stores counts', () => { + const s = run([snap()]); + expect(s.nodes.size).toBe(3); + expect(s.counts).toEqual(COUNTS); + expect(s.cursor).toEqual({ streamId: 's1', seq: 0 }); + expect(s.darkPaths.size).toBe(0); + }); + + it('resumed does not replace the map (scenario 4)', () => { + const s0 = run([snap()]); + const before = serialise(s0); + const s1 = applyFrame(s0, JSON.stringify({ seq: 1, type: 'resumed', from: 0 })).state; + expect(s1.nodes.size).toBe(3); + expect(s1.cursor.seq).toBe(1); + expect(serialise(s1).nodes).toEqual(before.nodes); + }); + + it('node upserts by id', () => { + const s = run([snap(), JSON.stringify({ + seq: 1, type: 'node', + node: bld('builder:/a#two', 'workspace:/a', { status: 'running' }), + })]); + expect(s.nodes.has('builder:/a#two')).toBe(true); + expect(s.nodes.get('builder:/a#two')?.buckets).toEqual(Array(TRACE_LEN).fill(0)); + }); + + it('gone deletes by id', () => { + const s = run([snap(), JSON.stringify({ seq: 1, type: 'gone', id: 'builder:/a#one' })]); + expect(s.nodes.has('builder:/a#one')).toBe(false); + }); + + it('counts replaces Counts', () => { + const next = { workspaces: 1, builders: { total: 1, byStatus: { stalled: 1 } }, gateWaiting: 0 }; + const s = run([snap(), JSON.stringify({ seq: 1, type: 'counts', counts: next })]); + expect(s.counts).toEqual(next); + }); + + it('tick advances traces', () => { + const s = run([snap(), JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: { 'builder:/a#one': 9 } })]); + const t = s.nodes.get('builder:/a#one')?.buckets ?? []; + expect(t).toHaveLength(TRACE_LEN); + expect(t[TRACE_LEN - 1]).toBe(9); + expect(t[0]).toBe(1); + }); + + it('dark marks a workspace path', () => { + const s = run([snap(), JSON.stringify({ seq: 1, type: 'dark', id: 'workspace:/gone', reason: 'unknown' })]); + expect(s.darkPaths.get('workspace:/gone')?.reason).toBe('unknown'); + expect(s.nodes.size).toBe(3); + }); +}); + +describe('tick absence means zero (scenario 2)', () => { + it('omitted builder appends 0 not a repeat', () => { + const s = run([snap(), JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: {} })]); + const t = s.nodes.get('builder:/a#one')?.buckets ?? []; + expect(t[TRACE_LEN - 1]).toBe(0); + expect(t[TRACE_LEN - 2]).toBe(1); + }); +}); + +describe('node upsert buckets (scenarios 3, 39)', () => { + it('existing builder keeps its trace', () => { + const s0 = run([snap(), JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: { 'builder:/a#one': 7 } })]); + const before = s0.nodes.get('builder:/a#one')?.buckets; + const s1 = applyFrame(s0, JSON.stringify({ + seq: 2, type: 'node', + node: bld('builder:/a#one', 'workspace:/a', { status: 'stalled' }), + })).state; + expect(s1.nodes.get('builder:/a#one')?.buckets).toEqual(before); + expect(s1.nodes.get('builder:/a#one')?.status).toBe('stalled'); + }); + + it('new builder with absent buckets gets 20 zeros', () => { + const s = run([snap(), JSON.stringify({ + seq: 1, type: 'node', + node: { id: 'builder:/a#new', kind: 'builder', parentId: 'workspace:/a', name: 'new', status: 'running', flags: { heldMail: false }, lastDataAt: null }, + })]); + expect(s.nodes.get('builder:/a#new')?.buckets).toEqual(Array(TRACE_LEN).fill(0)); + }); +}); + +describe('resume refused (scenario 5)', () => { + it('snapshot with resumed false replaces the map', () => { + const s = run([ + snap(), + JSON.stringify({ + seq: 0, type: 'snapshot', streamId: 's2', resumed: false, + nodes: [ws('workspace:/b', 'b')], + counts: { workspaces: 1, builders: { total: 0, byStatus: {} }, gateWaiting: 0 }, + }), + ]); + expect([...s.nodes.keys()]).toEqual(['workspace:/b']); + expect(s.cursor.streamId).toBe('s2'); + }); +}); + +describe('unknown status is stored as-is (scenario 6, 31)', () => { + it('does not rewrite to running and is not mismatch', () => { + const s = run([snap(), JSON.stringify({ + seq: 1, type: 'node', + node: bld('builder:/a#one', 'workspace:/a', { status: 'reticulating' }), + })]); + expect(s.mismatch).toBeNull(); + expect(s.nodes.get('builder:/a#one')?.status).toBe('reticulating'); + }); +}); + +describe('two reducers converge (scenario 8)', () => { + it('50 frames into two instances match', () => { + const frames = [snap()]; + for (let i = 1; i <= 49; i++) { + frames.push(JSON.stringify({ + seq: i, type: 'tick', at: String(i), + buckets: i % 2 === 0 ? {} : { 'builder:/a#one': i }, + })); + } + const a = run(frames); + const b = run(frames); + expect(serialise(a)).toEqual(serialise(b)); + }); +}); + +describe('cursor advances on deltas (scenario 19)', () => { + it('reconnect would use the last delta seq', () => { + const frames = [snap()]; + for (let i = 1; i <= 5; i++) { + frames.push(JSON.stringify({ + seq: i, type: 'node', + node: bld('builder:/a#one', 'workspace:/a', { status: 'running' }), + })); + } + const s = run(frames); + expect(s.cursor.seq).toBe(5); + }); +}); + +describe('degenerate frames (scenario 20)', () => { + it('invalid JSON is mismatch and does not advance cursor', () => { + const s0 = run([snap()]); + const r = applyFrame(s0, 'not-json'); + expect(r.effect).toBe('recover-fresh'); + expect(r.state.cursor.seq).toBe(0); + expect(r.state.mismatch?.how).toBe('invalid-json'); + }); + + it('unknown type is mismatch and does not advance cursor', () => { + const s0 = run([snap()]); + const r = applyFrame(s0, JSON.stringify({ seq: 1, type: 'nope' })); + expect(r.effect).toBe('recover-fresh'); + expect(r.state.cursor.seq).toBe(0); + expect(r.state.mismatch?.type).toBe('nope'); + }); +}); + +describe('dark store (scenarios 21, 22, 41)', () => { + it('snapshot nodes [] plus dark is a dark plot not empty', () => { + const s = run([ + JSON.stringify({ + seq: 0, type: 'snapshot', streamId: 's1', resumed: false, nodes: [], counts: COUNTS, + }), + JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/gone', reason: 'unreadable' }), + ]); + expect(s.nodes.size).toBe(0); + expect(s.darkPaths.size).toBe(1); + expect(s.darkPaths.get('workspace:/gone')?.reason).toBe('unreadable'); + }); + + it('gone does not clear a dark path (scenario 22)', () => { + const s = run([ + snap(), + JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/gone', reason: 'unknown' }), + JSON.stringify({ seq: 1, type: 'gone', id: 'workspace:/gone' }), + ]); + expect(s.darkPaths.has('workspace:/gone')).toBe(true); + expect(s.nodes.has('workspace:/gone')).toBe(false); + }); + + it('dark records the injected arrival time', () => { + const s0 = run([snap()]); + const s = applyFrame( + s0, + JSON.stringify({ seq: 1, type: 'dark', id: 'workspace:/gone', reason: 'unknown' }), + '2026-08-24T12:00:00.000Z', + ).state; + expect(s.darkPaths.get('workspace:/gone')?.at).toBe('2026-08-24T12:00:00.000Z'); + }); + + it('dark survives deltas and is cleared by a replacement snapshot', () => { + const s0 = run([ + snap(), + JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/gone', reason: 'unknown' }), + JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: {} }), + ]); + expect(s0.darkPaths.has('workspace:/gone')).toBe(true); + const s1 = applyFrame(s0, snap({ streamId: 's2' })).state; + expect(s1.darkPaths.size).toBe(0); + }); + + it('snapshot replaces darkPaths before its own dark frames (scenario 41)', () => { + let s = run([ + snap(), + JSON.stringify({ seq: 1, type: 'dark', id: 'workspace:/a', reason: 'x' }), + JSON.stringify({ seq: 1, type: 'dark', id: 'workspace:/b', reason: 'x' }), + JSON.stringify({ seq: 1, type: 'dark', id: 'workspace:/c', reason: 'x' }), + ]); + expect(s.darkPaths.size).toBe(3); + s = applyFrame(s, JSON.stringify({ + seq: 0, type: 'snapshot', streamId: 's2', resumed: false, nodes: [], counts: COUNTS, + })).state; + s = applyFrame(s, JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/a', reason: 'x' })).state; + s = applyFrame(s, JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/b', reason: 'x' })).state; + expect([...s.darkPaths.keys()].sort()).toEqual(['workspace:/a', 'workspace:/b']); + }); +}); + +describe('buckets two shapes (scenario 23)', () => { + it('node number[] and tick {} do not throw and append a zero', () => { + const s = run([snap(), JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: {} })]); + expect(s.nodes.get('builder:/a#one')?.buckets?.at(-1)).toBe(0); + }); +}); + +describe('counts from snapshot alone (scenario 24)', () => { + it('stores snapshot counts with no counts delta', () => { + const s = run([snap()]); + expect(s.counts).toEqual(COUNTS); + }); +}); + +describe('mismatch budget (scenarios 28, 37)', () => { + it('first bad frame recover-fresh; second on that state halt', () => { + const s0 = run([snap()]); + const r1 = applyFrame(s0, '@@@'); + expect(r1.effect).toBe('recover-fresh'); + const r2 = applyFrame(r1.state, '@@@'); + expect(r2.effect).toBe('halt'); + }); + + it('while in mismatch, a valid delta is ignored', () => { + const s0 = run([snap()]); + const r1 = applyFrame(s0, '@@@'); + const r2 = applyFrame(r1.state, JSON.stringify({ seq: 1, type: 'tick', at: 't', buckets: {} })); + expect(r2.effect).toBe('none'); + expect(r2.state.cursor.seq).toBe(0); + expect(r2.state.mismatch?.how).toBe('invalid-json'); + }); + + it('valid snapshot clears mismatch and resets the budget', () => { + const s0 = run([snap()]); + const r1 = applyFrame(s0, '@@@'); + const r2 = applyFrame(r1.state, snap({ streamId: 's2' })); + expect(r2.state.mismatch).toBeNull(); + expect(r2.state.mismatchAttempts).toBe(0); + const r3 = applyFrame(r2.state, '@@@'); + expect(r3.effect).toBe('recover-fresh'); + }); +}); + +describe('sequence ordering (scenarios 34, 36)', () => { + it('two frames sharing a seq are both applied', () => { + const s = run([ + snap(), + JSON.stringify({ seq: 0, type: 'dark', id: 'workspace:/x', reason: 'unknown' }), + ]); + expect(s.nodes.size).toBe(3); + expect(s.darkPaths.has('workspace:/x')).toBe(true); + }); + + it('lower seq on the same stream is terminal', () => { + const s0 = run([snap(), JSON.stringify({ seq: 5, type: 'tick', at: 't', buckets: {} })]); + const r = applyFrame(s0, JSON.stringify({ seq: 4, type: 'tick', at: 't', buckets: {} })); + expect(r.effect).toBe('recover-fresh'); + expect(r.state.mismatch?.field).toBe('seq'); + }); + + it('new streamId seq 0 after cursor 500 is accepted', () => { + let s = run([snap()]); + for (let i = 1; i <= 500; i++) { + s = applyFrame(s, JSON.stringify({ seq: i, type: 'tick', at: String(i), buckets: {} })).state; + } + expect(s.cursor.seq).toBe(500); + const r = applyFrame(s, snap({ streamId: 'fresh', seq: 0 })); + expect(r.state.mismatch).toBeNull(); + expect(r.state.cursor).toEqual({ streamId: 'fresh', seq: 0 }); + }); +}); + +describe('extra fields ignored (scenario 35)', () => { + it('node with garbage in an unread field applies', () => { + const s = run([snap(), JSON.stringify({ + seq: 1, type: 'node', + node: { ...bld('builder:/a#z', 'workspace:/a'), color: { r: 1 } }, + })]); + expect(s.mismatch).toBeNull(); + expect(s.nodes.has('builder:/a#z')).toBe(true); + }); +}); + +describe('seq via applyUnknown (scenario 33 objects)', () => { + it('rejects NaN seq', () => { + const r = applyUnknown(run([snap()]), { seq: NaN, type: 'gone', id: 'x' }); + expect(r.effect).toBe('recover-fresh'); + }); +}); diff --git a/apps/v2/__tests__/sse-reader.test.ts b/apps/v2/__tests__/sse-reader.test.ts new file mode 100644 index 000000000..0847a54bd --- /dev/null +++ b/apps/v2/__tests__/sse-reader.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { readSseData } from '../src/lib/sse-reader.js'; + +function streamOf(chunks: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(c) { + for (const ch of chunks) c.enqueue(ch); + c.close(); + }, + }); +} + +async function collect(stream: ReadableStream): Promise { + const out: string[] = []; + for await (const line of readSseData(stream)) out.push(line); + return out; +} + +const FRAME_A = { seq: 0, type: 'tick', at: 't0', buckets: {} }; +const FRAME_B = { seq: 1, type: 'tick', at: 't1', buckets: {} }; +const FRAME_C = { seq: 2, type: 'tick', at: 't2', buckets: {} }; +const FRAME_D = { seq: 3, type: 'tick', at: 't3', buckets: {} }; + +function sse(frame: unknown, nl = '\n'): string { + return `data: ${JSON.stringify(frame)}${nl}${nl}`; +} + +describe('readSseData (scenario 27)', () => { + it('reassembles one frame split across 3 chunks', async () => { + const bytes = new TextEncoder().encode(sse(FRAME_A)); + const a = Math.max(1, Math.floor(bytes.length / 3)); + const b = Math.max(a + 1, Math.floor((2 * bytes.length) / 3)); + const out = await collect(streamOf([bytes.slice(0, a), bytes.slice(a, b), bytes.slice(b)])); + expect(out).toEqual([JSON.stringify(FRAME_A)]); + }); + + it('reassembles a chunk that splits a multi-byte UTF-8 character', async () => { + const frame = { seq: 0, type: 'tick', at: 'café', buckets: {} }; + const bytes = new TextEncoder().encode(sse(frame)); + const idx = bytes.indexOf(0xc3); + expect(idx).toBeGreaterThan(0); + const out = await collect(streamOf([bytes.slice(0, idx + 1), bytes.slice(idx + 1)])); + expect(out).toEqual([JSON.stringify(frame)]); + }); + + it('yields 4 frames that arrive in one chunk', async () => { + const text = [FRAME_A, FRAME_B, FRAME_C, FRAME_D].map((f) => sse(f)).join(''); + const out = await collect(streamOf([new TextEncoder().encode(text)])); + expect(out).toEqual([FRAME_A, FRAME_B, FRAME_C, FRAME_D].map((f) => JSON.stringify(f))); + }); + + it('holds a mid-frame remainder until the rest arrives', async () => { + const full = sse(FRAME_A); + const cut = full.indexOf('"type"'); + const bytes = new TextEncoder().encode(full); + const out = await collect(streamOf([bytes.slice(0, cut), bytes.slice(cut)])); + expect(out).toEqual([JSON.stringify(FRAME_A)]); + }); + + it('accepts CRLF line endings', async () => { + const out = await collect(streamOf([new TextEncoder().encode(sse(FRAME_A, '\r\n'))])); + expect(out).toEqual([JSON.stringify(FRAME_A)]); + }); + + it('does not apply a trailing partial frame at EOF', async () => { + const complete = sse(FRAME_A); + const partial = `data: ${JSON.stringify(FRAME_B).slice(0, 12)}`; + const out = await collect(streamOf([new TextEncoder().encode(complete + partial)])); + expect(out).toEqual([JSON.stringify(FRAME_A)]); + }); +}); diff --git a/apps/v2/__tests__/stream.test.ts b/apps/v2/__tests__/stream.test.ts new file mode 100644 index 000000000..db9c28e94 --- /dev/null +++ b/apps/v2/__tests__/stream.test.ts @@ -0,0 +1,485 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TOWER_KEY_HEADER } from '@cluesmith/codev-types'; +import { connect, type Session } from '../src/lib/stream.js'; + +const KEY = 'ab'.repeat(32); +const COUNTS = { workspaces: 22, builders: { total: 58, byStatus: { running: 10 } }, gateWaiting: 3 }; + +function snap(over: Record = {}) { + return { + seq: 0, + type: 'snapshot', + streamId: 's1', + resumed: false, + nodes: [], + counts: COUNTS, + ...over, + }; +} + +function jsonRes(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function statusRes(status: number): Response { + return new Response('', { status }); +} + +function sseRes(frames: unknown[]): Response { + const text = frames + .map((f) => `data: ${typeof f === 'string' ? f : JSON.stringify(f)}\n\n`) + .join(''); + return new Response(text, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); +} + +function hangingSse(frames: unknown[]): { response: Response; close: () => void } { + const enc = new TextEncoder(); + let ctrl: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(c) { + ctrl = c; + for (const f of frames) ctrl.enqueue(enc.encode(`data: ${JSON.stringify(f)}\n\n`)); + }, + }); + return { + response: new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }), + close: () => ctrl.close(), + }; +} + +type FetchFn = typeof globalThis.fetch; + +function recordFetch(impl: (url: URL, init?: RequestInit) => Response | Promise): { + fetch: FetchFn; + urls: string[]; + inits: Array; +} { + const urls: string[] = []; + const inits: Array = []; + const fetchFn: FetchFn = async (input, init) => { + const url = new URL(String(input), 'http://localhost'); + urls.push(url.pathname + url.search); + inits.push(init); + return impl(url, init); + }; + return { fetch: fetchFn, urls, inits }; +} + +const sessions: Session[] = []; + +function start(fetch: FetchFn): Session { + const s = connect({ fetch, getKey: () => KEY }); + sessions.push(s); + return s; +} + +async function settle(session: Session): Promise { + for (let i = 0; i < 40; i++) await Promise.resolve(); + void session; +} + +afterEach(() => { + while (sessions.length) sessions.pop()?.stop(); +}); + +describe('bootstrap then stream (scenarios 13, 14, 17)', () => { + it('requests /api/workspaces once; reconnect does not re-request (scenario 13)', async () => { + vi.useFakeTimers(); + try { + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return sseRes([snap()]); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().bootstrap).toBe('scoped'); + expect(s.getState().reducer.cursor.streamId).toBe('s1'); + expect(s.getState().connection).toBe('reconnecting'); + expect(urls.filter((u) => u.startsWith('/api/workspaces'))).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(urls.filter((u) => u.startsWith('/api/workspaces'))).toHaveLength(1); + expect(urls.filter((u) => u.startsWith('/v2/events')).length).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + + it('200 + [] is empty and never opens the stream (scenario 14)', async () => { + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [] }); + throw new Error('stream opened'); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().bootstrap).toBe('empty'); + expect(s.getState().connection).toBe('live'); + expect(s.getState().connection).not.toBe('unreachable'); + expect(urls.some((u) => u.startsWith('/v2/events'))).toBe(false); + }); + + it('401 bootstrap is unreachable, not empty (scenario 17)', async () => { + vi.useFakeTimers(); + try { + const { fetch, urls } = recordFetch(() => statusRes(401)); + const s = start(fetch); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + expect(s.getState().connectionWhy).toBe('auth'); + expect(s.getState().bootstrap).not.toBe('empty'); + expect(urls.some((u) => u.startsWith('/v2/events'))).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('thrown bootstrap fetch is unreachable (scenario 17)', async () => { + vi.useFakeTimers(); + try { + const s = start(async () => { + throw new TypeError('offline'); + }); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + expect(s.getState().connectionWhy).toBe('transport'); + expect(s.getState().bootstrap).not.toBe('empty'); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('EOF and HTTP classification (scenarios 20, 40)', () => { + it('clean EOF resumes with since+stream, not empty, not mismatch (scenario 20)', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return sseRes([snap()]); + return hangingSse([ + { seq: 0, type: 'snapshot', streamId: 's1', resumed: true, nodes: [], counts: COUNTS }, + ]).response; + }); + const s = start(fetch); + await settle(s); + expect(s.getState().connection).toBe('reconnecting'); + expect(s.getState().bootstrap).toBe('scoped'); + expect(s.getState().httpMismatch).toBeNull(); + expect(s.getState().reducer.mismatch).toBeNull(); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + const second = urls.filter((u) => u.startsWith('/v2/events'))[1]; + expect(second).toContain('since=0'); + expect(second).toContain('stream=s1'); + expect(second).toContain(`scope=${encodeURIComponent('/a')}`); + } finally { + vi.useRealTimers(); + } + }); + + it('400 is mismatch with no retry (scenario 40)', async () => { + vi.useFakeTimers(); + try { + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return statusRes(400); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().httpMismatch).toEqual({ status: 400 }); + expect(s.getState().connection).not.toBe('unreachable'); + await vi.advanceTimersByTimeAsync(15_000); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it('401 stream is auth-unreachable with no retry (scenario 40)', async () => { + vi.useFakeTimers(); + try { + const { fetch, urls, inits } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return statusRes(401); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + expect(s.getState().connectionWhy).toBe('auth'); + expect(s.getState().bootstrap).not.toBe('empty'); + await vi.advanceTimersByTimeAsync(15_000); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(1); + const hdrs = new Headers(inits[1]?.headers); + expect(hdrs.get(TOWER_KEY_HEADER)).toBe(KEY); + } finally { + vi.useRealTimers(); + } + }); + + it('404 is mismatch with no retry (scenario 40)', async () => { + vi.useFakeTimers(); + try { + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return statusRes(404); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().httpMismatch).toEqual({ status: 404 }); + await vi.advanceTimersByTimeAsync(15_000); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it('503 retries on backoff (scenario 40)', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const hang = hangingSse([snap()]); + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return statusRes(503); + return hang.response; + }); + const s = start(fetch); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + expect(s.getState().connectionWhy).toBe('transport'); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(2); + expect(s.getState().connection).toBe('live'); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('bad frame recovery (scenario 28)', () => { + it('one recover-fresh without since/stream; second bad frame opens no third', async () => { + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return sseRes(['{nope']); + }); + const s = start(fetch); + await settle(s); + const eventUrls = urls.filter((u) => u.startsWith('/v2/events')); + expect(eventUrls).toHaveLength(2); + expect(eventUrls[0]).not.toContain('since='); + expect(eventUrls[0]).not.toContain('stream='); + expect(eventUrls[1]).not.toContain('since='); + expect(eventUrls[1]).not.toContain('stream='); + expect(s.getState().reducer.mismatch).not.toBeNull(); + expect(s.getState().connection).not.toBe('unreachable'); + expect(s.getState().bootstrap).not.toBe('empty'); + }); +}); + +describe('stale unreachable does not hide mismatch', () => { + it('500 then malformed 200 is mismatch, not unreachable', async () => { + vi.useFakeTimers(); + try { + let n = 0; + const s = start(async (input) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname !== '/api/workspaces') throw new Error('stream opened'); + n += 1; + if (n === 1) return statusRes(500); + return jsonRes(200, {}); + }); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(s.getState().bootstrap).toBe('mismatch'); + expect(s.getState().connection).not.toBe('unreachable'); + await vi.advanceTimersByTimeAsync(2000); + await settle(s); + expect(s.getState().bootstrap).toBe('mismatch'); + expect(s.getState().connection).not.toBe('unreachable'); + expect(n).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('503 then 400 is http mismatch, not unreachable', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const { fetch } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return statusRes(503); + return statusRes(400); + }); + const s = start(fetch); + await settle(s); + expect(s.getState().connection).toBe('unreachable'); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(s.getState().httpMismatch).toEqual({ status: 400 }); + expect(s.getState().connection).not.toBe('unreachable'); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('recover-fresh survives transient failure', () => { + it('keeps requesting a fresh snapshot after recover-fresh then 503', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const hang = hangingSse([snap({ streamId: 's2' })]); + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return sseRes([snap(), '{nope']); + if (events === 2) return statusRes(503); + return hang.response; + }); + const s = start(fetch); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(2); + expect(urls[2]).not.toContain('since='); + expect(urls[2]).not.toContain('stream='); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + const third = urls.filter((u) => u.startsWith('/v2/events'))[2]; + expect(third).toBeDefined(); + expect(third).not.toContain('since='); + expect(third).not.toContain('stream='); + } finally { + vi.useRealTimers(); + } + }); + + it('resumes after a recover-fresh snapshot then a body error', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const hang = hangingSse([snap({ streamId: 's2', seq: 0 })]); + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return sseRes([snap(), '{nope']); + if (events === 2) { + const enc = new TextEncoder(); + let pulls = 0; + const stream = new ReadableStream({ + pull(c) { + pulls += 1; + if (pulls === 1) { + c.enqueue(enc.encode(`data: ${JSON.stringify(snap({ streamId: 's2' }))}\n\n`)); + return; + } + c.error(new Error('reset')); + }, + }); + return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); + } + return hang.response; + }); + const s = start(fetch); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + const third = urls.filter((u) => u.startsWith('/v2/events'))[2]; + expect(third).toBeDefined(); + expect(third).toContain('since=0'); + expect(third).toContain('stream=s2'); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps requesting a fresh snapshot after recover-fresh then EOF', async () => { + vi.useFakeTimers(); + try { + let events = 0; + const hang = hangingSse([snap({ streamId: 's2' })]); + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + events += 1; + if (events === 1) return sseRes([snap(), '{nope']); + if (events === 2) return new Response(null, { status: 200 }); + return hang.response; + }); + const s = start(fetch); + await settle(s); + expect(urls.filter((u) => u.startsWith('/v2/events'))).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + const third = urls.filter((u) => u.startsWith('/v2/events'))[2]; + expect(third).toBeDefined(); + expect(third).not.toContain('since='); + expect(third).not.toContain('stream='); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('emit snapshots state', () => { + it('onState receives a new object each time', async () => { + const seen: Array> = []; + const { fetch } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') return jsonRes(200, { workspaces: [{ path: '/a' }] }); + return hangingSse([snap()]).response; + }); + const s = connect({ + fetch, + getKey: () => KEY, + onState: (st) => seen.push(st), + }); + sessions.push(s); + await settle(s); + expect(seen.length).toBeGreaterThan(1); + expect(seen[0]).not.toBe(seen[1]); + }); +}); + +describe('bootstrap then later reconnect (scenario 25)', () => { + it('500 then 200 is two bootstrap requests; later stream reconnect makes none', async () => { + vi.useFakeTimers(); + try { + let boots = 0; + const { fetch, urls } = recordFetch((url) => { + if (url.pathname === '/api/workspaces') { + boots += 1; + if (boots === 1) return statusRes(500); + return jsonRes(200, { workspaces: [{ path: '/a' }] }); + } + return sseRes([snap()]); + }); + const s = start(fetch); + await settle(s); + expect(boots).toBe(1); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(boots).toBe(2); + expect(s.getState().bootstrap).toBe('scoped'); + await vi.advanceTimersByTimeAsync(1000); + await settle(s); + expect(boots).toBe(2); + expect(urls.filter((u) => u.startsWith('/api/workspaces'))).toHaveLength(2); + expect(urls.filter((u) => u.startsWith('/v2/events')).length).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/v2/__tests__/tree.test.ts b/apps/v2/__tests__/tree.test.ts new file mode 100644 index 000000000..7b04dc099 --- /dev/null +++ b/apps/v2/__tests__/tree.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { initialReducerState } from '../src/lib/reducer.js'; +import { buildTree, workspaceLabel } from '../src/lib/tree.js'; +import type { ClientNode } from '../src/lib/validate.js'; + +function node(over: Partial & Pick): ClientNode { + return { + parentId: null, + name: over.id, + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + ...over, + }; +} + +describe('tree (D13, scenario 21)', () => { + it('labels a dark workspace from the id basename', () => { + expect(workspaceLabel('workspace:/tmp/ws-a')).toBe('ws-a'); + }); + + it('places a builder under its workspace beside the architect', () => { + const nodes = new Map([ + ['workspace:/a', node({ id: 'workspace:/a', kind: 'workspace', name: 'a' })], + ['architect:1', node({ id: 'architect:1', kind: 'architect', parentId: 'workspace:/a', name: 'arch' })], + ['builder:1', node({ id: 'builder:1', kind: 'builder', parentId: 'workspace:/a', name: 'b1' })], + ]); + const { plots, orphanArchitects, orphanBuilders } = buildTree(nodes, new Map()); + expect(plots).toHaveLength(1); + expect(plots[0].architects.map((g) => g.node.name)).toEqual(['arch']); + expect(plots[0].architects[0].builders).toEqual([]); + expect(plots[0].builders.map((b) => b.name)).toEqual(['b1']); + expect(orphanArchitects).toEqual([]); + expect(orphanBuilders).toEqual([]); + }); + + it('nests a builder under its architect parent', () => { + const nodes = new Map([ + ['workspace:/a', node({ id: 'workspace:/a', kind: 'workspace', name: 'a' })], + ['architect:1', node({ id: 'architect:1', kind: 'architect', parentId: 'workspace:/a', name: 'arch' })], + ['builder:1', node({ id: 'builder:1', kind: 'builder', parentId: 'architect:1', name: 'b1' })], + ]); + const { plots, orphanBuilders } = buildTree(nodes, new Map()); + expect(plots[0].architects[0].builders.map((b) => b.name)).toEqual(['b1']); + expect(plots[0].builders).toEqual([]); + expect(orphanBuilders).toEqual([]); + }); + + it('does not invent an architect parent by name', () => { + const nodes = new Map([ + ['workspace:/a', node({ id: 'workspace:/a', kind: 'workspace', name: 'a' })], + ['architect:pay', node({ id: 'architect:pay', kind: 'architect', parentId: 'workspace:/a', name: 'pay' })], + ['builder:pay-1', node({ id: 'builder:pay-1', kind: 'builder', parentId: 'workspace:/a', name: 'pay-1' })], + ]); + const { plots } = buildTree(nodes, new Map()); + expect(plots[0].architects[0].builders).toEqual([]); + expect(plots[0].builders[0].name).toBe('pay-1'); + }); + + it('surfaces an unresolvable parent at machine level', () => { + const nodes = new Map([ + ['workspace:/a', node({ id: 'workspace:/a', kind: 'workspace', name: 'a' })], + ['architect:ghost', node({ id: 'architect:ghost', kind: 'architect', parentId: 'workspace:/missing', name: 'ghost' })], + ['builder:lost', node({ id: 'builder:lost', kind: 'builder', parentId: 'workspace:/missing', name: 'lost' })], + ]); + const { plots, orphanArchitects, orphanBuilders } = buildTree(nodes, new Map()); + expect(plots).toHaveLength(1); + expect(plots[0].architects).toEqual([]); + expect(plots[0].builders).toEqual([]); + expect(orphanArchitects.map((g) => g.node.name)).toEqual(['ghost']); + expect(orphanBuilders.map((b) => b.name)).toEqual(['lost']); + }); + + it('builds a dark plot from the id when there is no node', () => { + const dark = new Map([['workspace:/tmp/gone', { reason: 'unreadable', at: 't0' }]]); + const { plots } = buildTree(new Map(), dark); + expect(plots).toHaveLength(1); + expect(plots[0].name).toBe('gone'); + expect(plots[0].dark).toEqual({ reason: 'unreadable', at: 't0' }); + expect(plots[0].architects).toEqual([]); + expect(plots[0].builders).toEqual([]); + expect(initialReducerState().nodes.size).toBe(0); + }); +}); diff --git a/apps/v2/__tests__/validate.test.ts b/apps/v2/__tests__/validate.test.ts new file mode 100644 index 000000000..7262e9e6a --- /dev/null +++ b/apps/v2/__tests__/validate.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { escapePreview, parseAndValidate, validateFrame } from '../src/lib/validate.js'; + +const after = 0; + +describe('parseAndValidate', () => { + it('invalid JSON reports preview and no seq/type (scenario 29)', () => { + const r = parseAndValidate('{nope', after); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.mismatch.how).toBe('invalid-json'); + expect(r.mismatch.preview).toBe('{nope'); + expect(r.mismatch.seq).toBeUndefined(); + expect(r.mismatch.type).toBeUndefined(); + }); + + it('preview is the first 120 UTF-8 bytes, escaped', () => { + const euro = '€'.repeat(80); + const preview = escapePreview(euro); + expect(new TextEncoder().encode(euro).length).toBeGreaterThan(120); + expect(preview.startsWith('\\xe2\\x82\\xac')).toBe(true); + expect(preview.match(/\\x[0-9a-f]{2}/g)?.length).toBe(120); + }); +}); + +describe('validateFrame read-set (scenario 30)', () => { + it('node with no id', () => { + const r = validateFrame({ seq: 1, type: 'node', node: { kind: 'builder', parentId: null, name: 'x', status: 'running', flags: { heldMail: false }, lastDataAt: null } }, after); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.mismatch.field).toMatch(/id/); + expect(r.mismatch.type).toBe('node'); + expect(r.mismatch.seq).toBe(1); + } + }); + + it('node with kind machine', () => { + const r = validateFrame({ + seq: 1, type: 'node', + node: { id: 'b', kind: 'machine', parentId: null, name: 'x', status: 'running', flags: { heldMail: false }, lastDataAt: null }, + }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toMatch(/kind/); + }); + + it('node with numeric parentId', () => { + const r = validateFrame({ + seq: 1, type: 'node', + node: { id: 'b', kind: 'builder', parentId: 1, name: 'x', status: 'running', flags: { heldMail: false }, lastDataAt: null }, + }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toMatch(/parentId/); + }); + + it('snapshot nodes not an array', () => { + const r = validateFrame({ seq: 0, type: 'snapshot', streamId: 's', resumed: false, nodes: {}, counts: { workspaces: 0, builders: { total: 0, byStatus: {} }, gateWaiting: 0 } }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toBe('nodes'); + }); + + it('snapshot with one bad element among good ones', () => { + const good = { id: 'w', kind: 'workspace', parentId: null, name: 'a', status: 'running', flags: { heldMail: false }, lastDataAt: null }; + const r = validateFrame({ + seq: 0, type: 'snapshot', streamId: 's', resumed: false, + nodes: [good, { ...good, id: '', kind: 'builder' }], + counts: { workspaces: 1, builders: { total: 0, byStatus: {} }, gateWaiting: 0 }, + }, after); + expect(r.ok).toBe(false); + }); + + it('snapshot with no counts', () => { + const r = validateFrame({ seq: 0, type: 'snapshot', streamId: 's', resumed: false, nodes: [] }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toMatch(/counts/); + }); + + it('counts with a negative total', () => { + const r = validateFrame({ seq: 1, type: 'counts', counts: { workspaces: 1, builders: { total: -1, byStatus: {} }, gateWaiting: 0 } }, after); + expect(r.ok).toBe(false); + }); + + it('tick with buckets as an array', () => { + const r = validateFrame({ seq: 1, type: 'tick', at: 't', buckets: [] }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toBe('buckets'); + }); + + it('tick with a non-numeric value', () => { + const r = validateFrame({ seq: 1, type: 'tick', at: 't', buckets: { a: 'x' } }, after); + expect(r.ok).toBe(false); + }); + + it('dark with id that is not workspace:', () => { + const r = validateFrame({ seq: 0, type: 'dark', id: 'machine:x', reason: 'unknown' }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toBe('id'); + }); + + it('resumed with a bad from', () => { + const r = validateFrame({ seq: 1, type: 'resumed', from: -1 }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toBe('from'); + }); +}); + +describe('seq is a safe non-negative integer (scenario 33)', () => { + const base = { type: 'gone', id: 'x' }; + for (const seq of [NaN, Infinity, 1.5, -1, 2 ** 60]) { + it(`rejects ${String(seq)}`, () => { + const r = validateFrame({ ...base, seq }, after); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.mismatch.field).toBe('seq'); + }); + } +}); + +describe('unknown type (scenario 29)', () => { + it('reports the type and seq', () => { + const r = validateFrame({ seq: 4, type: 'wibble' }, after); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.mismatch.how).toBe('unknown-type'); + expect(r.mismatch.type).toBe('wibble'); + expect(r.mismatch.seq).toBe(4); + }); +}); + +describe('fields outside the read-set are ignored (scenario 35)', () => { + it('extra unknown field on a gone frame applies', () => { + const r = validateFrame({ seq: 1, type: 'gone', id: 'x', extra: { garbage: true } }, after); + expect(r.ok).toBe(true); + }); +}); diff --git a/apps/v2/e2e/fixture-server.mjs b/apps/v2/e2e/fixture-server.mjs new file mode 100644 index 000000000..d7edd2da7 --- /dev/null +++ b/apps/v2/e2e/fixture-server.mjs @@ -0,0 +1,289 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DIST = path.resolve(__dirname, '../dist'); +const PORT = Number(process.env.FIXTURE_PORT ?? 4173); +const KEY = 'ab'.repeat(32); +const KEY_RE = /^[0-9a-f]{64}$/; +const ASSET_EXT = new Set(['.js', '.css', '.map', '.svg', '.woff2', '.png', '.ico']); + +const COUNTS = { workspaces: 22, builders: { total: 58, byStatus: { running: 10 } }, gateWaiting: 3 }; + +function zeros() { + return Array.from({ length: 20 }, () => 0); +} + +function node(over) { + return { + parentId: null, + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + buckets: zeros(), + ...over, + }; +} + +const DEFAULT_NODES = [ + node({ id: 'workspace:/tmp/alpha', kind: 'workspace', name: 'alpha' }), + node({ id: 'architect:1', kind: 'architect', parentId: 'workspace:/tmp/alpha', name: 'arch' }), + node({ id: 'builder:1', kind: 'builder', parentId: 'workspace:/tmp/alpha', name: 'b1', status: 'running' }), + node({ + id: 'builder:2', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'b2', + status: 'gate-waiting', + }), +]; + +const state = { + workspacesStatus: 200, + workspacesBody: { workspaces: [{ path: '/tmp/alpha', name: 'alpha' }] }, + eventsStatus: 200, + honorResume: true, + streamId: 's1', + seq: 0, + nodes: DEFAULT_NODES, + dark: [], + pending: [], + clients: [], + unreachable: false, + lastEvents: { since: null, stream: null, mode: null }, +}; + +function injectV2Key(html, key) { + if (!KEY_RE.test(key) || !html.includes('')) return html; + return html.replace('', ``); +} + +function writeSse(res, frame) { + res.write(`data: ${JSON.stringify(frame)}\n\n`); +} + +function snapshot(resumed) { + return { + seq: state.seq, + type: 'snapshot', + streamId: state.streamId, + resumed, + nodes: state.nodes, + counts: COUNTS, + }; +} + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); +} + +function json(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function serveIndex(res) { + const file = path.join(DIST, 'index.html'); + let html; + try { + html = fs.readFileSync(file, 'utf8'); + } catch { + res.writeHead(404); + res.end('no dist'); + return; + } + res.removeHeader('Access-Control-Allow-Origin'); + res.removeHeader('Vary'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(injectV2Key(html, KEY)); +} + +function serveAsset(res, urlPath) { + const rel = urlPath.slice('/v2/assets/'.length); + if (!rel || rel.includes('..') || path.isAbsolute(rel)) { + res.writeHead(404); + res.end('Not found'); + return; + } + const ext = path.extname(rel); + if (!ASSET_EXT.has(ext)) { + res.writeHead(404); + res.end('Not found'); + return; + } + const full = path.resolve(path.join(DIST, 'assets'), rel); + const root = path.resolve(path.join(DIST, 'assets')) + path.sep; + if (!full.startsWith(root)) { + res.writeHead(404); + res.end('Not found'); + return; + } + const mime = { + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.map': 'application/json', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.png': 'image/png', + '.ico': 'image/x-icon', + }; + try { + const buf = fs.readFileSync(full); + res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream' }); + res.end(buf); + } catch { + res.writeHead(404); + res.end('Not found'); + } +} + +function handleEvents(req, res, url) { + if (state.unreachable) { + req.socket.destroy(); + return; + } + if (state.eventsStatus !== 200) { + res.writeHead(state.eventsStatus); + res.end(''); + return; + } + const since = url.searchParams.get('since'); + const stream = url.searchParams.get('stream'); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + }); + const client = { res, closed: false }; + state.clients.push(client); + req.on('close', () => { + client.closed = true; + state.clients = state.clients.filter((c) => c !== client); + }); + const resumeOk = state.honorResume && since !== null && stream === state.streamId; + state.lastEvents = { since, stream, mode: resumeOk ? 'resumed' : 'snapshot' }; + if (resumeOk) { + writeSse(res, { seq: state.seq, type: 'resumed', from: Number(since) }); + for (const f of state.pending) writeSse(res, f); + state.pending = []; + } else { + state.pending = []; + writeSse(res, snapshot(false)); + for (const d of state.dark) { + writeSse(res, { seq: state.seq, type: 'dark', id: d.id, reason: d.reason }); + } + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${PORT}`); + if (state.unreachable && url.pathname.startsWith('/api/')) { + req.socket.destroy(); + return; + } + if (req.method === 'GET' && url.pathname === '/__fixture/last-events') { + json(res, 200, state.lastEvents); + return; + } + if (req.method === 'POST' && url.pathname.startsWith('/__fixture/')) { + const raw = await readBody(req); + const body = raw ? JSON.parse(raw) : {}; + if (url.pathname === '/__fixture/reset') { + state.workspacesStatus = 200; + state.workspacesBody = { workspaces: [{ path: '/tmp/alpha', name: 'alpha' }] }; + state.eventsStatus = 200; + state.honorResume = true; + state.streamId = 's1'; + state.seq = 0; + state.nodes = [...DEFAULT_NODES]; + state.dark = []; + state.pending = []; + state.unreachable = false; + state.lastEvents = { since: null, stream: null, mode: null }; + for (const c of state.clients) { + c.closed = true; + c.res.end(); + } + state.clients = []; + json(res, 200, { ok: true }); + return; + } + if (url.pathname === '/__fixture/workspaces') { + if (body.status) state.workspacesStatus = body.status; + if (body.body !== undefined) state.workspacesBody = body.body; + json(res, 200, { ok: true }); + return; + } + if (url.pathname === '/__fixture/unreachable') { + state.unreachable = true; + json(res, 200, { ok: true }); + return; + } + if (url.pathname === '/__fixture/honor-resume') { + state.honorResume = Boolean(body.honor); + if (body.streamId) state.streamId = body.streamId; + json(res, 200, { ok: true }); + return; + } + if (url.pathname === '/__fixture/disconnect') { + for (const c of state.clients) { + c.closed = true; + c.res.end(); + } + state.clients = []; + json(res, 200, { ok: true }); + return; + } + if (url.pathname === '/__fixture/push') { + const frames = body.frames; + for (const f of frames) { + state.seq += 1; + const framed = { ...f, seq: state.seq }; + if (state.clients.length === 0) state.pending.push(framed); + else { + for (const c of state.clients) { + if (!c.closed) writeSse(c.res, framed); + } + } + } + json(res, 200, { ok: true, seq: state.seq }); + return; + } + json(res, 404, { error: 'unknown fixture' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/workspaces') { + if (state.workspacesStatus !== 200) { + res.writeHead(state.workspacesStatus); + res.end(''); + return; + } + json(res, 200, state.workspacesBody); + return; + } + if (req.method === 'GET' && url.pathname === '/v2/events') { + handleEvents(req, res, url); + return; + } + if (req.method === 'GET' && url.pathname === '/v2/') { + serveIndex(res); + return; + } + if (req.method === 'GET' && url.pathname.startsWith('/v2/assets/')) { + serveAsset(res, url.pathname); + return; + } + res.writeHead(404); + res.end('Not found'); +}); + +server.listen(PORT, '127.0.0.1', () => { + process.stdout.write(`fixture on ${PORT}\n`); +}); diff --git a/apps/v2/e2e/site.spec.ts b/apps/v2/e2e/site.spec.ts new file mode 100644 index 000000000..6a0500866 --- /dev/null +++ b/apps/v2/e2e/site.spec.ts @@ -0,0 +1,314 @@ +import { expect, test, type Page } from '@playwright/test'; + +const FIXTURE = 'http://127.0.0.1:4173'; + +async function reset(): Promise { + await fetch(`${FIXTURE}/__fixture/reset`, { method: 'POST', body: '{}' }); +} + +async function push(frames: unknown[]): Promise { + await fetch(`${FIXTURE}/__fixture/push`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frames }), + }); +} + +async function lastEvents(): Promise<{ since: string | null; stream: string | null; mode: string | null }> { + const res = await fetch(`${FIXTURE}/__fixture/last-events`); + return res.json() as Promise<{ since: string | null; stream: string | null; mode: string | null }>; +} + +async function plantSentinel(page: Page): Promise { + await page.evaluate(() => { + (window as Window & { __v2Sentinel?: number }).__v2Sentinel = 1; + }); +} + +async function sentinelAlive(page: Page): Promise { + return page.evaluate(() => (window as Window & { __v2Sentinel?: number }).__v2Sentinel); +} + +async function treeDump(page: Page): Promise { + return page.evaluate(() => + [...document.querySelectorAll('[data-kind]')] + .map((el) => + [ + el.getAttribute('data-kind'), + el.getAttribute('data-id'), + el.getAttribute('data-dark') ?? '', + el.className, + ].join('|'), + ) + .join('\n'), + ); +} + +async function openSite(page: Page): Promise { + await page.goto('/v2/'); + await expect(page.locator('[data-kind="workspace"]').first()).toBeVisible({ timeout: 10_000 }); +} + +test.beforeEach(async () => { + await reset(); +}); + +test('load and render hierarchy', async ({ page }) => { + await openSite(page); + await expect(page.locator('[data-kind="workspace"]')).toContainText('alpha'); + await expect(page.locator('[data-kind="architect"]')).toContainText('arch'); + await expect(page.locator('[data-kind="builder"][data-id="builder:1"]')).toContainText('b1'); + const header = page.locator('[data-kind="workspace"] .ws-plot-name'); + await expect.poll(async () => header.evaluate((el) => getComputedStyle(el).display)).toBe('flex'); + await expect.poll(async () => header.evaluate((el) => getComputedStyle(el).gap)).toBe('8px'); +}); + +test('new builder appears with no reload', async ({ page }) => { + await openSite(page); + const before = await page.evaluate(() => performance.navigation.type); + await push([ + { + type: 'node', + node: { + id: 'builder:new', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'newb', + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + await expect(page.locator('[data-kind="builder"][data-id="builder:new"]')).toBeVisible(); + const after = await page.evaluate(() => performance.navigation.type); + expect(after).toBe(before); +}); + +test('gate-waiting is GATE rust and rust nowhere else', async ({ page }) => { + await openSite(page); + const gate = page.locator('[data-id="builder:2"] .stamp-gate'); + await expect(gate).toHaveText('GATE'); + await expect(page.locator('[data-id="builder:2"]')).toHaveClass(/needs-attn/); + const rust = 'rgb(181, 80, 42)'; + await expect.poll(async () => gate.evaluate((el) => getComputedStyle(el).color)).toBe(rust); + const rustHolders = await page.evaluate((want) => { + return [...document.querySelectorAll('*')].filter((el) => { + const s = getComputedStyle(el); + return s.color === want || s.backgroundColor === want; + }).map((el) => el.className); + }, rust); + expect(rustHolders.length).toBeGreaterThan(0); + expect(rustHolders.every((c) => String(c).includes('stamp-gate') || String(c).includes('needs-attn'))).toBe(true); +}); + +test('stalled is STALLED ochre', async ({ page }) => { + await openSite(page); + await push([ + { + type: 'node', + node: { + id: 'builder:1', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'b1', + status: 'stalled', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + const stamp = page.locator('[data-id="builder:1"] .stamp-stalled'); + await expect(stamp).toHaveText('STALLED'); + await expect.poll(async () => stamp.evaluate((el) => getComputedStyle(el).color)).toBe('rgb(192, 138, 46)'); +}); + +test('sparkline advances on tick and silent builder flattens', async ({ page }) => { + await openSite(page); + await push([{ type: 'tick', at: 't0', buckets: { 'builder:1': 9, 'builder:2': 9 } }]); + const busy = page.locator('[data-id="builder:1"] .spark i').last(); + const silent = page.locator('[data-id="builder:2"] .spark i').last(); + await expect.poll(async () => busy.evaluate((el) => (el as HTMLElement).style.height)).not.toBe('2px'); + await expect.poll(async () => silent.evaluate((el) => (el as HTMLElement).style.height)).not.toBe('2px'); + await push([{ type: 'tick', at: 't1', buckets: { 'builder:1': 9 } }]); + await expect.poll(async () => silent.evaluate((el) => (el as HTMLElement).style.height)).toBe('2px'); +}); + +test('gone removes the row', async ({ page }) => { + await openSite(page); + await push([{ type: 'gone', id: 'builder:2' }]); + await expect(page.locator('[data-id="builder:2"]')).toHaveCount(0); +}); + +test('disconnect then honoured resume recovers without reload', async ({ page }) => { + await openSite(page); + await plantSentinel(page); + await fetch(`${FIXTURE}/__fixture/honor-resume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ honor: true }), + }); + await fetch(`${FIXTURE}/__fixture/disconnect`, { method: 'POST', body: '{}' }); + await expect.poll(async () => (await lastEvents()).mode).toBe('resumed'); + const honoured = await lastEvents(); + expect(honoured.since).not.toBeNull(); + expect(honoured.stream).toBe('s1'); + await expect(page.locator('[data-id="builder:1"]')).toBeVisible(); + await push([ + { + type: 'node', + node: { + id: 'builder:resume', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'resumed', + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + await expect(page.locator('[data-id="builder:resume"]')).toBeVisible(); + await expect(page.locator('[data-id="builder:1"]')).toBeVisible(); + expect(await sentinelAlive(page)).toBe(1); +}); + +test('disconnect then refused snapshot recovers without reload', async ({ page }) => { + await openSite(page); + await plantSentinel(page); + await fetch(`${FIXTURE}/__fixture/honor-resume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ honor: false, streamId: 's2' }), + }); + await fetch(`${FIXTURE}/__fixture/disconnect`, { method: 'POST', body: '{}' }); + await expect.poll(async () => (await lastEvents()).since).not.toBeNull(); + const refused = await lastEvents(); + expect(refused.mode).toBe('snapshot'); + expect(refused.stream).toBe('s1'); + await expect(page.locator('[data-id="builder:1"]')).toBeVisible(); + await push([ + { + type: 'node', + node: { + id: 'builder:refused', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'refused', + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + await expect(page.locator('[data-id="builder:refused"]')).toBeVisible(); + await expect(page.locator('[data-id="builder:1"]')).toBeVisible(); + expect(await sentinelAlive(page)).toBe(1); +}); + +test('dark workspace dark, sibling live', async ({ page }) => { + await openSite(page); + await push([{ type: 'dark', id: 'workspace:/tmp/gone', reason: 'unreadable' }]); + await expect(page.locator('[data-dark="true"]')).toContainText('gone'); + await expect(page.locator('[data-id="workspace:/tmp/alpha"]')).not.toHaveClass(/dim-sub/); +}); + +test('unreachable and zero workspaces differ', async ({ page }) => { + await fetch(`${FIXTURE}/__fixture/workspaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: { workspaces: [] } }), + }); + await page.goto('/v2/'); + await expect(page.getByTestId('empty-site')).toBeVisible(); + await expect(page.getByTestId('unreachable')).toHaveCount(0); + + await reset(); + await fetch(`${FIXTURE}/__fixture/unreachable`, { method: 'POST', body: '{}' }); + await page.goto('/v2/'); + await expect(page.getByTestId('unreachable')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('empty-site')).toHaveCount(0); +}); + +test('two pages on one scope converge', async ({ browser }) => { + const ctx = await browser.newContext(); + const a = await ctx.newPage(); + const b = await ctx.newPage(); + await a.goto('/v2/'); + await b.goto('/v2/'); + await expect(a.locator('[data-kind="builder"][data-id="builder:1"]')).toBeVisible(); + await expect(b.locator('[data-kind="builder"][data-id="builder:1"]')).toBeVisible(); + await push([ + { + type: 'node', + node: { + id: 'builder:z', + kind: 'builder', + parentId: 'workspace:/tmp/alpha', + name: 'bz', + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + await expect(a.locator('[data-id="builder:z"]')).toBeVisible(); + await expect(b.locator('[data-id="builder:z"]')).toBeVisible(); + expect(await treeDump(a)).toBe(await treeDump(b)); + await ctx.close(); +}); + +test('counts sit in the footer as machine totals', async ({ page }) => { + await openSite(page); + const foot = page.getByTestId('machine-totals'); + await expect(foot).toContainText('Machine totals'); + await expect(foot).toContainText('22 workspaces'); + await expect(foot).toContainText('58 builders'); +}); + +test('builder sits under workspace beside architect', async ({ page }) => { + await openSite(page); + const ws = page.locator('[data-kind="workspace"]'); + await expect(ws.locator('[data-kind="architect"]')).toHaveCount(1); + await expect(ws.locator('[data-kind="builder"]')).toHaveCount(2); + await expect(ws.locator('[data-kind="architect"] [data-kind="builder"]')).toHaveCount(0); +}); + +test('architect-parented builder nests under that architect', async ({ page }) => { + await openSite(page); + await push([ + { + type: 'node', + node: { + id: 'builder:nested', + kind: 'builder', + parentId: 'architect:1', + name: 'nested', + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }, + }, + ]); + const arch = page.locator('[data-kind="architect"][data-id="architect:1"]'); + await expect(arch.locator('[data-id="builder:nested"]')).toBeVisible(); + await expect(page.locator('[data-kind="workspace"] > .stake-list [data-id="builder:nested"]')).toHaveCount(0); +}); + +test('cold load and idle bandwidth are measured', async ({ page }) => { + const start = Date.now(); + await openSite(page); + const ms = Date.now() - start; + const loadBytes = await page.evaluate(() => { + const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + return entries.reduce((n, e) => n + (e.transferSize || 0), 0); + }); + const t0 = await page.evaluate(() => + (performance.getEntriesByType('resource') as PerformanceResourceTiming[]).reduce((n, e) => n + (e.transferSize || 0), 0), + ); + await page.waitForTimeout(1000); + const t1 = await page.evaluate(() => + (performance.getEntriesByType('resource') as PerformanceResourceTiming[]).reduce((n, e) => n + (e.transferSize || 0), 0), + ); + console.log(`cold-load-ms=${ms} load-bytes=${loadBytes} idle-Bps=${t1 - t0}`); +}); diff --git a/apps/v2/index.html b/apps/v2/index.html new file mode 100644 index 000000000..99c3dd74b --- /dev/null +++ b/apps/v2/index.html @@ -0,0 +1,12 @@ + + + + + + v2 + + +
+ + + diff --git a/apps/v2/package.json b/apps/v2/package.json new file mode 100644 index 000000000..4dd5cd532 --- /dev/null +++ b/apps/v2/package.json @@ -0,0 +1,31 @@ +{ + "name": "@cluesmith/codev-v2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "pnpm build && playwright test" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@cluesmith/codev-types": "workspace:*", + "@playwright/test": "^1.58.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^26.0.0", + "typescript": "catalog:", + "vite": "^6.0.0", + "vitest": "^4.0.0" + } +} diff --git a/apps/v2/playwright.config.ts b/apps/v2/playwright.config.ts new file mode 100644 index 000000000..7e5c6f764 --- /dev/null +++ b/apps/v2/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:4173', + viewport: { width: 1440, height: 900 }, + }, + webServer: { + command: 'node e2e/fixture-server.mjs', + url: 'http://127.0.0.1:4173/v2/', + reuseExistingServer: false, + timeout: 15_000, + }, +}); diff --git a/apps/v2/src/App.tsx b/apps/v2/src/App.tsx new file mode 100644 index 000000000..4be2b7934 --- /dev/null +++ b/apps/v2/src/App.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { ConnectionBanner } from './components/ConnectionBanner.js'; +import { MachineFooter } from './components/MachineFooter.js'; +import { SiteView } from './components/SiteView.js'; +import { connect, initialAppState, type AppState } from './lib/stream.js'; +import { viewKind } from './lib/view.js'; + +export function Page({ state, hostname }: { state: AppState; hostname: string }) { + const kind = viewKind(state); + if (kind === 'unreachable') { + return ( +
+ +
+ ); + } + if (kind === 'mismatch') { + return ( +
+ +
+ ); + } + if (kind === 'empty') { + return ( +
+
+

{hostname}

+ this machine +
+
+

+ No workspaces on this machine. +

+
+ +
+ ); + } + if (kind === 'loading') { + return ( +
+
+

{hostname}

+ this machine +
+
+

Loading

+
+
+ ); + } + return ( +
+ {state.connection === 'reconnecting' ? ( + + ) : null} + +
+ ); +} + +export function App() { + const [state, setState] = useState(initialAppState); + useEffect(() => { + const session = connect({ fetch: globalThis.fetch, onState: setState }); + return () => session.stop(); + }, []); + return ; +} diff --git a/apps/v2/src/components/ArchitectHeader.tsx b/apps/v2/src/components/ArchitectHeader.tsx new file mode 100644 index 000000000..8007d3c55 --- /dev/null +++ b/apps/v2/src/components/ArchitectHeader.tsx @@ -0,0 +1,27 @@ +import type { ClientNode } from '../lib/validate.js'; +import { BuilderRow } from './BuilderRow.js'; +import { StatusStamp } from './StatusStamp.js'; + +type Props = { node: ClientNode; builders?: ClientNode[] }; + +export function ArchitectHeader({ node, builders = [] }: Props) { + const cls = ['arch-block', node.status === 'offline' ? 'dim-sub' : ''].filter(Boolean).join(' '); + return ( +
+
+ + {node.name} + {node.flags.heldMail ? mail : null} + + +
+ {builders.length > 0 ? ( +
+ {builders.map((b) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/v2/src/components/BuilderRow.tsx b/apps/v2/src/components/BuilderRow.tsx new file mode 100644 index 000000000..1a939391e --- /dev/null +++ b/apps/v2/src/components/BuilderRow.tsx @@ -0,0 +1,23 @@ +import type { ClientNode } from '../lib/validate.js'; +import { Sparkline } from './Sparkline.js'; +import { StatusStamp } from './StatusStamp.js'; + +type Props = { node: ClientNode }; + +export function BuilderRow({ node }: Props) { + const gate = node.status === 'gate-waiting'; + const offline = node.status === 'offline'; + const cls = ['stake', gate ? 'needs-attn' : '', offline ? 'dim-sub' : ''].filter(Boolean).join(' '); + return ( +
+
+ + {node.name} + {node.flags.heldMail ? mail : null} + + +
+ +
+ ); +} diff --git a/apps/v2/src/components/ConnectionBanner.tsx b/apps/v2/src/components/ConnectionBanner.tsx new file mode 100644 index 000000000..5b12f1aea --- /dev/null +++ b/apps/v2/src/components/ConnectionBanner.tsx @@ -0,0 +1,37 @@ +import type { AppState } from '../lib/stream.js'; + +type Props = { state: AppState; kind: 'unreachable' | 'mismatch' | 'reconnecting' }; + +export function ConnectionBanner({ state, kind }: Props) { + if (kind === 'reconnecting') { + return
Reconnecting
; + } + if (kind === 'unreachable') { + const auth = state.connectionWhy === 'auth'; + return ( +
+

{auth ? 'Auth failed' : 'Cannot reach Tower'}

+

{auth ? 'The tower key was rejected.' : 'The connection failed. Retrying.'}

+
+ ); + } + const frame = state.reducer.mismatch; + const boot = state.bootstrapMismatch; + const http = state.httpMismatch; + let detail = 'The client cannot read this contract.'; + if (http) detail = `HTTP ${http.status}`; + else if (boot) detail = boot.how + (boot.preview ? `: ${boot.preview}` : ''); + else if (frame) { + if (frame.how === 'invalid-json') { + detail = `invalid JSON after cursor ${frame.afterSeq}` + (frame.preview ? `: ${frame.preview}` : ''); + } else { + detail = [frame.type, frame.seq, frame.field].filter((x) => x !== undefined).join(' · '); + } + } + return ( +
+

Contract mismatch

+

{detail}

+
+ ); +} diff --git a/apps/v2/src/components/MachineFooter.tsx b/apps/v2/src/components/MachineFooter.tsx new file mode 100644 index 000000000..b8c3d16cd --- /dev/null +++ b/apps/v2/src/components/MachineFooter.tsx @@ -0,0 +1,13 @@ +import type { ClientCounts } from '../lib/validate.js'; + +type Props = { counts: ClientCounts | null }; + +export function MachineFooter({ counts }: Props) { + if (!counts) return null; + return ( +
+ Machine totals: {counts.workspaces} workspaces · {counts.builders.total} builders ·{' '} + {counts.gateWaiting} gate-waiting +
+ ); +} diff --git a/apps/v2/src/components/SiteView.tsx b/apps/v2/src/components/SiteView.tsx new file mode 100644 index 000000000..45f41aa8c --- /dev/null +++ b/apps/v2/src/components/SiteView.tsx @@ -0,0 +1,49 @@ +import type { AppState } from '../lib/stream.js'; +import { buildTree } from '../lib/tree.js'; +import { ArchitectHeader } from './ArchitectHeader.js'; +import { BuilderRow } from './BuilderRow.js'; +import { MachineFooter } from './MachineFooter.js'; +import { WorkspacePlot } from './WorkspacePlot.js'; + +type Props = { state: AppState; hostname: string }; + +export function SiteView({ state, hostname }: Props) { + const { plots, orphanArchitects, orphanBuilders } = buildTree(state.reducer.nodes, state.reducer.darkPaths); + const hasOrphans = orphanArchitects.length > 0 || orphanBuilders.length > 0; + return ( + <> +
+

{hostname}

+ this machine +
+
+
+ Machine Lot +
+ {plots.map((p) => ( + + ))} + {hasOrphans ? ( +
+
+ parent not in tree +
+ {orphanArchitects.map((g) => ( + + ))} + {orphanBuilders.length > 0 ? ( +
+ {orphanBuilders.map((b) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+
+
+ + + ); +} diff --git a/apps/v2/src/components/Sparkline.tsx b/apps/v2/src/components/Sparkline.tsx new file mode 100644 index 000000000..b04324fbd --- /dev/null +++ b/apps/v2/src/components/Sparkline.tsx @@ -0,0 +1,15 @@ +import { TRACE_LEN } from '../lib/validate.js'; + +type Props = { values?: number[] }; + +export function Sparkline({ values }: Props) { + const bars = values && values.length === TRACE_LEN ? values : Array.from({ length: TRACE_LEN }, () => 0); + const peak = Math.max(1, ...bars); + return ( +
+ {bars.map((v, i) => ( + + ))} +
+ ); +} diff --git a/apps/v2/src/components/StatusStamp.tsx b/apps/v2/src/components/StatusStamp.tsx new file mode 100644 index 000000000..b3593b4ae --- /dev/null +++ b/apps/v2/src/components/StatusStamp.tsx @@ -0,0 +1,9 @@ +type Props = { status: string }; + +export function StatusStamp({ status }: Props) { + if (status === 'gate-waiting') return GATE; + if (status === 'stalled') return STALLED; + if (status === 'running') return RUN; + if (status === 'offline') return OFF; + return {status}; +} diff --git a/apps/v2/src/components/WorkspacePlot.tsx b/apps/v2/src/components/WorkspacePlot.tsx new file mode 100644 index 000000000..400fa86e9 --- /dev/null +++ b/apps/v2/src/components/WorkspacePlot.tsx @@ -0,0 +1,35 @@ +import type { WorkspacePlotModel } from '../lib/tree.js'; +import { ArchitectHeader } from './ArchitectHeader.js'; +import { BuilderRow } from './BuilderRow.js'; +import { StatusStamp } from './StatusStamp.js'; + +type Props = { plot: WorkspacePlotModel }; + +export function WorkspacePlot({ plot }: Props) { + const dim = Boolean(plot.dark) || plot.status === 'offline'; + const cls = dim ? 'ws-plot dim-sub' : 'ws-plot'; + return ( +
+
+ {plot.name} + {plot.flags.heldMail ? mail : null} + {plot.status ? : null} +
+ {plot.dark ? ( +
+ {plot.dark.reason} · {plot.dark.at} +
+ ) : null} + {plot.architects.map((g) => ( + + ))} + {plot.builders.length > 0 ? ( +
+ {plot.builders.map((b) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/v2/src/lib/bootstrap.ts b/apps/v2/src/lib/bootstrap.ts new file mode 100644 index 000000000..9e284552c --- /dev/null +++ b/apps/v2/src/lib/bootstrap.ts @@ -0,0 +1,133 @@ +import { TOWER_KEY_HEADER } from '@cluesmith/codev-types'; +import { escapePreview } from './validate.js'; + +export type BootstrapMismatch = { + how: 'invalid-json' | 'bad-body'; + preview?: string; + field?: string; +}; + +export type BootstrapOnce = + | { kind: 'scoped'; paths: string[] } + | { kind: 'empty' } + | { kind: 'unreachable'; why: 'auth' | 'transport' } + | { kind: 'mismatch'; mismatch: BootstrapMismatch }; + +export type BootstrapEnd = + | { kind: 'scoped'; paths: string[] } + | { kind: 'empty' } + | { kind: 'mismatch'; mismatch: BootstrapMismatch } + | { kind: 'aborted' }; + +export type BackoffFn = (ms: number, cb: () => void) => unknown; + +const BACKOFF_START = 1000; +const BACKOFF_CAP = 15_000; + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function parseWorkspacesBody(text: string): BootstrapOnce { + let obj: unknown; + try { + obj = JSON.parse(text); + } catch { + return { + kind: 'mismatch', + mismatch: { how: 'invalid-json', preview: escapePreview(text) }, + }; + } + if (!isPlainObject(obj) || !Object.prototype.hasOwnProperty.call(obj, 'workspaces')) { + return { kind: 'mismatch', mismatch: { how: 'bad-body', field: 'workspaces' } }; + } + if (!Array.isArray(obj.workspaces)) { + return { kind: 'mismatch', mismatch: { how: 'bad-body', field: 'workspaces' } }; + } + const paths: string[] = []; + for (const entry of obj.workspaces) { + if (!isPlainObject(entry) || typeof entry.path !== 'string' || entry.path === '') { + return { kind: 'mismatch', mismatch: { how: 'bad-body', field: 'path' } }; + } + paths.push(entry.path); + } + if (paths.length === 0) return { kind: 'empty' }; + return { kind: 'scoped', paths }; +} + +export async function fetchWorkspacesOnce( + fetchFn: typeof globalThis.fetch, + key: string | undefined, + signal?: AbortSignal, +): Promise { + let res: Response; + try { + const headers: Record = {}; + if (key) headers[TOWER_KEY_HEADER] = key; + res = await fetchFn('/api/workspaces', { headers, signal }); + } catch { + return { kind: 'unreachable', why: 'transport' }; + } + if (res.status !== 200) { + const why = res.status === 401 || res.status === 403 ? 'auth' : 'transport'; + return { kind: 'unreachable', why }; + } + let text: string; + try { + text = await res.text(); + } catch { + return { + kind: 'mismatch', + mismatch: { how: 'invalid-json', preview: '' }, + }; + } + return parseWorkspacesBody(text); +} + +function wait(ms: number, reconnectBackoff: BackoffFn, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + let id: unknown; + const onAbort = () => { + clearTimeout(id as number); + resolve(); + }; + id = reconnectBackoff(ms, () => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export async function runBootstrap(opts: { + fetch: typeof globalThis.fetch; + key: string | undefined; + reconnectBackoff: BackoffFn; + signal?: AbortSignal; + onUnreachable?: (why: 'auth' | 'transport') => void; + onMismatch?: (mismatch: BootstrapMismatch) => void; +}): Promise { + let delay = BACKOFF_START; + let mismatchAttempts = 0; + while (!opts.signal?.aborted) { + const result = await fetchWorkspacesOnce(opts.fetch, opts.key, opts.signal); + if (opts.signal?.aborted) return { kind: 'aborted' }; + if (result.kind === 'scoped' || result.kind === 'empty') return result; + if (result.kind === 'mismatch') { + mismatchAttempts += 1; + opts.onMismatch?.(result.mismatch); + if (mismatchAttempts >= 2) return result; + await wait(delay, opts.reconnectBackoff, opts.signal); + delay = Math.min(delay * 2, BACKOFF_CAP); + continue; + } + opts.onUnreachable?.(result.why); + await wait(delay, opts.reconnectBackoff, opts.signal); + delay = Math.min(delay * 2, BACKOFF_CAP); + } + return { kind: 'aborted' }; +} diff --git a/apps/v2/src/lib/encode-scope.ts b/apps/v2/src/lib/encode-scope.ts new file mode 100644 index 000000000..3a826064e --- /dev/null +++ b/apps/v2/src/lib/encode-scope.ts @@ -0,0 +1,3 @@ +export function encodeScope(paths: string[]): string { + return paths.map((p) => encodeURIComponent(p)).join(','); +} diff --git a/apps/v2/src/lib/key.ts b/apps/v2/src/lib/key.ts new file mode 100644 index 000000000..60cb8dacd --- /dev/null +++ b/apps/v2/src/lib/key.ts @@ -0,0 +1,5 @@ +export function getTowerKey(): string | undefined { + const g = globalThis as typeof globalThis & { __CODEV_TOWER_KEY__?: unknown }; + const key = g.__CODEV_TOWER_KEY__; + return typeof key === 'string' && key !== '' ? key : undefined; +} diff --git a/apps/v2/src/lib/reducer.ts b/apps/v2/src/lib/reducer.ts new file mode 100644 index 000000000..5caa8e058 --- /dev/null +++ b/apps/v2/src/lib/reducer.ts @@ -0,0 +1,183 @@ +import { + TRACE_LEN, + parseAndValidate, + validateFrame, + type ClientCounts, + type ClientNode, + type Mismatch, + type ValidatedFrame, +} from './validate.js'; + +export type DarkEntry = { reason: string; at: string }; + +export type ReducerState = { + nodes: Map; + darkPaths: Map; + counts: ClientCounts | null; + cursor: { streamId: string | null; seq: number }; + mismatch: Mismatch | null; + mismatchAttempts: number; +}; + +export type ApplyEffect = 'none' | 'recover-fresh' | 'halt'; + +export type ApplyResult = { state: ReducerState; effect: ApplyEffect }; + +export function initialReducerState(): ReducerState { + return { + nodes: new Map(), + darkPaths: new Map(), + counts: null, + cursor: { streamId: null, seq: 0 }, + mismatch: null, + mismatchAttempts: 0, + }; +} + +function zeros(): number[] { + return Array.from({ length: TRACE_LEN }, () => 0); +} + +function cloneNode(n: ClientNode): ClientNode { + return { + ...n, + flags: { ...n.flags }, + buckets: n.buckets ? [...n.buckets] : undefined, + }; +} + +function cloneState(s: ReducerState): ReducerState { + return { + nodes: new Map([...s.nodes].map(([k, v]) => [k, cloneNode(v)])), + darkPaths: new Map(s.darkPaths), + counts: s.counts + ? { + workspaces: s.counts.workspaces, + builders: { + total: s.counts.builders.total, + byStatus: { ...s.counts.builders.byStatus }, + }, + gateWaiting: s.counts.gateWaiting, + } + : null, + cursor: { ...s.cursor }, + mismatch: s.mismatch ? { ...s.mismatch } : null, + mismatchAttempts: s.mismatchAttempts, + }; +} + +function enterMismatch(state: ReducerState, mismatch: Mismatch): ApplyResult { + const next = cloneState(state); + next.mismatch = mismatch; + if (state.mismatchAttempts === 0) { + next.mismatchAttempts = 1; + return { state: next, effect: 'recover-fresh' }; + } + return { state: next, effect: 'halt' }; +} + +function applyValidated(state: ReducerState, frame: ValidatedFrame, now: string): ReducerState { + const next = cloneState(state); + + if (frame.type === 'snapshot') { + next.nodes = new Map(); + for (const n of frame.nodes) { + const copy = cloneNode(n); + if (copy.kind === 'builder' && copy.buckets === undefined) { + copy.buckets = zeros(); + } + next.nodes.set(copy.id, copy); + } + next.darkPaths = new Map(); + next.counts = frame.counts; + next.cursor = { streamId: frame.streamId, seq: frame.seq }; + next.mismatch = null; + next.mismatchAttempts = 0; + return next; + } + + switch (frame.type) { + case 'resumed': + break; + case 'node': { + const existing = next.nodes.get(frame.node.id); + const copy = cloneNode(frame.node); + if (existing?.buckets) { + copy.buckets = [...existing.buckets]; + } else if (copy.kind === 'builder' && copy.buckets === undefined) { + copy.buckets = zeros(); + } + next.nodes.set(copy.id, copy); + break; + } + case 'gone': + next.nodes.delete(frame.id); + break; + case 'counts': + next.counts = frame.counts; + break; + case 'tick': { + for (const [id, node] of next.nodes) { + if (node.kind !== 'builder') continue; + const trace = node.buckets ? [...node.buckets] : zeros(); + const value = Object.prototype.hasOwnProperty.call(frame.buckets, id) + ? frame.buckets[id] + : 0; + trace.push(value); + while (trace.length > TRACE_LEN) trace.shift(); + next.nodes.set(id, { ...node, buckets: trace }); + } + break; + } + case 'dark': + next.darkPaths.set(frame.id, { reason: frame.reason, at: now }); + break; + } + + next.cursor = { ...next.cursor, seq: frame.seq }; + return next; +} + +export function applyFrame(state: ReducerState, raw: string, now = new Date().toISOString()): ApplyResult { + const parsed = parseAndValidate(raw, state.cursor.seq); + if (!parsed.ok) return enterMismatch(state, parsed.mismatch); + return applyValidatedFrame(state, parsed.frame, now); +} + +export function applyValidatedFrame(state: ReducerState, frame: ValidatedFrame, now = new Date().toISOString()): ApplyResult { + if (state.mismatch !== null && frame.type !== 'snapshot') { + return { state, effect: 'none' }; + } + + const sameStream = + state.cursor.streamId !== null && + (frame.type !== 'snapshot' || frame.streamId === state.cursor.streamId); + if (sameStream && frame.seq < state.cursor.seq) { + return enterMismatch(state, { + how: 'bad-field', + afterSeq: state.cursor.seq, + type: frame.type, + seq: frame.seq, + field: 'seq', + }); + } + + return { state: applyValidated(state, frame, now), effect: 'none' }; +} + +export function applyUnknown(state: ReducerState, obj: unknown, now = new Date().toISOString()): ApplyResult { + const parsed = validateFrame(obj, state.cursor.seq); + if (!parsed.ok) return enterMismatch(state, parsed.mismatch); + return applyValidatedFrame(state, parsed.frame, now); +} + +export function serialise(state: ReducerState): unknown { + return { + nodes: [...state.nodes.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, n]) => n), + darkPaths: [...state.darkPaths.entries()].sort(([a], [b]) => a.localeCompare(b)), + counts: state.counts, + cursor: state.cursor, + mismatch: state.mismatch, + mismatchAttempts: state.mismatchAttempts, + }; +} diff --git a/apps/v2/src/lib/sse-reader.ts b/apps/v2/src/lib/sse-reader.ts new file mode 100644 index 000000000..2a989e2ca --- /dev/null +++ b/apps/v2/src/lib/sse-reader.ts @@ -0,0 +1,42 @@ +export async function* readSseData(stream: ReadableStream): AsyncGenerator { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let buffer = ''; + let dataLines: string[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + while (true) { + const nl = buffer.indexOf('\n'); + if (nl < 0) break; + let line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + if (line.endsWith('\r')) line = line.slice(0, -1); + if (line === '') { + if (dataLines.length > 0) { + yield dataLines.join('\n'); + dataLines = []; + } + continue; + } + if (line.startsWith('data:')) { + let payload = line.slice(5); + if (payload.startsWith(' ')) payload = payload.slice(1); + dataLines.push(payload); + } + } + } + } finally { + try { + await reader.cancel(); + } catch { + try { + reader.releaseLock(); + } catch { + /* already released */ + } + } + } +} diff --git a/apps/v2/src/lib/stream.ts b/apps/v2/src/lib/stream.ts new file mode 100644 index 000000000..b419237c0 --- /dev/null +++ b/apps/v2/src/lib/stream.ts @@ -0,0 +1,279 @@ +import { TOWER_KEY_HEADER } from '@cluesmith/codev-types'; +import { runBootstrap, type BootstrapMismatch } from './bootstrap.js'; +import { encodeScope } from './encode-scope.js'; +import { getTowerKey } from './key.js'; +import { applyFrame, initialReducerState, type ReducerState } from './reducer.js'; +import { readSseData } from './sse-reader.js'; + +export type ConnectionState = 'loading' | 'unreachable' | 'reconnecting' | 'live'; +export type ConnectionWhy = null | 'auth' | 'transport'; +export type BootstrapPhase = 'pending' | 'scoped' | 'empty' | 'mismatch'; + +export type HttpMismatch = { status: number }; + +export type AppState = { + connection: ConnectionState; + connectionWhy: ConnectionWhy; + bootstrap: BootstrapPhase; + bootstrapMismatch: BootstrapMismatch | null; + httpMismatch: HttpMismatch | null; + reducer: ReducerState; +}; + +export type StreamDeps = { + fetch: typeof globalThis.fetch; + now?: () => string; + getKey?: () => string | undefined; + onState?: (state: AppState) => void; +}; + +export type Session = { + stop: () => void; + getState: () => AppState; +}; + +const BACKOFF_START = 1000; +const BACKOFF_CAP = 15_000; + +export function reconnectBackoff(ms: number, cb: () => void): unknown { + return setTimeout(cb, ms); +} + +export function initialAppState(): AppState { + return { + connection: 'loading', + connectionWhy: null, + bootstrap: 'pending', + bootstrapMismatch: null, + httpMismatch: null, + reducer: initialReducerState(), + }; +} + +function eventsUrl(paths: string[], resume: { since: number; stream: string } | null): string { + const q = `scope=${encodeScope(paths)}`; + if (!resume) return `/v2/events?${q}`; + return `/v2/events?${q}&since=${resume.since}&stream=${encodeURIComponent(resume.stream)}`; +} + +function classifyStreamStatus(status: number): 'mismatch' | 'auth' | 'retry' { + if (status === 401 || status === 403) return 'auth'; + if (status >= 500) return 'retry'; + return 'mismatch'; +} + +export function connect(deps: StreamDeps): Session { + const state = initialAppState(); + const ctrl = new AbortController(); + let stopped = false; + let timer: unknown = null; + let delay = BACKOFF_START; + const fetchFn = deps.fetch; + const now = deps.now ?? (() => new Date().toISOString()); + const getKey = deps.getKey ?? getTowerKey; + + function emit(): void { + deps.onState?.({ + ...state, + bootstrapMismatch: state.bootstrapMismatch ? { ...state.bootstrapMismatch } : null, + httpMismatch: state.httpMismatch ? { ...state.httpMismatch } : null, + }); + } + + function leaveUnreachable(): void { + if (state.connection === 'unreachable') { + state.connection = 'loading'; + state.connectionWhy = null; + } + } + + function cancelBody(res: Response): void { + void res.body?.cancel().catch(() => {}); + } + + function sessionBackoff(ms: number, cb: () => void): unknown { + timer = reconnectBackoff(ms, () => { + timer = null; + cb(); + }); + return timer; + } + + function stop(): void { + if (stopped) return; + stopped = true; + ctrl.abort(); + if (timer !== null) { + clearTimeout(timer as number); + timer = null; + } + } + + function resetDelay(): void { + delay = BACKOFF_START; + } + + function waitBackoff(): Promise { + return new Promise((resolve) => { + const ms = delay; + delay = Math.min(delay * 2, BACKOFF_CAP); + sessionBackoff(ms, () => resolve()); + }); + } + + function currentResume(): { since: number; stream: string } | null { + const id = state.reducer.cursor.streamId; + if (!id) return null; + return { since: state.reducer.cursor.seq, stream: id }; + } + + async function openOnce( + paths: string[], + resume: { since: number; stream: string } | null, + ): Promise< + | 'halt' + | 'recover-fresh' + | 'eof' + | 'applied-eof' + | 'retry' + | 'applied-retry' + | 'no-retry' + | 'aborted' + > { + if (stopped) return 'aborted'; + const key = getKey(); + const headers: Record = {}; + if (key) headers[TOWER_KEY_HEADER] = key; + let res: Response; + try { + res = await fetchFn(eventsUrl(paths, resume), { headers, signal: ctrl.signal }); + } catch { + if (stopped || ctrl.signal.aborted) return 'aborted'; + state.connection = 'unreachable'; + state.connectionWhy = 'transport'; + emit(); + return 'retry'; + } + if (stopped) return 'aborted'; + if (res.status !== 200) { + cancelBody(res); + const kind = classifyStreamStatus(res.status); + if (kind === 'auth') { + state.connection = 'unreachable'; + state.connectionWhy = 'auth'; + emit(); + return 'no-retry'; + } + if (kind === 'mismatch') { + leaveUnreachable(); + state.httpMismatch = { status: res.status }; + emit(); + return 'no-retry'; + } + state.connection = 'unreachable'; + state.connectionWhy = 'transport'; + emit(); + return 'retry'; + } + + const body = res.body; + if (!body) return 'eof'; + + let applied = false; + try { + for await (const data of readSseData(body)) { + if (stopped) return 'aborted'; + const result = applyFrame(state.reducer, data, now()); + state.reducer = result.state; + if (result.effect === 'none' && result.state.mismatch === null) { + applied = true; + state.connection = 'live'; + state.connectionWhy = null; + state.httpMismatch = null; + resetDelay(); + } + if (result.state.mismatch !== null) leaveUnreachable(); + emit(); + if (result.effect === 'recover-fresh') return 'recover-fresh'; + if (result.effect === 'halt') return 'halt'; + } + } catch { + if (stopped || ctrl.signal.aborted) return 'aborted'; + state.connection = 'unreachable'; + state.connectionWhy = 'transport'; + emit(); + return applied ? 'applied-retry' : 'retry'; + } + if (stopped) return 'aborted'; + return applied ? 'applied-eof' : 'eof'; + } + + async function streamLoop(paths: string[]): Promise { + let forceFresh = false; + while (!stopped) { + const resume = forceFresh ? null : currentResume(); + const outcome = await openOnce(paths, resume); + if (stopped || outcome === 'aborted' || outcome === 'halt' || outcome === 'no-retry') return; + if (outcome === 'recover-fresh') { + forceFresh = true; + continue; + } + if (outcome === 'applied-eof' || outcome === 'applied-retry') forceFresh = false; + if ((outcome === 'eof' || outcome === 'applied-eof') && !forceFresh) { + state.connection = 'reconnecting'; + emit(); + } + await waitBackoff(); + } + } + + async function run(): Promise { + const boot = await runBootstrap({ + fetch: fetchFn, + key: getKey(), + signal: ctrl.signal, + reconnectBackoff: sessionBackoff, + onUnreachable: (why) => { + state.connection = 'unreachable'; + state.connectionWhy = why; + state.bootstrap = 'pending'; + emit(); + }, + onMismatch: (m) => { + leaveUnreachable(); + state.bootstrap = 'mismatch'; + state.bootstrapMismatch = m; + emit(); + }, + }); + if (stopped) return; + if (boot.kind === 'aborted') return; + if (boot.kind === 'empty') { + state.bootstrap = 'empty'; + state.bootstrapMismatch = null; + state.connection = 'live'; + state.connectionWhy = null; + resetDelay(); + emit(); + return; + } + if (boot.kind === 'mismatch') { + leaveUnreachable(); + state.bootstrap = 'mismatch'; + state.bootstrapMismatch = boot.mismatch; + emit(); + return; + } + leaveUnreachable(); + state.bootstrap = 'scoped'; + state.bootstrapMismatch = null; + state.connectionWhy = null; + resetDelay(); + emit(); + await streamLoop(boot.paths); + } + + void run(); + + return { stop, getState: () => state }; +} diff --git a/apps/v2/src/lib/tree.ts b/apps/v2/src/lib/tree.ts new file mode 100644 index 000000000..18eb6bb49 --- /dev/null +++ b/apps/v2/src/lib/tree.ts @@ -0,0 +1,90 @@ +import type { DarkEntry } from './reducer.js'; +import type { ClientNode } from './validate.js'; + +export type ArchitectGroup = { + node: ClientNode; + builders: ClientNode[]; +}; + +export type WorkspacePlotModel = { + id: string; + name: string; + status: string | null; + flags: { heldMail: boolean }; + dark: DarkEntry | null; + architects: ArchitectGroup[]; + builders: ClientNode[]; +}; + +export type TreeModel = { + plots: WorkspacePlotModel[]; + orphanArchitects: ArchitectGroup[]; + orphanBuilders: ClientNode[]; +}; + +const WS_PREFIX = 'workspace:'; + +export function workspaceLabel(id: string): string { + const path = id.startsWith(WS_PREFIX) ? id.slice(WS_PREFIX.length) : id; + const parts = path.split('/').filter((p) => p.length > 0); + return parts[parts.length - 1] ?? path; +} + +export function buildTree( + nodes: Map, + darkPaths: Map, +): TreeModel { + const plots = new Map(); + + for (const n of nodes.values()) { + if (n.kind !== 'workspace') continue; + plots.set(n.id, { + id: n.id, + name: n.name, + status: n.status, + flags: { ...n.flags }, + dark: darkPaths.get(n.id) ?? null, + architects: [], + builders: [], + }); + } + + for (const [id, entry] of darkPaths) { + if (plots.has(id)) continue; + plots.set(id, { + id, + name: workspaceLabel(id), + status: null, + flags: { heldMail: false }, + dark: entry, + architects: [], + builders: [], + }); + } + + const architects = new Map(); + const orphanArchitects: ArchitectGroup[] = []; + for (const n of nodes.values()) { + if (n.kind !== 'architect') continue; + const group: ArchitectGroup = { node: n, builders: [] }; + architects.set(n.id, group); + const plot = n.parentId ? plots.get(n.parentId) : undefined; + if (plot) plot.architects.push(group); + else orphanArchitects.push(group); + } + + const orphanBuilders: ClientNode[] = []; + for (const n of nodes.values()) { + if (n.kind !== 'builder') continue; + const underArch = n.parentId ? architects.get(n.parentId) : undefined; + if (underArch) { + underArch.builders.push(n); + continue; + } + const plot = n.parentId ? plots.get(n.parentId) : undefined; + if (plot) plot.builders.push(n); + else orphanBuilders.push(n); + } + + return { plots: [...plots.values()], orphanArchitects, orphanBuilders }; +} diff --git a/apps/v2/src/lib/validate.ts b/apps/v2/src/lib/validate.ts new file mode 100644 index 000000000..33a7746de --- /dev/null +++ b/apps/v2/src/lib/validate.ts @@ -0,0 +1,257 @@ +export const TRACE_LEN = 20; +export const NODE_KINDS = ['workspace', 'architect', 'builder'] as const; +export const FRAME_TYPES = ['snapshot', 'node', 'gone', 'counts', 'tick', 'dark', 'resumed'] as const; + +export type NodeKind = (typeof NODE_KINDS)[number]; +export type FrameType = (typeof FRAME_TYPES)[number]; + +export type ClientNode = { + id: string; + kind: NodeKind; + parentId: string | null; + name: string; + status: string; + flags: { heldMail: boolean }; + lastDataAt: string | null; + buckets?: number[]; +}; + +export type ClientCounts = { + workspaces: number; + builders: { total: number; byStatus: Record }; + gateWaiting: number; +}; + +export type ValidatedSnapshot = { + seq: number; + type: 'snapshot'; + streamId: string; + resumed: boolean; + nodes: ClientNode[]; + counts: ClientCounts; +}; + +export type ValidatedNode = { seq: number; type: 'node'; node: ClientNode }; +export type ValidatedGone = { seq: number; type: 'gone'; id: string }; +export type ValidatedCounts = { seq: number; type: 'counts'; counts: ClientCounts }; +export type ValidatedTick = { seq: number; type: 'tick'; at: string; buckets: Record }; +export type ValidatedDark = { seq: number; type: 'dark'; id: string; reason: string; path: string }; +export type ValidatedResumed = { seq: number; type: 'resumed'; from: number }; + +export type ValidatedFrame = + | ValidatedSnapshot + | ValidatedNode + | ValidatedGone + | ValidatedCounts + | ValidatedTick + | ValidatedDark + | ValidatedResumed; + +export type Mismatch = { + how: 'invalid-json' | 'unknown-type' | 'bad-field'; + afterSeq: number; + preview?: string; + seq?: number; + type?: string; + field?: string; +}; + +export type ValidateOk = { ok: true; frame: ValidatedFrame }; +export type ValidateErr = { ok: false; mismatch: Mismatch }; +export type ValidateResult = ValidateOk | ValidateErr; + +function isSafeNonNegInt(n: unknown): n is number { + return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; +} + +function isNonNegInt(n: unknown): n is number { + return typeof n === 'number' && Number.isInteger(n) && n >= 0 && Number.isFinite(n); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function fail(afterSeq: number, field: string, obj: Record): ValidateErr { + return { + ok: false, + mismatch: { + how: 'bad-field', + afterSeq, + type: typeof obj.type === 'string' ? obj.type : undefined, + seq: typeof obj.seq === 'number' ? obj.seq : undefined, + field, + }, + }; +} + +export function escapePreview(s: string): string { + const bytes = new TextEncoder().encode(s).subarray(0, 120); + let out = ''; + for (const b of bytes) { + out += b >= 0x20 && b <= 0x7e + ? String.fromCharCode(b) + : `\\x${b.toString(16).padStart(2, '0')}`; + } + return out; +} + +function validateCounts(raw: unknown): ClientCounts | string { + if (!isPlainObject(raw)) return 'counts'; + if (!isNonNegInt(raw.workspaces)) return 'counts.workspaces'; + if (!isPlainObject(raw.builders)) return 'counts.builders'; + if (!isNonNegInt(raw.builders.total)) return 'counts.builders.total'; + if (!isPlainObject(raw.builders.byStatus)) return 'counts.builders.byStatus'; + for (const v of Object.values(raw.builders.byStatus)) { + if (!isNonNegInt(v)) return 'counts.builders.byStatus'; + } + if (!isNonNegInt(raw.gateWaiting)) return 'counts.gateWaiting'; + const byStatus: Record = {}; + for (const [k, v] of Object.entries(raw.builders.byStatus)) { + byStatus[k] = v as number; + } + return { + workspaces: raw.workspaces, + builders: { total: raw.builders.total, byStatus }, + gateWaiting: raw.gateWaiting, + }; +} + +function validateNode(raw: unknown): ClientNode | string { + if (!isPlainObject(raw)) return 'node'; + if (typeof raw.id !== 'string' || raw.id === '') return 'id'; + if (raw.kind !== 'workspace' && raw.kind !== 'architect' && raw.kind !== 'builder') return 'kind'; + if (!(typeof raw.parentId === 'string' || raw.parentId === null)) return 'parentId'; + if (typeof raw.name !== 'string') return 'name'; + if (typeof raw.status !== 'string') return 'status'; + if (!isPlainObject(raw.flags) || typeof raw.flags.heldMail !== 'boolean') return 'flags.heldMail'; + if (!(typeof raw.lastDataAt === 'string' || raw.lastDataAt === null)) return 'lastDataAt'; + if (raw.buckets !== undefined) { + if (!Array.isArray(raw.buckets) || raw.buckets.some((n) => typeof n !== 'number' || !Number.isFinite(n))) { + return 'buckets'; + } + } + return { + id: raw.id, + kind: raw.kind, + parentId: raw.parentId, + name: raw.name, + status: raw.status, + flags: { heldMail: raw.flags.heldMail }, + lastDataAt: raw.lastDataAt, + buckets: raw.buckets as number[] | undefined, + }; +} + +function parseDarkId(id: string): string | null { + if (!id.startsWith('workspace:')) return null; + const path = id.slice('workspace:'.length); + return path === '' ? null : path; +} + +export function validateFrame(obj: unknown, afterSeq: number): ValidateResult { + if (!isPlainObject(obj)) { + return { ok: false, mismatch: { how: 'bad-field', afterSeq, field: 'type' } }; + } + if (!isSafeNonNegInt(obj.seq)) { + return fail(afterSeq, 'seq', obj); + } + if (typeof obj.type !== 'string') { + return fail(afterSeq, 'type', obj); + } + if (!(FRAME_TYPES as readonly string[]).includes(obj.type)) { + return { + ok: false, + mismatch: { + how: 'unknown-type', + afterSeq, + type: obj.type, + seq: obj.seq, + }, + }; + } + + switch (obj.type) { + case 'snapshot': { + if (typeof obj.streamId !== 'string' || obj.streamId === '') return fail(afterSeq, 'streamId', obj); + if (typeof obj.resumed !== 'boolean') return fail(afterSeq, 'resumed', obj); + if (!Array.isArray(obj.nodes)) return fail(afterSeq, 'nodes', obj); + const nodes: ClientNode[] = []; + for (const el of obj.nodes) { + const n = validateNode(el); + if (typeof n === 'string') return fail(afterSeq, `nodes.${n}`, obj); + nodes.push(n); + } + const counts = validateCounts(obj.counts); + if (typeof counts === 'string') return fail(afterSeq, counts, obj); + return { + ok: true, + frame: { + seq: obj.seq, + type: 'snapshot', + streamId: obj.streamId, + resumed: obj.resumed, + nodes, + counts, + }, + }; + } + case 'node': { + const n = validateNode(obj.node); + if (typeof n === 'string') return fail(afterSeq, `node.${n}`, obj); + return { ok: true, frame: { seq: obj.seq, type: 'node', node: n } }; + } + case 'gone': { + if (typeof obj.id !== 'string' || obj.id === '') return fail(afterSeq, 'id', obj); + return { ok: true, frame: { seq: obj.seq, type: 'gone', id: obj.id } }; + } + case 'counts': { + const counts = validateCounts(obj.counts); + if (typeof counts === 'string') return fail(afterSeq, counts, obj); + return { ok: true, frame: { seq: obj.seq, type: 'counts', counts } }; + } + case 'tick': { + if (typeof obj.at !== 'string') return fail(afterSeq, 'at', obj); + if (!isPlainObject(obj.buckets)) return fail(afterSeq, 'buckets', obj); + const buckets: Record = {}; + for (const [k, v] of Object.entries(obj.buckets)) { + if (typeof v !== 'number' || !Number.isFinite(v)) return fail(afterSeq, 'buckets', obj); + buckets[k] = v; + } + return { ok: true, frame: { seq: obj.seq, type: 'tick', at: obj.at, buckets } }; + } + case 'dark': { + if (typeof obj.id !== 'string') return fail(afterSeq, 'id', obj); + const path = parseDarkId(obj.id); + if (path === null) return fail(afterSeq, 'id', obj); + if (typeof obj.reason !== 'string') return fail(afterSeq, 'reason', obj); + return { ok: true, frame: { seq: obj.seq, type: 'dark', id: obj.id, reason: obj.reason, path } }; + } + case 'resumed': { + if (!isSafeNonNegInt(obj.from)) return fail(afterSeq, 'from', obj); + return { ok: true, frame: { seq: obj.seq, type: 'resumed', from: obj.from } }; + } + default: + return { + ok: false, + mismatch: { how: 'unknown-type', afterSeq, type: obj.type, seq: obj.seq }, + }; + } +} + +export function parseAndValidate(line: string, afterSeq: number): ValidateResult { + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + return { + ok: false, + mismatch: { + how: 'invalid-json', + afterSeq, + preview: escapePreview(line), + }, + }; + } + return validateFrame(obj, afterSeq); +} diff --git a/apps/v2/src/lib/view.ts b/apps/v2/src/lib/view.ts new file mode 100644 index 000000000..6c86c2926 --- /dev/null +++ b/apps/v2/src/lib/view.ts @@ -0,0 +1,16 @@ +import type { AppState } from './stream.js'; + +export type ViewKind = 'loading' | 'unreachable' | 'mismatch' | 'empty' | 'site'; + +export function viewKind(state: AppState): ViewKind { + if (state.connection === 'unreachable') return 'unreachable'; + if (state.bootstrap === 'mismatch' || state.reducer.mismatch !== null || state.httpMismatch !== null) { + return 'mismatch'; + } + if (state.bootstrap === 'empty') return 'empty'; + if (state.reducer.nodes.size === 0 && state.reducer.darkPaths.size === 0) { + if (state.connection === 'live') return 'empty'; + return 'loading'; + } + return 'site'; +} diff --git a/apps/v2/src/main.tsx b/apps/v2/src/main.tsx new file mode 100644 index 000000000..754a6ba98 --- /dev/null +++ b/apps/v2/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import './tokens.css'; +import './site.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/v2/src/site.css b/apps/v2/src/site.css new file mode 100644 index 000000000..c4aa92789 --- /dev/null +++ b/apps/v2/src/site.css @@ -0,0 +1,182 @@ +html, body, #root { + margin: 0; + min-height: 100%; + background: var(--bone); + color: var(--ink); + font-family: var(--font-sans); +} + +.page { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.lot-header { + border-bottom: var(--rule) solid var(--ink); + background: var(--chalk); + padding: 16px 24px; + display: flex; + align-items: baseline; + gap: 12px; +} + +.lot-header h1 { + margin: 0; + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; +} + +.lot-header .stamp { + font-size: 11px; + text-transform: uppercase; + color: var(--graphite); +} + +.site-main { + flex: 1; + padding: 24px; +} + +.lot { + position: relative; + padding: 20px 16px 16px; +} + +.lot-tag { + position: absolute; + top: 0; + left: 12px; + transform: translateY(-50%); + background: var(--chalk); + font-size: 10px; + text-transform: uppercase; + padding: 0 8px; + border: 1px solid var(--ink); +} + +.plot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.ws-plot { + border: var(--rule) solid var(--ink); + background: var(--bone); + padding: 12px; +} + +.ws-plot-name { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + margin-bottom: 12px; +} + +.arch-block { + margin-bottom: 8px; +} + +.arch-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.unattached { + border-style: dashed; +} + +.arch-row .stamp { + font-size: 11px; + font-weight: 500; +} + +.stake-list { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 12px; +} + +.stake { + background: var(--chalk); + padding: 6px 8px 6px 8px; +} + +.stake-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.stake-name { + font-size: 11px; +} + +.held-mail { + font-size: 9px; + text-transform: uppercase; + color: var(--graphite); + margin-left: 6px; +} + +.stamp-gate { color: var(--rust); font-size: 9px; text-transform: uppercase; } +.stamp-stalled { color: var(--ochre); font-size: 9px; text-transform: uppercase; } +.stamp-run { color: var(--moss); font-size: 9px; text-transform: uppercase; } +.stamp-offline { color: var(--graphite); font-size: 9px; text-transform: uppercase; } +.stamp-unknown { color: var(--graphite); font-size: 9px; text-transform: uppercase; } + +.dark-meta { + font-size: 11px; + color: var(--graphite); + margin-top: 8px; +} + +.machine-footer { + border-top: var(--rule) solid var(--ink); + background: var(--chalk); + padding: 12px 24px; + font-size: 11px; + text-transform: uppercase; + color: var(--graphite); +} + +.banner { + padding: 16px 24px; + border-bottom: var(--rule) solid var(--ink); + background: var(--chalk); +} + +.banner h2 { + margin: 0 0 6px; + font-family: var(--font-display); + font-size: 20px; +} + +.banner p { + margin: 0; + color: var(--graphite); + font-size: 13px; +} + +.reconnecting { + padding: 8px 24px; + background: var(--chalk); + border-bottom: 1px solid var(--concrete); + font-size: 11px; + text-transform: uppercase; + color: var(--graphite); +} + +.empty-copy { + padding: 48px 24px; + color: var(--graphite); +} diff --git a/apps/v2/src/tokens-dark.css b/apps/v2/src/tokens-dark.css new file mode 100644 index 000000000..4cae1805d --- /dev/null +++ b/apps/v2/src/tokens-dark.css @@ -0,0 +1,131 @@ +/* + * Codev v2 — dark palette. Designed, not derived. + * Issue #63. Companion to tokens.css. + * + * The light page is a lit room holding dark screens. Invert that and the + * screens vanish into the walls. This file keeps the room: warm paper, now + * unlit. Terminals and the gate rail drop into --well, a cooler hole. + * --ink is the mark (bone-dust). It cannot also be the well. Light mode + * got away with one token doing both jobs. This one cannot. + * + * Contrast for every pairing the dark mockups use: + * codev/experiments/63-v2-dark-palette/artifacts/contrast.md + */ + +:root { + /* ---- ground and ink ---- */ + --bone: #2A251E; /* page. dusk paper, not OLED, not grey */ + --chalk: #353027; /* raised: cards, plots, header, footer, tickets */ + --concrete: #8A8070; /* rules, dividers, blueprint grid */ + --ink: #EDE4D4; /* text and primary borders. bone-dust, never white */ + --graphite: #C4B8A4; /* secondary text. do not fade it further */ + --well: #0A0908; /* terminals and the gate rail. the hole */ + + /* ---- signal ---- */ + --rust: #ED7C48; /* GATES AND ATTENTION ONLY. recast so 11px stamps pass AA */ + --rustdark: #D26432; /* rust hover/pressed */ + --ochre: #D9A84C; /* warnings, stalled work, terminal caution lines */ + --moss: #9AAA86; /* healthy, running, online. sage, not mint */ + + /* ---- type ---- */ + --font-display: "Fraunces", Georgia, serif; + --font-sans: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + + /* ---- structure ---- */ + --rule: 1.5px; /* same weight. the stroke is dusty ink, not a white wire */ +} + +/* + * --well is not a fifth signal. rust / ochre / moss / ink still do that work. + * --well is the job --ink lost when it became the mark. + */ + +/* ---------- the recurring patterns ---------- */ + +.stamp { + font-family: var(--font-mono); + letter-spacing: 0.06em; +} + +.grid-bg { + background-image: + linear-gradient(var(--concrete) 1px, transparent 1px), + linear-gradient(90deg, var(--concrete) 1px, transparent 1px); + background-size: 24px 24px; +} + +.plot { + border: var(--rule) solid var(--ink); + background: var(--chalk); +} + +.corner::before { + content: ''; + position: absolute; + top: -1px; + left: -1px; + width: 10px; + height: 10px; + border-top: var(--rule) solid var(--ink); + border-left: var(--rule) solid var(--ink); +} + +.stake { + border-left: 3px solid var(--ink); +} + +/* + * Offline machine: keep the shape, kill the chroma. + * opacity: 0.42 on a dark ground reads as "closer to the page". + * Full opacity, full grayscale: still a lot, no longer alive. + */ +.dim-sub { + opacity: 1; + filter: grayscale(1); +} + +.spark { + display: flex; + align-items: flex-end; + gap: 2px; + height: 22px; +} +.spark i { + display: block; + width: 3px; + background: var(--ink); +} + +.needs-attn { animation: pulseAttn 2.2s ease-in-out infinite; } +@keyframes pulseAttn { + 0%, 100% { box-shadow: 0 0 0 0 rgba(237, 124, 72, 0); } + 50% { box-shadow: 0 0 0 3px rgba(237, 124, 72, 0.22); } +} +@media (prefers-reduced-motion: reduce) { + .needs-attn { animation: none; box-shadow: 0 0 0 2px rgba(237, 124, 72, 0.4); } +} + +.ticket-edge { + background-image: repeating-linear-gradient(90deg, var(--ink) 0 10px, transparent 10px 18px); + height: 2px; +} + +/* Light scratches on dusk paper. Distinguishes stopped from the site grid. */ +.hatch { + background-image: repeating-linear-gradient(135deg, var(--ink) 0, var(--ink) 1px, transparent 1px, transparent 8px); + opacity: 0.07; +} + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: var(--concrete); border-radius: 0; } +::-webkit-scrollbar-track { background: transparent; } + +/* + * COLOUR DISCIPLINE — unchanged: + * rust = a human is needed. Gates, and nothing else. + * ochre = something may be wrong but nobody is blocked. Stalled, warnings. + * moss = healthy. Running, online. + * ink = everything else. + * There is no fifth colour, and rust is never used for emphasis. + */ diff --git a/apps/v2/src/tokens.css b/apps/v2/src/tokens.css new file mode 100644 index 000000000..216997ac1 --- /dev/null +++ b/apps/v2/src/tokens.css @@ -0,0 +1,143 @@ +/* + * Codev v2 — design tokens, extracted from the approved UX Pilot mockups. + * Source: uxpilot page SXl8jE8uNyYLwBsG6vsL, 2026-08-21. + * + * The mockups ship these as a Tailwind CDN config. This file is the + * framework-neutral form so apps/v2 can adopt them without inheriting + * Tailwind-via-CDN, which is a prototype convenience, not a build target. + */ + +:root { + /* ---- ground and ink ---- */ + --bone: #EDE8DE; /* page ground. warm, not grey, not white */ + --chalk: #F6F3EB; /* raised surfaces: cards, plots, header, footer */ + --concrete: #DAD3C4; /* rules, dividers, blueprint grid lines */ + --ink: #221F1A; /* text, borders, terminal ground */ + --graphite: #4A463D; /* secondary text, muted labels */ + + /* ---- signal ---- */ + --rust: #B5502A; /* GATES AND ATTENTION ONLY. never decoration */ + --rustdark: #8C3D1F; /* rust hover/pressed */ + --ochre: #C08A2E; /* warnings, stalled work, terminal caution lines */ + --moss: #5C6B4F; /* healthy, running, online */ + + /* ---- type ---- */ + --font-display: "Fraunces", Georgia, serif; + --font-sans: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + + /* ---- structure ---- */ + --rule: 1.5px; /* the border weight the whole design rests on */ +} + +/* + * NOTE ON THE SANS FACE + * Was Space Grotesk, which is on the standard "AI-generated design" tell list. + * Swapped for IBM Plex Sans: same superfamily as the mono already carrying most + * of the UI text, so it harmonises by construction rather than by taste, and it + * drops a font dependency instead of adding one. The sans carries almost no + * weight here anyway — nearly all UI text is IBM Plex Mono (.stamp) or Fraunces + * (display). + */ + +/* ---------- the recurring patterns ---------- */ + +/* Uppercase letterspaced micro-label. Used for nearly every piece of UI + chrome: MACHINE LOT, LIVE PANES, THE QUESTION, HELD FOR. */ +.stamp { + font-family: var(--font-mono); + letter-spacing: 0.06em; +} + +/* Blueprint grid. Background of the site view only. */ +.grid-bg { + background-image: + linear-gradient(var(--concrete) 1px, transparent 1px), + linear-gradient(90deg, var(--concrete) 1px, transparent 1px); + background-size: 24px 24px; +} + +/* A machine's surveyed lot. Containment is how hierarchy is shown. */ +.plot { + border: var(--rule) solid var(--ink); + background: var(--chalk); +} + +/* Surveyor's corner tick on a plot. Small, and does a lot of the work. */ +.corner::before { + content: ''; + position: absolute; + top: -1px; + left: -1px; + width: 10px; + height: 10px; + border-top: var(--rule) solid var(--ink); + border-left: var(--rule) solid var(--ink); +} + +/* A builder row. The left stake reads as driven into the workspace. */ +.stake { + border-left: 3px solid var(--ink); +} + +/* Offline machine: keeps its shape, loses its life. Never hidden. */ +.dim-sub { + opacity: 0.42; + filter: grayscale(55%); +} + +/* Per-builder activity trace. Bar heights are output volume over time. + This is the one piece of real information design in the mockup: + working vs stalled is legible without reading a word. */ +.spark { + display: flex; + align-items: flex-end; + gap: 2px; + height: 22px; +} +.spark i { + display: block; + width: 3px; + background: var(--ink); +} + +/* Something needs a human. The only animation in the design. */ +.needs-attn { animation: pulseAttn 2.2s ease-in-out infinite; } +@keyframes pulseAttn { + 0%, 100% { box-shadow: 0 0 0 0 rgba(181, 80, 42, 0); } + 50% { box-shadow: 0 0 0 3px rgba(181, 80, 42, 0.18); } +} +@media (prefers-reduced-motion: reduce) { + .needs-attn { animation: none; box-shadow: 0 0 0 2px rgba(181, 80, 42, 0.3); } +} + +/* Torn-ticket rule under the gate header. */ +.ticket-edge { + background-image: repeating-linear-gradient(90deg, var(--ink) 0 10px, transparent 10px 18px); + height: 2px; +} + +/* Diagonal hatch behind the gate view. Distinguishes "stopped" from the + site view's grid, which reads as "in progress". */ +.hatch { + background-image: repeating-linear-gradient(135deg, var(--ink) 0, var(--ink) 1px, transparent 1px, transparent 8px); + opacity: 0.06; +} + +/* Square scrollbars. No rounding anywhere in this design. */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: #C7BFAC; border-radius: 0; } +::-webkit-scrollbar-track { background: transparent; } + +/* + * COLOUR DISCIPLINE — the rule that keeps this from becoming noise: + * rust = a human is needed. Gates, and nothing else. + * ochre = something may be wrong but nobody is blocked. Stalled, warnings. + * moss = healthy. Running, online. + * ink = everything else. + * There is no fifth colour, and rust is never used for emphasis. + * + * DARK MODE is tokens-dark.css. Designed, not derived. The light terminals + * are already ink-on-chalk inversions; inverting the whole page destroys + * that. The dark file keeps the room and adds --well for the screens. + */ diff --git a/apps/v2/src/vite-env.d.ts b/apps/v2/src/vite-env.d.ts new file mode 100644 index 000000000..42bdc7d69 --- /dev/null +++ b/apps/v2/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +interface Window { + __CODEV_TOWER_KEY__?: string; +} + diff --git a/apps/v2/tsconfig.json b/apps/v2/tsconfig.json new file mode 100644 index 000000000..b88248e7b --- /dev/null +++ b/apps/v2/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/apps/v2/vite.config.ts b/apps/v2/vite.config.ts new file mode 100644 index 000000000..9c63b1145 --- /dev/null +++ b/apps/v2/vite.config.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { defineConfig, type Plugin } from 'vite'; +import react from '@vitejs/plugin-react'; + +const KEY_RE = /^[0-9a-f]{64}$/; + +function readDevKey(): string | null { + const env = process.env.CODEV_TOWER_KEY?.trim(); + if (env && KEY_RE.test(env)) return env; + try { + const raw = fs.readFileSync(path.join(os.homedir(), '.agent-farm', 'local-key'), 'utf8').trim(); + return KEY_RE.test(raw) ? raw : null; + } catch { + return null; + } +} + +function injectDevKey(): Plugin { + return { + name: 'inject-dev-key', + apply: 'serve', + transformIndexHtml(html) { + const key = readDevKey(); + if (!key || !html.includes('')) return html; + const tag = ``; + return html.replace('', `${tag}`); + }, + }; +} + +export default defineConfig({ + plugins: [react(), injectDevKey()], + server: { + proxy: { + '/api': 'http://localhost:4100', + '/v2/events': 'http://localhost:4100', + }, + }, + base: '/v2/', + build: { + outDir: 'dist', + sourcemap: false, + }, +}); diff --git a/apps/v2/vitest.config.ts b/apps/v2/vitest.config.ts new file mode 100644 index 000000000..11f975aee --- /dev/null +++ b/apps/v2/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + exclude: ['**/node_modules/**', '**/e2e/**'], + }, +}); diff --git a/codev/plans/83-v2-client-shell.md b/codev/plans/83-v2-client-shell.md new file mode 100644 index 000000000..917e5eb14 --- /dev/null +++ b/codev/plans/83-v2-client-shell.md @@ -0,0 +1,479 @@ +# Plan: v2 client shell — apps/v2 renders the live hierarchy + +**Specification**: [codev/specs/83-v2-client-shell.md](../specs/83-v2-client-shell.md) + +## Executive Summary + +Approach A from the spec: React 19 + Vite in a new fork-owned `apps/v2` workspace, one reducer over the frame stream. The client imports **types** from `@cluesmith/codev-types` and **no SDK behaviour** (C3, D7). It writes its own bootstrap `fetch`, its own SSE reader, and its own reconnect. + +Serving reuses the existing `/v2/` prefix branch in `tower-routes.ts` (byte-frozen). A new `v2-static.ts` plus a one-branch change to the `handleV2Route` prologue is the only server seam. `isPublicRoute` gains the two GET clauses in D9. Packaging follows the `dashboard-dist` precedent as `v2-dist` (D14). + +The site view is containment from `01-site.html` plus `tokens.css` as shipped. No Tailwind, no Font Awesome, no `recharts`. D8 chrome (gate rail, Find node, Add machine, terminal bank, palette) is omitted entirely, not stubbed. FR-3 is rendered from `parentId` as the wire sent it (D13, rev. 12). FR-15 is deferred (D5). #97 is closed; the leftover defect is #100. + +## Grounded seams (verified) + +| Need | Use | Do not use | +|---|---|---| +| Stream | `GET /v2/events` via `fetch` + `ReadableStream` + `codev-tower-key` | `EventSource`; `subscribeEvents` / `parseSseText` (wrong endpoint, `{type,body}` envelope) | +| Scope paths | One raw `GET /api/workspaces`, branch on HTTP status, validate every `path` | `listWorkspaces()` (`tower-client.ts:400-403` collapses 401/500/dead into `[]`) | +| Scope query | `scope=,` with a **literal** comma (D12) | `encodeURIComponent(paths.join(','))` | +| Key | `window.__CODEV_TOWER_KEY__` injected by `v2-static.ts` | Exporting `injectWebKey` (module-private; `tower-routes.ts` is frozen) | +| Public shell | `isPublicRoute`: `GET /v2/` and `GET /v2/assets/*` | Widening `/v2/events` or any non-GET | +| Tokens | Copy `codev/research/v2-mockups/tokens.css` into `apps/v2/src/tokens.css` | Tailwind CDN from the mockup; `tokens-dark.css` is copied and unused | +| Sparkline | `.spark` + 20 `` bars | `recharts` | +| Machine name | `window.location.hostname` (D10) | A machine node; a new endpoint | +| Builder parent | `parentId` as the wire sent it | Name-matching a builder to an architect (D13) | +| Stall | Render `status: "stalled"` from the stream | Client-side `IDLE_WAITING_THRESHOLD_MS` | +| `gone` in Playwright | Fixture `gone` frame | `afx cleanup` (irreversible; human-only) | + +`parseScope` (`v2-routes.ts:80-99`) is below the prologue and frozen. Do not export it. Scenario 18 asserts the query-string shape in the client, then round-trips through `handleV2Route` (which already calls `parseScope`) with two known workspace paths. + +## File layout + +``` +apps/v2/ + package.json @cluesmith/codev-v2 + index.html base /v2/; no key placeholder + vite.config.ts base: '/v2/'; proxy /api and /v2/events → :4100 + vitest.config.ts jsdom + tsconfig.json house stack, noEmit + playwright.config.ts viewport 1440, fixture server + src/ + main.tsx + App.tsx page states: loading / unreachable / mismatch / empty / live + tokens.css vendored copy of the shipped file + tokens-dark.css vendored, not applied + site.css containment layout; no Tailwind + vite-env.d.ts + lib/ + key.ts read window.__CODEV_TOWER_KEY__ + encode-scope.ts D12 + validate.ts D1 read-set; seq rules + reducer.ts D1 table; two stores + counts + sse-reader.ts TextDecoder {stream:true}; scenario 27 + bootstrap.ts D7 + stream.ts D1 classification + D2 reconnect + tree.ts group by parentId; invent nothing + components/ + SiteView.tsx one machine lot + WorkspacePlot.tsx live plot or dark-from-id + ArchitectHeader.tsx + BuilderRow.tsx + Sparkline.tsx 20 bars + StatusStamp.tsx D3; unknown status visibly wrong + MachineFooter.tsx counts, labelled machine-wide + ConnectionBanner.tsx unreachable / mismatch / reconnecting; never rust + __tests__/ unit + component tests (scenarios below) + e2e/ Playwright, fixture HTTP server + +packages/codev/src/agent-farm/servers/v2-static.ts +packages/codev/src/agent-farm/__tests__/v2-static.test.ts +packages/codev/src/agent-farm/__tests__/v2-public-route.test.ts +packages/codev/src/agent-farm/__tests__/v2-packaging.e2e.test.ts +packages/codev/src/agent-farm/__tests__/v2-scope-encoding.test.ts # scenario 18 round-trip +``` + +## Shared implementation rules (every phase) + +**Two layers, one composed `AppState`. The reducer is the frame layer only.** + +``` +AppState = { + connection: 'loading' | 'unreachable' | 'reconnecting' | 'live', + connectionWhy: null | 'auth' | 'transport', + bootstrap: 'pending' | 'scoped' | 'empty' | 'mismatch', + bootstrapMismatch: null | { how, preview? }, + reducer: { // frames only. No unreachable here. + nodes, darkPaths, counts, + cursor: { streamId, seq }, + mismatch: null | { how, seq?, type?, field?, preview?, afterSeq }, + mismatchAttempts, + }, +} +``` + +Display, in this order: + +1. `connection === 'unreachable'` → connection banner (auth-labelled when `connectionWhy === 'auth'`). Not the empty site. +2. `bootstrap === 'mismatch'` or `reducer.mismatch` → mismatch page. Not unreachable. +3. `bootstrap === 'empty'` **or** (`connection === 'live'` and `nodes.size === 0` and `darkPaths.size === 0`) → empty site. +4. Else SiteView. `nodes: []` plus a `dark` entry is a dark plot, not empty (scenario 21). + +`App.tsx` is the only composer. The reducer never sees a failed fetch. Bootstrap mismatch and frame mismatch share a render, not a store. + +Every accepted frame advances `cursor.seq` to that frame's `seq`. A `snapshot` with a new `streamId` resets the baseline and **replaces `darkPaths` wholesale** before applying that snapshot's own `dark` frames. + +**Validate before reduce.** The read-set in D1 is the closed list. Extra fields are ignored (scenario 35). `status` value is not validated (scenario 31). `seq` is a finite non-negative safe integer. Ordering is non-decreasing **within** a `streamId`; across `streamId`s there is no comparison. + +**New builder, no buckets.** On `node` upsert: known id → leave trace; new `kind === 'builder'` → 20 zeros. `tick` is the only later writer. Absent id in `tick.buckets` appends 0. + +**Timers.** Zero `setInterval` under `apps/v2/src`. Exactly one `setTimeout`, inside `reconnectBackoff` in `stream.ts`. Bootstrap retries and stream reconnects both call that function. Start 1s, cap 15s, reset on a successful frame or a successful bootstrap 200. No refetch on focus or visibility. + +**Retry policy.** + +| Cause | State | Retry | +|---|---|---| +| Stream 400 / 404 / 405 | mismatch | none | +| Stream 401 / 403 | unreachable, labelled auth | none | +| Stream 5xx or thrown fetch | unreachable | backoff, indefinitely | +| Bad frame | mismatch | one fresh snapshot (no `since`/`stream`); second bad frame stops | +| Valid fresh snapshot after mismatch | live | budget resets | +| Bootstrap non-200 or thrown | unreachable | backoff, indefinitely, until one 200 | +| Bootstrap 200 unreadable or bad entry | mismatch | one retry, then stop | +| Bootstrap 200 with `[]` | empty | do not open the stream | + +**FR-49.** No client code may terminate a session. Grep `apps/v2/src` for `cleanup`, `destroy`, `kill`, `DELETE`. None. + +**Colour.** `--rust` only on `gate-waiting` stamps and `.needs-attn` on those rows. Reconnecting, mismatch, dark, heldMail, footer `gateWaiting` are not rust. + +**Frozen files stay frozen.** `git diff --stat` empty on every C1/C2 path, including `tower-routes.ts`. The only production edits outside `apps/v2/` are `v2-static.ts` (new), the `handleV2Route` prologue, the two `isPublicRoute` clauses, and D14 packaging. + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "phase_1", "title": "Workspace and /v2/ static serving"}, + {"id": "phase_2", "title": "Frame validation and reducer"}, + {"id": "phase_3", "title": "Bootstrap, stream reader, reconnect"}, + {"id": "phase_4", "title": "Site view"}, + {"id": "phase_5", "title": "Playwright fixture proof"} + ] +} +``` + +## Phase Breakdown + +### Phase 1: Workspace and /v2/ static serving + +**Dependencies**: None + +#### Objective + +`apps/v2` exists, builds, and is served at `GET /v2/` with the key injected. `npm pack` contains `v2-dist`. A browser with no header gets the page; `/v2/events` still 401s. + +#### Files to Create / Modify + +- `apps/v2/package.json` — `@cluesmith/codev-v2`, private, React 19 / Vite 6 / Vitest 4 / testing-library / jsdom. Dep: `@cluesmith/codev-types`. **No** `@cluesmith/codev-sdk`. No `recharts`. +- `apps/v2/index.html`, `src/main.tsx`, `src/App.tsx` — shell that renders a single heading so serving is observable. Replaced in phase 4. +- `apps/v2/vite.config.ts` — `base: '/v2/'`; `server.proxy` `/api` and `/v2/events` to `http://localhost:4100`. Dev-only (`apply: 'serve'`) `transformIndexHtml` plugin injects `window.__CODEV_TOWER_KEY__` from `CODEV_TOWER_KEY` or `~/.agent-farm/local-key` (64-hex check, `JSON.stringify`, before ``). Production injection stays in `v2-static.ts`. No `localStorage` fallback. +- `apps/v2/vitest.config.ts` — jsdom, `passWithNoTests: true` (phase 1 has no client tests yet; without this, `pnpm --filter @cluesmith/codev-v2 test` fails and porch's root `npm test` goes red) +- `apps/v2/tsconfig.json`, `src/vite-env.d.ts` +- `apps/v2/src/tokens.css`, `apps/v2/src/tokens-dark.css` — byte copies of the research files +- `packages/codev/src/agent-farm/servers/v2-static.ts` — see rules below +- `packages/codev/src/agent-farm/servers/v2-routes.ts` — **prologue only**: + ``` + if (url.pathname !== V2_EVENTS_PATH) { + serveV2Static(req, res, url); + return; + } + ``` + Everything from the existing `req.method !== 'GET'` check downward is untouched. +- `packages/codev/src/agent-farm/utils/server-utils.ts` — `isPublicRoute` clauses as listed under `v2-static.ts` rules below. No `/v2` alias. +- `packages/codev/package.json` — `files` += `v2-dist`; `copy-v2` script builds `@cluesmith/codev-v2` then `rm -rf v2-dist && cp -r ../../apps/v2/dist v2-dist`; `bundle-assets` runs `copy-v2`; `devDependencies` += `@cluesmith/codev-v2`; `test` becomes `vitest run && pnpm --filter @cluesmith/codev-v2 test` so porch's root `npm test` covers the client and does not hang in watch mode. +- `pnpm-lock.yaml` (workspace already lists `apps/*`; lockfile is the expected touch) +- Tests listed below + +`v2-static.ts` rules: + +1. ESM shim, same as `tower-server.ts:69-70`: `const __dirname = path.dirname(fileURLToPath(import.meta.url))`. Then resolve the asset root from `path.resolve(__dirname, '../../../v2-dist')`. Export `setV2DistRoot` for tests (same shape as `setV2RouteDeps`). +2. `GET /v2/` **only** → read `index.html`, inject key, strip `Access-Control-Allow-Origin` and `Vary`, `text/html; charset=utf-8`. **Do not handle bare `/v2`.** `tower-routes.ts:282` is `pathname.startsWith('/v2/')` and C1 freezes it, so `/v2` never reaches this function. D9 names `/v2/` and `/v2/assets/*` only. +3. Injection is a pure `injectV2Key(html, key)` that mirrors `injectWebKey` (`tower-routes.ts:2441`): embed via `JSON.stringify` **only** when the key matches `/^[0-9a-f]{64}$/`; insert before ``; no placeholder required. The file comments the three D6 properties and names `injectWebKey` as the source of record. `removeHeader` is called **only** on this index branch. +4. `GET /v2/assets/*` → extension allowlist `{js,css,map,svg,woff2,png,ico}`; reject `..`, absolute segments, and any resolved path that is not under `/assets/`; 404 otherwise. Do not call `removeHeader`. +5. Any other path, and any non-GET: 404, same body as today (`Not found`). No `removeHeader`. Existing `v2-routes.test.ts:127` (`GET /v2/nope` on a mock without `removeHeader`) keeps passing. + +`isPublicRoute` clauses, after `/` / `/index.html`: + +- `GET` + `pathname === '/v2/'` → true +- `GET` + `pathname.startsWith('/v2/assets/')` → true +- nothing else under `/v2/`. No `/v2` alias. + +#### Deliverables + +- [ ] `apps/v2` builds (`pnpm --filter @cluesmith/codev-v2 build`) +- [ ] `GET /v2/` serves the shell with `window.__CODEV_TOWER_KEY__` set when the key is well-formed +- [ ] `copy-v2` + `files` put `v2-dist` in `npm pack` +- [ ] Tests for this phase + +#### Acceptance Criteria + +- [ ] Scenario 12: malformed key → no injection; HTML has no `Access-Control-Allow-Origin` / `Vary`; script precedes `` +- [ ] Scenario 15: `GET /v2/assets/../../etc/passwd` refused; non-allowlisted extension refused; `GET /v2/nonsense` is 404 +- [ ] Scenario 16: no-header `GET /v2/` → public; no-header `GET /v2/assets/` → public; no-header `GET /v2/events?scope=…` → not public; no-header `POST /v2/` → not public +- [ ] Scenario 19 (pack): `npm pack --dry-run` in `packages/codev` lists `v2-dist/index.html` and `v2-dist/assets/` +- [ ] Existing `handleV2Route` 404 for `/v2/nope` still passes; spec 52's v2 suite still passes +- [ ] `git diff --stat` empty on every C1/C2 frozen file +- [ ] Build and unit tests pass + +#### Test Plan + +- `v2-static.test.ts` — injectV2Key table; traversal; extension allowlist; missing dist → 404; method not GET → 404. Drive via `setV2DistRoot` + `handleV2Route` so the prologue is the path under test. Index-path mocks must implement `removeHeader`; 404-path mocks need not (the handler must not call it there). +- `v2-public-route.test.ts` — scenario 16 against both `isPublicRoute` **and** `isRequestAllowed` (same helper style as `request-auth.test.ts`). Keyless `GET /v2/` and `GET /v2/assets/x.js` allowed; keyless `GET /v2/events?scope=…` and `POST /v2/` rejected. Existing `request-auth.test.ts` cases stay untouched and must still pass. +- `v2-packaging.e2e.test.ts` — named so the default unit suite excludes it (`**/*.e2e.test.ts`). The test itself runs `pnpm copy-v2` then `npm pack --dry-run` and asserts `v2-dist/index.html` and `v2-dist/assets/`. Self-contained; does not depend on a prior build. + +### Phase 2: Frame validation and reducer + +**Dependencies**: Phase 1 + +#### Objective + +Every frame type has a validator and a reducer transition. Degenerate frames are terminal. Two reducer instances fed the same frames converge. No network, no DOM required for the core table. + +#### Files to Create / Modify + +- `apps/v2/src/lib/validate.ts` — D1 read-set; returns `{ok, frame}` or `{ok: false, mismatch}` with only decoded facts (scenario 29) +- `apps/v2/src/lib/reducer.ts` — D1 table +- `apps/v2/__tests__/validate.test.ts` +- `apps/v2/__tests__/reducer.test.ts` + +Reducer state (frame layer only — see Shared implementation rules): + +``` +{ + nodes: Map, // Node always has buckets?: number[] + darkPaths: Map, + counts: V2Counts | null, + cursor: { streamId: string | null, seq: number }, + mismatch: null | { how, seq?, type?, field?, preview?, afterSeq }, + mismatchAttempts: 0, // 0 = budget full; 1 = recovery used +} +``` + +Empty is derived: `nodes.size === 0 && darkPaths.size === 0`. `nodes: []` plus a `dark` entry is **not** empty (scenario 21). Unreachable is `AppState.connection`, not a reducer field. + +`applyFrame(state, raw): { state, effect }` where `effect` is `none | recover-fresh | halt`. `recover-fresh` means "one reconnect without since/stream". The stream client owns the attempt counter's side effects; the reducer exposes whether this mismatch is still recoverable. + +#### Deliverables + +- [ ] D1 table implemented and tested +- [ ] Tests for this phase + +#### Acceptance Criteria + +- [ ] Scenario 1: one case per D1 row (`snapshot`, `resumed`, `node`, `gone`, `counts`, `tick`, `dark`) +- [ ] Scenario 2: tick omitting a builder appends 0 +- [ ] Scenario 3 / 39: existing builder's trace unchanged on `node`; new builder with absent buckets gets 20 zeros +- [ ] Scenario 4: `resumed` + deltas do not replace the map +- [ ] Scenario 5: `snapshot` + `resumed: false` replaces the map +- [ ] Scenario 6 (state): unknown status is stored as-is, not rewritten to `running`, not mismatch +- [ ] Scenario 8: 50 frames into two reducers → identical serialised state +- [ ] Scenario 19: snapshot then 5 `node` frames → cursor is the last delta's `seq` +- [ ] Scenario 20 (frame half): invalid JSON and unknown `type` are mismatch; cursor does not advance +- [ ] Scenario 21 / 41 / 22: dark-from-id; snapshot replaces darkPaths; deltas do not clear a dark plot +- [ ] Scenario 23: node `buckets: number[]` and tick `buckets: {}` in one session +- [ ] Scenario 24: footer counts come from the snapshot when no `counts` delta follows +- [ ] Scenario 28 / 37: first bad frame → `recover-fresh`; second on the fresh connection → `halt`; a valid snapshot in between resets the budget +- [ ] Scenario 29: mismatch claims only what it decoded +- [ ] Scenario 30: one case per read-set row +- [ ] Scenario 31: `status: "reticulating"` is not terminal +- [ ] Scenario 33: `NaN`, `Infinity`, `1.5`, `-1`, `2**60` each terminal +- [ ] Scenario 34: shared `seq` both applied; lower `seq` same stream terminal +- [ ] Scenario 35: extra unknown field applies +- [ ] Scenario 36: new `streamId` + `seq: 0` after cursor 500 is accepted +- [ ] Build and unit tests pass + +#### Test Plan + +Table-driven fixtures. No `fetch`, no timers. Serialise Maps to sorted arrays for convergence. + +### Phase 3: Bootstrap, stream reader, reconnect + +**Dependencies**: Phase 2 + +#### Objective + +The page can obtain scope once, open the stream, survive disconnect by resume, and classify every failure the spec names. Still no designed UI — a thin hook/`connect()` is enough for tests to drive. + +#### Files to Create / Modify + +- `apps/v2/src/lib/key.ts` — injected key only. No `localStorage` fallback (the v1 dashboard persists; this unit does not need it, and a stored key is a second copy of a secret). +- `apps/v2/src/lib/encode-scope.ts` +- `apps/v2/src/lib/sse-reader.ts` — reuse one `TextDecoder({ stream: true })`; split on `\n` / `\r\n`; accept `data: ` lines; **do not** apply a trailing partial at EOF +- `apps/v2/src/lib/bootstrap.ts` — D7 +- `apps/v2/src/lib/stream.ts` — open, classify, D2 reconnect, one named `reconnectBackoff` `setTimeout` +- `apps/v2/__tests__/encode-scope.test.ts` +- `apps/v2/__tests__/sse-reader.test.ts` +- `apps/v2/__tests__/bootstrap.test.ts` +- `apps/v2/__tests__/stream.test.ts` +- `apps/v2/__tests__/no-polling.test.ts` +- `packages/codev/src/agent-farm/__tests__/v2-scope-encoding.test.ts` + +`stream.ts` takes injected `fetch`, `setTimeout`/`clearTimeout`, and a `now` if needed. Tests never use real timers except a fake clock. + +Backoff lives in `stream.ts` as: + +``` +function reconnectBackoff(ms: number, cb: () => void): Timer +``` + +That identifier is what scenario 9 greps for. It is the only `setTimeout` in `apps/v2/src`. `bootstrap.ts` retries by calling `reconnectBackoff`; it does not import `setTimeout` itself. + +#### Deliverables + +- [ ] Bootstrap, reader, and stream client +- [ ] Tests for this phase + +#### Acceptance Criteria + +- [ ] Scenario 9: `grep -r setInterval apps/v2/src` → 0; `grep -r setTimeout apps/v2/src` → exactly one file, the `reconnectBackoff` site +- [ ] Scenario 13: `/api/workspaces` once on success; reconnect does not re-request +- [ ] Scenario 14: 200 + `[]` → empty, stream never opened +- [ ] Scenario 17: 401, 500, thrown fetch → unreachable; 200 + `[]` → empty +- [ ] Scenario 18: `encodeScope(['/a,b','/c'])` produces a query with a literal comma between encodings; `encodeURIComponent(join)` is not the output; `handleV2Route` given two known paths in that encoding returns both in the snapshot, not `nodes: []` + one `dark` +- [ ] Scenario 20 (transport half): clean EOF → resume reconnect, not empty, not mismatch; non-2xx classified per D1 +- [ ] Scenario 25: 500 then 200 → two bootstrap requests; later reconnect → zero more +- [ ] Scenario 26 / 32: invalid JSON, `{}`, `{"workspaces":null}`, `{"workspaces":"nope"}`, `[{}]`, `{path:42}`, `{path:""}` → mismatch, one retry, stop; never empty, never unreachable +- [ ] Scenario 27: split frame, split UTF-8 code point, 4 frames in one chunk, mid-frame remainder, `\r\n`, trailing partial at EOF not applied +- [ ] Scenario 28 (client): one recover-fresh (no `since`/`stream`); second bad frame opens no third connection +- [ ] Scenario 38: 500 retries on backoff; unreadable 200 retries once +- [ ] Scenario 40: 400 mismatch no retry; 401 auth-unreachable no retry; 404 mismatch no retry; 503 unreachable retries. No-retry cases open exactly one connection +- [ ] Build and unit tests pass + +#### Test Plan + +Scripted `ReadableStream` and a fake `fetch` that records URLs. Scenario 18's round-trip lives in `v2-scope-encoding.test.ts` and **duplicates** `WS_A` / `WS_B` / `makeReq` / `makeRes` / `urlFor` — those helpers are module-local in `v2-routes.test.ts` and are not exported. The test still calls `handleV2Route` so it exercises the frozen `parseScope`. + +### Phase 4: Site view + +**Dependencies**: Phase 3 + +#### Objective + +`/v2/` draws the approved site view from reducer state. Empty, dark, and unreachable cannot be mistaken for each other. Builders sit under the workspace beside architect headers. Counts sit in the footer as machine totals. + +#### Files to Create / Modify + +- `apps/v2/src/site.css` — hand-translate the mockup's containment layout (lot, plot grid, architect header, `.stake` rows, footer). `01-site.html` does this with Tailwind utilities; `tokens.css` only ships the pattern classes. This file is the rest. No Tailwind. No Font Awesome. Fonts: the fallback stacks already in `tokens.css` (Fraunces → Georgia, Plex Sans → system-ui, Plex Mono → ui-monospace). No Google Fonts ``, no vendored `woff2`. That is an accepted deviation from the mockup's CDN faces, not a later-unit stub. +- `apps/v2/src/components/*.tsx` as in the file layout +- `apps/v2/src/lib/tree.ts` — workspaces = nodes with `kind === 'workspace'` plus darkPaths entries not in nodes; children grouped by `parentId`; no inferred architect parent; unresolvable parents surface as machine-level orphans, not silent drops +- `apps/v2/src/App.tsx` — the `AppState` composer (Shared implementation rules). Display precedence lives here, not in the reducer. +- `apps/v2/__tests__/SiteView.test.tsx` +- `apps/v2/__tests__/StatusStamp.test.tsx` +- `apps/v2/__tests__/Sparkline.test.tsx` +- `apps/v2/__tests__/tree.test.ts` + +D8 cut, in markup: + +- **In:** machine lot (hostname), workspace plot, architect header, builder row (`.stake`), four stamps, sparkline, `heldMail` mark, `.grid-bg`, `.dim-sub`, `.needs-attn` +- **Out, absent not disabled:** gate rail, Find node, Add machine, terminal bank, command palette, rust queue chip + +Header is the machine name only. Footer is `Machine totals:` + `counts.workspaces` / `counts.builders.total` / `counts.gateWaiting` in graphite. The footer must not read as a rollup of the drawn tree. + +Dark plot: decode `workspace:`, label with basename, show `reason` and the `at` stored when the dark frame arrived. `.dim-sub`. No node required. + +Unknown status: stamp shows the raw string, no `--moss` / `--rust` / `--ochre`, so it cannot be mistaken for `running`. + +#### Deliverables + +- [ ] Site view components wired to live state +- [ ] Tests for this phase + +#### Acceptance Criteria + +- [ ] Scenario 6 (render): unknown status visibly wrong, not `RUN` +- [ ] Scenario 7: dark sibling stays live; `nodes: []` is empty-site copy; unreachable is a connection banner and **not** the empty-site copy +- [ ] Scenario 21 (render): `nodes: []` + one `dark` → one dark plot from the id +- [ ] Criteria 17 / 18: footer labelled machine totals; builder beside architect, under workspace +- [ ] `--rust` appears only on `gate-waiting` treatment +- [ ] D8-out selectors (`#gate-rail`, Find node, Add machine, `#terminal-bank`) are absent +- [ ] Scenario 9 still holds after UI code +- [ ] Build and unit tests pass + +#### Test Plan + +React Testing Library against fixture state. No real network. Assert text, class names (`.needs-attn`, `.dim-sub`, `.spark`), and absence of D8 chrome. + +### Phase 5: Playwright fixture proof + +**Dependencies**: Phase 4 + +#### Objective + +Every browser-facing criterion is driven at 1440 against a local fixture server that speaks `/api/workspaces` and `/v2/events`. No live Tower, no real worktree, no `afx cleanup`. + +#### Files to Create / Modify + +- `apps/v2/playwright.config.ts` — viewport `{ width: 1440, height: 900 }`; `baseURL: 'http://127.0.0.1:4173'`; `webServer.command` is `node e2e/fixture-server.ts` on port 4173. Do not start Vite preview. Do not hook `packages/codev/playwright.config.ts`. +- `apps/v2/e2e/fixture-server.ts` — **sole HTTP owner** for the suite. One origin, port 4173: + - `GET /v2/` → built `apps/v2/dist/index.html` with the same `injectV2Key` rules (well-formed 64-hex fixture key) + - `GET /v2/assets/*` → files from `apps/v2/dist/assets`, same allowlist / no-traversal as production + - `GET /api/workspaces` → scripted body + - `GET /v2/events` → SSE frames; honours or refuses `since`+`stream`; controllable disconnect + - control POST `/__fixture/...` for per-test scenario switches (not part of the app) +- `apps/v2/e2e/site.spec.ts` — one test per browser-facing criterion +- `apps/v2/package.json` — `test:e2e`; **devDependency `@playwright/test`** (do not import it from `packages/codev`) +- `codev/reviews/83-v2-client-shell.md` is **not** written here; cold-load and idle-bandwidth numbers from this phase are recorded in the review later +- Scenario 11 check: measured `git diff --stat` on the C1/C2 list at phase 5 close and recorded in the review. Not a durable unit test — those files are frozen for spec 83, not forever. + +The fixture is the only server Playwright talks to. `vite preview` is not in this phase: its `server.proxy` does not apply to preview, and a second origin would 401 the stream. + +#### Deliverables + +- [ ] Playwright suite covering scenario 10 +- [ ] Frozen-file check +- [ ] Tests for this phase + +#### Acceptance Criteria + +Scenario 10, each a test: + +- [ ] Load + render hierarchy (workspace / architect / builder from a snapshot) +- [ ] `node` for a new builder appears with no reload +- [ ] `gate-waiting` → `GATE` + rust; rust nowhere else +- [ ] `stalled` → `STALLED` + ochre +- [ ] Sparkline advances on `tick`; silent builder flattens to zero +- [ ] Fixture `gone` removes the row +- [ ] Kill fixture socket, restore: state recovers with no `reload`; both honoured resume and refused snapshot +- [ ] Dark workspace dark, sibling live +- [ ] Unreachable vs zero workspaces differ +- [ ] Two pages on one fixture scope converge +- [ ] Counts in the footer, not presented as the tree's rollup +- [ ] Builder under workspace, beside architect +- [ ] Cold load and idle KB/s **measured and printed**, not asserted (criteria 12, 13). `tower-routes.ts:242` sets `Cache-Control: no-store` on every response and C1 freezes that file, so hashed `/v2/assets/*` will not be cached in production either. Measure against that fact; do not try to override it. + +Also: + +- [ ] Scenario 11: frozen C1/C2 files have empty `git diff --stat` +- [ ] Spec 52 v2 suite still passes +- [ ] Build and unit tests pass + +#### Test Plan + +Playwright against the fixture. Resume: fixture honours `since`+`stream` or replies `resumed: false`. Unreachable: fixture closes the port. Empty: fixture returns `{"workspaces":[]}`. Two tabs: two `page` objects, same origin. + +Manual UX (not automated): open live `/v2/` on this machine, spawn a builder, wait past `IDLE_WAITING_THRESHOLD_MS`, `afx cleanup` by a human. Record in the review. + +## Test scenario → phase map + +| Scenarios | Phase | +|---|---| +| 12, 15, 16, 19 (pack) | 1 | +| 1–6 (state), 8, 19 (cursor), 20 (frames), 21–24, 28–31, 33–37, 39, 41, 35 | 2 | +| 9, 13, 14, 17, 18, 20 (EOF/HTTP), 25–28, 32, 38, 40 | 3 | +| 6 (render), 7, 21 (render), 17/18 criteria, D8 absence | 4 | +| 10, 11 | 5 | + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| `encodeURIComponent(join(','))` | High | High — 200 + empty + dark | D12; scenario 18 hits real `parseScope` | +| Client tests invisible to porch | High | High — green implement, broken client | `packages/codev` `test` also runs `@cluesmith/codev-v2 test` | +| Playwright against live Tower | Medium | High — flaky, or `afx cleanup` | Fixture is the sole origin; gone is a frame | +| Bare `/v2` treated as the shell | Medium | Medium — dead code behind a frozen `startsWith('/v2/')` | D9 as written; no `/v2` alias | +| `vitest` (watch) as the codev test script | High | High — porch hangs | `vitest run` | +| Phase 1 client test script with zero files | High | High — porch `npm test` red | `passWithNoTests: true` | +| `pnpm dev` has no key | High | Medium — every request 401s | serve-only `transformIndexHtml` plugin | +| `__dirname` in an ESM package | High | High — `v2-static.ts` throws at load | `fileURLToPath(import.meta.url)` shim | +| Tailwind/CDN pulled from the mockup | Medium | Medium — offline fail, extra CSS | Phase 4 forbids both | +| `listWorkspaces()` used out of habit | High | High — unreachable === empty | Phase 3; scenario 17 | +| Rust on footer / reconnect | High | High — colour discipline | Phase 4 + scenario 10 rust assertion | +| `v2-dist` missing from pack | High | High — adopters 404 | `copy-v2` builds first; scenario 19 | +| Prologue change regresses `/v2/events` | Low | High | Existing 57 v2 tests stay in the suite | +| Dark treated as a machine | Medium | Medium | Phase 4 builds the plot from `workspace:` | + +## Documentation Updates + +None in this unit. `arch.md` / `lessons-learned.md` wait for the review. `apps/v2` gets no README unless a later unit needs one; the page is the interface. + +**#97** is closed as already-working (spec rev. 12). **#98** (dark decided once per connection) and **#100** (worktrees with no `global.db` row) stay filed. The review lists them; this plan does not patch them. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-rebuttals.md new file mode 100644 index 000000000..dc9214147 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-rebuttals.md @@ -0,0 +1,19 @@ +# Phase 2 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. opencode APPROVE. codex REQUEST_CHANGES. claude REQUEST_CHANGES. + +## Codex / Claude: `gone` deletes `darkPaths` + +Accepted. `gone` now only deletes from `nodes`. Dark survives every delta; only a snapshot replaces `darkPaths`. Added a regression test. + +## Codex: preview is 120 UTF-16 units, not 120 UTF-8 bytes + +Accepted. `escapePreview` now takes the first 120 bytes of `TextEncoder().encode(s)` and hex-escapes non-ASCII. Test covers `€`. + +## Codex / Claude: `DarkEntry.at` is always empty + +Accepted. `applyFrame(state, raw, now?)` takes an ISO timestamp (default `new Date().toISOString()`). Dark stores that. Tests inject a fixed `now`. + +## Claude: dead branch in `applyFrame` + +Accepted. Both arms called `enterMismatch`; collapsed to one. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter2-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter2-context.md new file mode 100644 index 000000000..3d745718c --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter2-context.md @@ -0,0 +1,35 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Core validation and reducer behavior is strong, but dark-state persistence and mismatch previews violate explicit phase requirements. +- claude: REQUEST_CHANGES — Reducer and validator are complete and well tested (47 passing, clean typecheck), but `gone` clears dark entries against D5, and `DarkEntry.at` is a permanently empty placeholder that phase 4 depends on. +- opencode: APPROVE — Phase 2 validator and reducer implement the D1 table; 47 tests cover the listed scenarios. + +### Builder Response to Iteration 1 +# Phase 2 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. opencode APPROVE. codex REQUEST_CHANGES. claude REQUEST_CHANGES. + +## Codex / Claude: `gone` deletes `darkPaths` + +Accepted. `gone` now only deletes from `nodes`. Dark survives every delta; only a snapshot replaces `darkPaths`. Added a regression test. + +## Codex: preview is 120 UTF-16 units, not 120 UTF-8 bytes + +Accepted. `escapePreview` now takes the first 120 bytes of `TextEncoder().encode(s)` and hex-escapes non-ASCII. Test covers `€`. + +## Codex / Claude: `DarkEntry.at` is always empty + +Accepted. `applyFrame(state, raw, now?)` takes an ISO timestamp (default `new Date().toISOString()`). Dark stores that. Tests inject a fixed `now`. + +## Claude: dead branch in `applyFrame` + +Accepted. Both arms called `enterMismatch`; collapsed to one. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-rebuttals.md new file mode 100644 index 000000000..51d1372b6 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-rebuttals.md @@ -0,0 +1,31 @@ +# Phase 3 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. opencode timed out. codex REQUEST_CHANGES. claude REQUEST_CHANGES. + +## Codex: unreachable hides a later mismatch + +Accepted. Entering bootstrap mismatch, HTTP mismatch, or a bad frame now clears `connection === 'unreachable'`. Tests: `500` then `{}`; `503` then `400`. + +## Codex: forceFresh dropped on 5xx/EOF before a snapshot + +Accepted. `forceFresh` stays set until a valid live frame. A recover-fresh connection that 503s still opens the next attempt without `since`/`stream`. + +## Claude: leaked SSE body on early return + +Accepted. `readSseData` cancels the reader in `finally`. Non-200 responses cancel `res.body`. + +## Claude: emit mutates one object + +Accepted. `onState` now receives a shallow copy. + +## Claude: `wait()` TDZ on sync backoff + +Accepted. `onAbort` is declared before the backoff callback. + +## Claude: empty bootstrap sets `connection = 'live'` + +Kept. Display uses `bootstrap === 'empty'` first. `live` here means not waiting on a socket; no stream is opened. + +## Opencode: forceFresh cleared on EOF because `connection === 'live'` is sticky + +Accepted. `openOnce` returns `applied-eof` only when this attempt applied a valid frame. `forceFresh` clears on that, not on a leftover live connection. Test: recover-fresh then empty 200 body, next URL has no `since`/`stream`. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-context.md new file mode 100644 index 000000000..97c6442b7 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-context.md @@ -0,0 +1,47 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Phase 3’s core paths are implemented, but retry transitions can hide mismatches or abandon required fresh recovery. +- claude: REQUEST_CHANGES — Phase 3 meets every stated acceptance criterion with passing tests, but leaked SSE connections on early return and a mutated-in-place state object need fixing before phase 4. +- opencode: REQUEST_CHANGES — forceFresh still drops on EOF after recover-fresh, so a flaky recovery resumes into the same bad frame and halts. + +### Builder Response to Iteration 1 +# Phase 3 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. opencode timed out. codex REQUEST_CHANGES. claude REQUEST_CHANGES. + +## Codex: unreachable hides a later mismatch + +Accepted. Entering bootstrap mismatch, HTTP mismatch, or a bad frame now clears `connection === 'unreachable'`. Tests: `500` then `{}`; `503` then `400`. + +## Codex: forceFresh dropped on 5xx/EOF before a snapshot + +Accepted. `forceFresh` stays set until a valid live frame. A recover-fresh connection that 503s still opens the next attempt without `since`/`stream`. + +## Claude: leaked SSE body on early return + +Accepted. `readSseData` cancels the reader in `finally`. Non-200 responses cancel `res.body`. + +## Claude: emit mutates one object + +Accepted. `onState` now receives a shallow copy. + +## Claude: `wait()` TDZ on sync backoff + +Accepted. `onAbort` is declared before the backoff callback. + +## Claude: empty bootstrap sets `connection = 'live'` + +Kept. Display uses `bootstrap === 'empty'` first. `live` here means not waiting on a socket; no stream is opened. + +## Opencode: forceFresh cleared on EOF because `connection === 'live'` is sticky + +Accepted. `openOnce` returns `applied-eof` only when this attempt applied a valid frame. `forceFresh` clears on that, not on a leftover live connection. Test: recover-fresh then empty 200 body, next URL has no `since`/`stream`. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-rebuttals.md new file mode 100644 index 000000000..170ffbb45 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-rebuttals.md @@ -0,0 +1,7 @@ +# Phase 3 iter 2 rebuttals — spec 83 + +Lanes: gemini skipped. claude APPROVE. codex REQUEST_CHANGES. opencode no verdict. + +## Codex: `res.text()` failure classified as unreachable + +Accepted. Fetch errors stay unreachable. A 200 whose `text()` rejects is mismatch. Test added. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-context.md new file mode 100644 index 000000000..f482e9ab8 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-context.md @@ -0,0 +1,63 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Phase 3’s core paths are implemented, but retry transitions can hide mismatches or abandon required fresh recovery. +- claude: REQUEST_CHANGES — Phase 3 meets every stated acceptance criterion with passing tests, but leaked SSE connections on early return and a mutated-in-place state object need fixing before phase 4. +- opencode: REQUEST_CHANGES — forceFresh still drops on EOF after recover-fresh, so a flaky recovery resumes into the same bad frame and halts. + +### Builder Response to Iteration 1 +# Phase 3 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. opencode timed out. codex REQUEST_CHANGES. claude REQUEST_CHANGES. + +## Codex: unreachable hides a later mismatch + +Accepted. Entering bootstrap mismatch, HTTP mismatch, or a bad frame now clears `connection === 'unreachable'`. Tests: `500` then `{}`; `503` then `400`. + +## Codex: forceFresh dropped on 5xx/EOF before a snapshot + +Accepted. `forceFresh` stays set until a valid live frame. A recover-fresh connection that 503s still opens the next attempt without `since`/`stream`. + +## Claude: leaked SSE body on early return + +Accepted. `readSseData` cancels the reader in `finally`. Non-200 responses cancel `res.body`. + +## Claude: emit mutates one object + +Accepted. `onState` now receives a shallow copy. + +## Claude: `wait()` TDZ on sync backoff + +Accepted. `onAbort` is declared before the backoff callback. + +## Claude: empty bootstrap sets `connection = 'live'` + +Kept. Display uses `bootstrap === 'empty'` first. `live` here means not waiting on a socket; no stream is opened. + +## Opencode: forceFresh cleared on EOF because `connection === 'live'` is sticky + +Accepted. `openOnce` returns `applied-eof` only when this attempt applied a valid frame. `forceFresh` clears on that, not on a leftover live connection. Test: recover-fresh then empty 200 body, next URL has no `since`/`stream`. + + +### Iteration 2 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Phase 3 is largely correct, but a failed body read on a 200 bootstrap response is misclassified. +- claude: APPROVE — Phase 3 meets its acceptance criteria, all four iteration-1 concerns are fixed in code and covered by tests, and 134 tests plus the build pass with frozen files untouched. +- opencode: APPROVE — Phase 3 meets its criteria; iter-1 and the iter-2 res.text() mismatch are fixed in code and covered by tests. + +### Builder Response to Iteration 2 +# Phase 3 iter 2 rebuttals — spec 83 + +Lanes: gemini skipped. claude APPROVE. codex REQUEST_CHANGES. opencode no verdict. + +## Codex: `res.text()` failure classified as unreachable + +Accepted. Fetch errors stay unreachable. A 200 whose `text()` rejects is mismatch. Test added. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-rebuttals.md new file mode 100644 index 000000000..47f2a97cf --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-rebuttals.md @@ -0,0 +1,7 @@ +# Phase 3 iter 3 rebuttals — spec 83 + +Lanes: gemini skipped. codex REQUEST_CHANGES. claude timed out. opencode consult CLI failed; review written against disk. + +## Codex: forceFresh stays set after a valid recovery snapshot then a read error + +Accepted. `openOnce` returns `applied-retry` when a frame was applied before the body throws. `streamLoop` clears `forceFresh` on that. Test: recover-fresh snapshot then `c.error`, next URL has `since`/`stream`. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-rebuttals.md new file mode 100644 index 000000000..b5b09c714 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-rebuttals.md @@ -0,0 +1,11 @@ +# Phase 4 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. claude APPROVE. codex REQUEST_CHANGES. opencode consult CLI failed; review written against disk. + +## Codex: workspace/architect status and heldMail ignored + +Accepted. Offline workspace/architect get `.dim-sub`. Status stamps render on both. Architect `heldMail` shows as the mail mark. + +## Codex: empty snapshot hides machine totals + +Accepted. Empty site still renders `MachineFooter` when `counts` is present. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter2-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter2-context.md new file mode 100644 index 000000000..2061da9f2 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter2-context.md @@ -0,0 +1,27 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — The site view is largely aligned, but it drops required node state and machine totals in valid render paths. +- claude: APPROVE — Phase 4 site view meets the spec — three states distinguishable, colour discipline held, D13 flat tree, D8 chrome absent; build and 119 tests pass. +- opencode: APPROVE — Phase 4 meets its criteria; Codex's status/heldMail/empty-footer gaps are fixed on disk. + +### Builder Response to Iteration 1 +# Phase 4 iter 1 rebuttals — spec 83 + +Lanes: gemini skipped. claude APPROVE. codex REQUEST_CHANGES. opencode consult CLI failed; review written against disk. + +## Codex: workspace/architect status and heldMail ignored + +Accepted. Offline workspace/architect get `.dim-sub`. Status stamps render on both. Architect `heldMail` shows as the mail mark. + +## Codex: empty snapshot hides machine totals + +Accepted. Empty site still renders `MachineFooter` when `counts` is present. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-rebuttals.md new file mode 100644 index 000000000..6203b7eed --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-rebuttals.md @@ -0,0 +1,9 @@ +# Phase 5 iter 1 rebuttals — spec 83 + +## Codex: phase 5 files absent from HEAD + +Accepted. Files were untracked during the review. They are committed now. + +## Claude: vacuous resume / rust / flatten / idle assertions + +Accepted. Resume tests push a node after disconnect. Rust is checked via computed colour. Silent flatten is seeded then omitted. Idle Bps is sampled over 1s and printed. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-context.md new file mode 100644 index 000000000..a5d3c0317 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-context.md @@ -0,0 +1,25 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Phase 5 implementation is absent from the canonical PR scope. +- claude: REQUEST_CHANGES — Fixture harness and 14 passing e2e tests are structurally correct, but four acceptance criteria (7, 3, 5, 13) are asserted in ways that cannot fail. +- opencode: APPROVE — Phase 5 fixture proof covers scenario 10/11; 14 e2e tests pass and the weak assertions are tightened. + +### Builder Response to Iteration 1 +# Phase 5 iter 1 rebuttals — spec 83 + +## Codex: phase 5 files absent from HEAD + +Accepted. Files were untracked during the review. They are committed now. + +## Claude: vacuous resume / rust / flatten / idle assertions + +Accepted. Resume tests push a node after disconnect. Rust is checked via computed colour. Silent flatten is seeded then omitted. Idle Bps is sampled over 1s and printed. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-rebuttals.md new file mode 100644 index 000000000..8d7634eee --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-rebuttals.md @@ -0,0 +1,25 @@ +# Phase 5 iter 2 rebuttals — spec 83 + +## Codex: resume tests only prove a later node arrives + +Accepted. The fixture now records the last `/v2/events` query (`since`, `stream`) and the response mode (`resumed` or `snapshot`) on `GET /__fixture/last-events`. The honoured test waits for `mode=resumed` with `since` set and `stream=s1`. The refused test waits for a reconnect that still sent `since`+`s1`, then asserts `mode=snapshot`. Both plant a `window.__v2Sentinel` and assert it plus `builder:1` after reconnect, so a wipe-on-`resumed` or a page reload fails. + +## Codex: gate colour vacuous; stalled never checks ochre + +Accepted. The GATE stamp's computed colour is asserted as `rgb(181, 80, 42)`. `rustHolders.length > 0` is required, then rust is still confined to `stamp-gate` / `needs-attn`. The STALLED stamp's computed colour is asserted as `rgb(192, 138, 46)`. + +## Codex: idle bandwidth from Resource Timing + +Rejected. Plan phase 5 and spec scenario 10 both say cold load and idle KB/s are **measured and printed, not asserted**. CDP network byte events and fixture-side byte accounting are extra infrastructure those criteria do not require. `transferSize` does not accumulate on an in-flight SSE fetch, so the printed `idle-Bps` is a floor; the review will carry the figure from the manual UX pass. + +## Codex: two-page test checks only one new row + +Accepted. After `builder:z` appears on both pages, a stable serialization of every `[data-kind]` node (kind, id, dark, className) is compared. + +## Claude (APPROVE) + +Non-blocking items overlapped the resume/rust work above and are now asserted. Idle under-report is the same rebuttal as Codex. + +## Gemini (COMMENT, lane skipped) / opencode (APPROVE) + +No action. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter3-context.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter3-context.md new file mode 100644 index 000000000..cc7de61aa --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter3-context.md @@ -0,0 +1,59 @@ +### Iteration 1 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — Phase 5 implementation is absent from the canonical PR scope. +- claude: REQUEST_CHANGES — Fixture harness and 14 passing e2e tests are structurally correct, but four acceptance criteria (7, 3, 5, 13) are asserted in ways that cannot fail. +- opencode: APPROVE — Phase 5 fixture proof covers scenario 10/11; 14 e2e tests pass and the weak assertions are tightened. + +### Builder Response to Iteration 1 +# Phase 5 iter 1 rebuttals — spec 83 + +## Codex: phase 5 files absent from HEAD + +Accepted. Files were untracked during the review. They are committed now. + +## Claude: vacuous resume / rust / flatten / idle assertions + +Accepted. Resume tests push a node after disconnect. Rust is checked via computed colour. Silent flatten is seeded then omitted. Idle Bps is sampled over 1s and printed. + + +### Iteration 2 Reviews +- gemini: COMMENT — Gemini lane skipped — agy exited with code 1 +- codex: REQUEST_CHANGES — The fixture is sound, but several Phase 5 browser criteria remain asserted in ways that do not prove the required behavior. +- claude: APPROVE — Phase 5 fixture proof is complete — 14 e2e tests pass, the four previously-vacuous assertions are genuinely falsifiable, and frozen files are byte-clean. +- opencode: APPROVE — Phase 5 fixture proof is on HEAD and the iter-1 assertion gaps are closed. + +### Builder Response to Iteration 2 +# Phase 5 iter 2 rebuttals — spec 83 + +## Codex: resume tests only prove a later node arrives + +Accepted. The fixture now records the last `/v2/events` query (`since`, `stream`) and the response mode (`resumed` or `snapshot`) on `GET /__fixture/last-events`. The honoured test waits for `mode=resumed` with `since` set and `stream=s1`. The refused test waits for a reconnect that still sent `since`+`s1`, then asserts `mode=snapshot`. Both plant a `window.__v2Sentinel` and assert it plus `builder:1` after reconnect, so a wipe-on-`resumed` or a page reload fails. + +## Codex: gate colour vacuous; stalled never checks ochre + +Accepted. The GATE stamp's computed colour is asserted as `rgb(181, 80, 42)`. `rustHolders.length > 0` is required, then rust is still confined to `stamp-gate` / `needs-attn`. The STALLED stamp's computed colour is asserted as `rgb(192, 138, 46)`. + +## Codex: idle bandwidth from Resource Timing + +Rejected. Plan phase 5 and spec scenario 10 both say cold load and idle KB/s are **measured and printed, not asserted**. CDP network byte events and fixture-side byte accounting are extra infrastructure those criteria do not require. `transferSize` does not accumulate on an in-flight SSE fetch, so the printed `idle-Bps` is a floor; the review will carry the figure from the manual UX pass. + +## Codex: two-page test checks only one new row + +Accepted. After `builder:z` appears on both pages, a stable serialization of every `[data-kind]` node (kind, id, dark, className) is compared. + +## Claude (APPROVE) + +Non-blocking items overlapped the resume/rust work above and are now asserted. Idle under-report is the same rebuttal as Codex. + +## Gemini (COMMENT, lane skipped) / opencode (APPROVE) + +No action. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-plan-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-plan-iter1-rebuttals.md new file mode 100644 index 000000000..03751aba3 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-plan-iter1-rebuttals.md @@ -0,0 +1,27 @@ +# Plan iter 1 rebuttals — spec 83 + +Lanes: gemini skipped (agy exit 1). claude COMMENT. opencode COMMENT. codex REQUEST_CHANGES. + +## Codex REQUEST_CHANGES + +**Bare `GET /v2` public / served.** Accepted. `tower-routes.ts:282` is `startsWith('/v2/')` and C1 freezes it, so `/v2` never reaches `handleV2Route`. D9 names `/v2/` and `/v2/assets/*` only. Dropped the alias from `isPublicRoute` and from `v2-static.ts`. + +**Phase 5 topology.** Accepted. Fixture on `127.0.0.1:4173` is the sole HTTP owner: `/v2/`, `/v2/assets/*`, `/api/workspaces`, `/v2/events`. No `vite preview`. Playwright `baseURL` is that origin. + +**`@playwright/test` missing from `apps/v2`.** Accepted. Phase 5 adds it as a devDependency. + +**State ownership contradictory.** Accepted. Shared rules now define one composed `AppState`. Reducer is frames only. Unreachable is `connection`. Empty is derived (`nodes` empty and `darkPaths` empty). Display precedence is listed. + +**Scenario 16 only hits `isPublicRoute`.** Accepted. Phase 1 tests `isRequestAllowed` as well: keyless `/v2/` and assets allowed; keyless `/v2/events` and `POST /v2/` rejected. + +## Opencode COMMENT + +All five points accepted: drop unreachable from the reducer; bootstrap calls `reconnectBackoff`; one HTTP owner; empty vs dark-plot; `vitest run`. + +## Claude COMMENT + +Accepted: `passWithNoTests: true`; packaging test runs `copy-v2` itself; no `/v2` alias; `removeHeader` only on the index branch; serve-only key injection for `pnpm dev`; ESM `__dirname` shim; `site.css` is a hand translation and web fonts stay on the token fallbacks; duplicate the unexported test helpers; `Cache-Control: no-store` noted, not fought. + +## Gemini + +Did not review. Not treated as approval. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/83-review-iter1-rebuttals.md b/codev/projects/83-v2-client-shell-apps-v2-render/83-review-iter1-rebuttals.md new file mode 100644 index 000000000..8d270b98e --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/83-review-iter1-rebuttals.md @@ -0,0 +1,49 @@ +# Review iter 1 rebuttals — spec 83 + +## Codex: branch is behind spec rev. 12 + +Accepted. Merged `origin/main` (`22271aa15`). Plan, review, and PR body now say FR-3 is satisfied from `parentId`, #97 is closed, and #100 is the leftover defect. Client behaviour was already parentId-faithful; rev. 12 does not change it. + +## Codex: no architect-parented builder coverage + +Accepted. `tree.test.ts`, `SiteView.test.tsx`, and a Playwright case now drive `parentId: architect:1` and assert the row is inside that architect, not the workspace-level list. + +## Codex: untracked phase context files + +Accepted. `83-phase_4-iter2-context.md`, `83-phase_5-iter2-context.md`, and `83-phase_5-iter3-context.md` are included. + +## Codex: could not rerun Vitest (EPERM) + +Not a product defect. Suite rerun is local. + +## Claude: CI never runs apps/v2 + +Accepted. `.github/workflows/test.yml` has a `Run v2 unit tests` step (`apps/v2`, `pnpm test`). Playwright stays out of the matrix. + +## Claude: frozen-files.test.ts is a one-PR constraint + +Accepted. Test deleted. The empty `git diff --stat` at phase 5 close is recorded in the review. Those files are frozen for spec 83, not forever. + +## Claude: v2-packaging.test.ts in the unit suite + +Accepted. Renamed to `v2-packaging.e2e.test.ts` so the default vitest exclude (`**/*.e2e.test.ts`) applies. `vitest.e2e.config.ts` includes `src/**/*.e2e.test.ts`. + +## Claude: buildTree drops unresolvable parents + +Accepted. Architects and builders whose `parentId` is missing from the map render at machine level under `parent not in tree`. No parent is inferred. + +## Claude: sourcemap: true + .map allowlist (non-blocking) + +Accepted the decision. Production `sourcemap: false`. `.map` stays in `ASSET_EXT` so a leftover hashed map would still be served as a static asset, not as HTML. None are emitted. + +## Claude: Cache-Control (non-blocking) + +Rejected for this round. Hashed filenames already cache-bust; the header is not a spec criterion. + +## Claude: state why test is `vitest run` (non-blocking) + +Accepted in the PR body. Bare `vitest` is watch mode and hangs porch. + +## Gemini (COMMENT, lane skipped) / opencode (APPROVE) + +No action. diff --git a/codev/projects/83-v2-client-shell-apps-v2-render/status.yaml b/codev/projects/83-v2-client-shell-apps-v2-render/status.yaml new file mode 100644 index 000000000..e647fc261 --- /dev/null +++ b/codev/projects/83-v2-client-shell-apps-v2-render/status.yaml @@ -0,0 +1,234 @@ +id: '83' +title: v2-client-shell-apps-v2-render +protocol: spir +phase: review +plan_phases: + - id: phase_1 + title: Workspace and /v2/ static serving + status: complete + - id: phase_2 + title: Frame validation and reducer + status: complete + - id: phase_3 + title: Bootstrap, stream reader, reconnect + status: complete + - id: phase_4 + title: Site view + status: complete + - id: phase_5 + title: Playwright fixture proof + status: complete +current_plan_phase: null +gates: + spec-approval: + status: approved + approved_at: '2026-08-24T06:44:32.952Z' + plan-approval: + status: approved + requested_at: '2026-08-24T07:03:04.996Z' + approved_at: '2026-08-24T07:12:44.607Z' + pr: + status: approved + requested_at: '2026-08-24T10:36:01.312Z' + approved_at: '2026-08-24T11:26:53.574Z' + verify-approval: + status: pending +iteration: 1 +build_complete: true +history: + - iteration: 1 + plan_phase: phase_2 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-codex.txt + stated: true + - model: claude + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_2-iter1-opencode.txt + stated: true + - iteration: 1 + plan_phase: phase_3 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-codex.txt + stated: true + - model: claude + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-claude.txt + stated: true + - model: opencode + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter1-opencode.txt + stated: true + - iteration: 2 + plan_phase: phase_3 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-codex.txt + stated: true + - model: claude + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter2-opencode.txt + stated: true + - iteration: 3 + plan_phase: phase_3 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-codex.txt + stated: true + - model: claude + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_3-iter3-opencode.txt + stated: true + - iteration: 1 + plan_phase: phase_4 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-codex.txt + stated: true + - model: claude + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_4-iter1-opencode.txt + stated: true + - iteration: 1 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-codex.txt + stated: true + - model: claude + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter1-opencode.txt + stated: true + - iteration: 2 + plan_phase: phase_5 + build_output: '' + reviews: + - model: gemini + verdict: COMMENT + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-gemini.txt + stated: false + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-codex.txt + stated: true + - model: claude + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-claude.txt + stated: true + - model: opencode + verdict: APPROVE + file: >- + /Users/chris/dev/codev-1455/.builders/spir-83/codev/projects/83-v2-client-shell-apps-v2-render/83-phase_5-iter2-opencode.txt + stated: true +started_at: '2026-08-24T06:44:19.322Z' +updated_at: '2026-08-24T11:26:53.574Z' +context_refreshes: + - boundary: enter:implement + at: '2026-08-24T07:12:46.426Z' + acknowledged_at: '2026-08-24T07:13:14.682Z' + - boundary: plan-phase:phase_2 + at: '2026-08-24T07:29:12.283Z' + acknowledged_at: '2026-08-24T07:29:44.711Z' + - boundary: plan-phase:phase_3 + at: '2026-08-24T07:56:14.825Z' + acknowledged_at: '2026-08-24T07:56:44.092Z' + - boundary: plan-phase:phase_4 + at: '2026-08-24T09:18:40.332Z' + acknowledged_at: '2026-08-24T09:19:30.593Z' + - boundary: plan-phase:phase_5 + at: '2026-08-24T09:35:58.373Z' + acknowledged_at: '2026-08-24T09:36:09.922Z' + - boundary: enter:review + at: '2026-08-24T10:12:38.762Z' + acknowledged_at: '2026-08-24T10:13:13.169Z' +force_advanced: + phase: phase_3 + iteration: 3 + max_iterations: 3 + rebuttal_file: 83-phase_3-iter3-rebuttals.md + at: '2026-08-24T09:18:38.454Z' +pr_ready_for_human: false diff --git a/codev/resources/arch.md b/codev/resources/arch.md index e5cbf0211..c9f7d6f17 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -17,6 +17,7 @@ Codev is a Human-Agent Software Development Operating System. This repository se - **Server Runtime**: `packages/core/` — local-key issuance, homedir-derived paths (server-side only) - **VS Code Extension**: `apps/vscode/` — thin client over Tower API - **Dashboard**: `apps/web/` — React SPA served by Tower +- **v2 site**: `apps/v2/` — live hierarchy at `/v2/`; types from `@cluesmith/codev-types`, no SDK behaviour - **Consult Tool**: See `packages/codev/src/commands/consult/` and `codev/roles/consultant.md` - **Protocols**: Read the relevant protocol in `codev/protocols/{spir,maintain,experiment}/protocol.md` @@ -1159,6 +1160,7 @@ live in `packages/`; end-user client surfaces live in `apps/`: | `packages/config` | `@cluesmith/codev-config` | Shared tsconfig base (cross-project) | | `packages/artifact-canvas` | `@cluesmith/codev-artifact-canvas` | Reusable React surface for rendering/reviewing Codev markdown artifacts | | `apps/web` | `@cluesmith/codev-web` | React dashboard SPA (built into codev package) | +| `apps/v2` | `@cluesmith/codev-v2` | Live hierarchy at `/v2/`. Types from `@cluesmith/codev-types` only — no SDK behaviour. Built into `packages/codev/v2-dist` | | `apps/vscode` | `codev-vscode` (Marketplace: `cluesmith.codev-vscode`) | VS Code extension | | `apps/streamdeck` | `@cluesmith/codev-streamdeck` (private; Elgato plugin UUID `com.cluesmith.codev`) | Stream Deck plugin — outside-in controller: overview reads + SSE + command-relay verbs via the sdk's `controller`/`node` subpaths. Imported from codev-integrations under #1347; packs on demand into a `.sdPlugin` Marketplace bundle (not yet Marketplace-distributed — initial Maker Console submission is a tracked follow-up), versioned in workspace lockstep (manifest `Version` = package version + build segment, pinned by a version-sync test) | @@ -1173,6 +1175,9 @@ codev (CLI + Tower) vscode (extension) dashboard (React SPA) imports core + sdk imports sdk imports sdk imports types (dev) imports types (dev) imports types (dev) +v2 site (apps/v2) + imports types only — own fetch, own SSE reader, own reconnect + streamdeck (Elgato plugin) imports sdk only (controller + node subpaths; own import-boundary test) ``` @@ -1326,6 +1331,8 @@ codev/ # Project root (pnpm monorepo) │ └── tsconfig.base.json ├── apps/web/ # @cluesmith/codev-web (React SPA; end-user surface) │ └── src/ # React 19 + Vite 6 + xterm.js + Recharts +├── apps/v2/ # @cluesmith/codev-v2 (live hierarchy at /v2/; types only) +│ └── src/ # React 19 + Vite 6; reducer over GET /v2/events ├── apps/vscode/ # VS Code extension (Marketplace: cluesmith.codev-vscode; end-user surface) │ └── src/ │ ├── extension.ts # Activation, command/view registration @@ -1404,6 +1411,7 @@ codev/ # Project root (pnpm monorepo) │ │ ├── porch.js # porch command │ │ └── generate-image.js # generate-image command │ ├── dashboard-dist/ # Dashboard build output (copied from apps/web/dist) +│ ├── v2-dist/ # v2 site build output (copied from apps/v2/dist) │ ├── skeleton/ # Embedded codev-skeleton (built) │ ├── templates/ # HTML templates │ │ ├── tower.html # Multi-project overview diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index 9d72f9d74..f5698ffe8 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -474,6 +474,7 @@ so it survives review. Pin the constant to the highest migration block in a test ## UI/UX +- [From #83] Sibling stamps in one header need `display: flex` and a gap. Inline flow concatenates uppercase name + status into one word (`ALPHARUN`, `ALPHAmailRUN`). `textContent` tests will not catch it; assert distinct nodes and the computed `gap`. - [From #1463] A Stream Deck action's identity is its **UUID**, not its `Name`. Renaming a key's `Name`/`Tooltip`/face label (e.g. `Open Terminal` → `Open Builder Terminal`) leaves every already-placed instance working — the app re-labels it in place — **as long as the manifest UUID diff --git a/codev/reviews/83-v2-client-shell.md b/codev/reviews/83-v2-client-shell.md new file mode 100644 index 000000000..5b47b832e --- /dev/null +++ b/codev/reviews/83-v2-client-shell.md @@ -0,0 +1,211 @@ +# Review: v2 client shell — apps/v2 renders the live hierarchy + +## Summary + +Five plan phases shipped `apps/v2`: static `/v2/` serving and `v2-dist` packaging, a closed-read-set reducer, bootstrap plus resume reconnect, the containment site view, and a same-origin Playwright fixture. The page draws the live hierarchy from `GET /v2/events` without polling, without SDK behaviour, and without touching the C1/C2 frozen files. Review round 1 merged spec rev. 12 and landed the CI / orphan / packaging-suite fixes. + +## Spec Compliance + +- [x] 1. `/v2/` loads and renders every workspace, architect and builder the stream reports, inside the local machine's frame (Phase 4, 5) +- [x] 2. A new builder row appears with no reload and no client timer (Phase 3, 4, 5) +- [x] 3. Gate-waiting renders rust with a `GATE` stamp (Phase 4, 5) +- [x] 4. Stalled renders ochre and `STALLED` from the stream status (Phase 4, 5) +- [x] 5. Sparkline advances on `tick` and flattens to zero when a builder is omitted (Phase 4, 5) +- [x] 6. `gone` removes the row. Driven by a fixture frame, not `afx cleanup` (Phase 5; live cleanup is the human UX pass) +- [x] 7. Disconnect then honoured resume or refused snapshot recovers without a page reload (Phase 3, 5) +- [x] 8. A `dark` workspace plot goes dark; siblings stay live (Phase 2, 4, 5) +- [x] 9. Unreachable and zero workspaces render differently (Phase 3, 4, 5) +- [x] 10. Two pages on one scope converge (Phase 5) +- [x] 11. No `setInterval`. Exactly one `setTimeout`, the named reconnect backoff. `/api/workspaces` succeeds at most once (Phase 3) +- [x] 12. Cold load under 2s on the fixture: `cold-load-ms=138` against a 2000ms budget (Phase 5) +- [x] 13. Idle under 1 KB/s: `idle-Bps=0` (Phase 5). Resource Timing reports 0 on the still-open SSE fetch, which is the no-polling evidence +- [x] 14. `GET /v2/` injects `window.__CODEV_TOWER_KEY__` and strips `Access-Control-Allow-Origin` (Phase 1) +- [x] 15. Keyless `GET /v2/` and `/v2/assets/*` are public; keyless `/v2/events` is still 401 (Phase 1) +- [x] 16. 401, 500, thrown fetch, and 200 with `[]` produce distinct renderings (Phase 3, 4, 5) +- [x] 17. `counts` sits in the footer as machine totals, including on an empty snapshot (Phase 4, 5) +- [x] 18. A workspace-parented builder sits beside the architect; an architect-parented builder nests under it; no parent is inferred (Phase 4, 5, review) +- [x] 19. `npm pack` on `packages/codev` contains `v2-dist` (Phase 1) +- [x] 20. C1 surfaces byte-unchanged vs `origin/main` at phase 5 close (measured, then the durable test was removed) +- [x] 21. Production changes outside `apps/v2/` are `v2-static.ts`, the `v2-routes.ts` prologue, two `isPublicRoute` GET clauses, packaging, new tests, and the workspace lockfile (Phase 1) +- [x] 22. Spec 52 v2 suite and existing `isPublicRoute` cases still pass (Phase 1–5) + +FR-3 is satisfied from `parentId` (spec rev. 12). FR-15 remains deferred (D5). #97 is closed; #98 and #100 stay filed. + +## Deviations from Plan + +- Fixture is `apps/v2/e2e/fixture-server.mjs`, not the planned `.ts`. Node 20 cannot run `.ts` without a loader. +- `AppState.httpMismatch` is extra vs the plan; `viewKind` puts it in mismatch slot 2. +- Empty site still shows `MachineFooter` when `counts` is present (D11; Codex phase 4). +- Workspace plot header name is wrapped in `.ws-plot-label` so name, mail, and stamp are separate nodes. + +## Consultation Feedback + +Gemini skipped (agy exit 1) on every round. Recorded as COMMENT / `LANE_DID_NOT_REVIEW`. Not an approval. + +### Plan (Round 1) + +#### Codex +- **Concern**: bare `GET /v2` made public → **Addressed**: D9 only `/v2/` and `/v2/assets/*` +- **Concern**: Playwright topology / two origins → **Addressed**: fixture is the sole origin on 4173 +- **Concern**: `@playwright/test` missing from `apps/v2` → **Addressed** +- **Concern**: state ownership split → **Addressed**: composed `AppState` +- **Concern**: scenario 16 only hits `isPublicRoute` → **Addressed**: also `isRequestAllowed` + +#### Claude / opencode +- COMMENT on plan shape. No remaining disputes. + +### Phase 1 (Round 1) + +#### Codex +- APPROVE. No concerns. + +#### Claude +- **Concern**: `copy-v2` under vitest `NODE_ENV=test` emits a React dev bundle → **Addressed** in `8489c45ca` + +#### opencode +- APPROVE. + +### Phase 2 (Round 1) + +#### Codex +- **Concern**: `gone` deleted `darkPaths` → **Addressed** +- **Concern**: preview used UTF-16 not 120 UTF-8 bytes → **Addressed** +- **Concern**: dark `at` always empty → **Addressed** (injectable receipt time) + +#### Claude +- REQUEST_CHANGES overlapping the same three. **Addressed**. + +#### opencode +- APPROVE. + +### Phase 2 (Round 2) + +No concerns raised — Codex, Claude, opencode APPROVE. Gemini COMMENT (skip). + +### Phase 3 (Round 1) + +#### Codex / Claude / opencode +- **Concern**: unreachable not cleared on a later mismatch → **Addressed** +- **Concern**: `forceFresh` cleared before a successful fresh snapshot → **Addressed** +- Other transport notes (cancel SSE, emit copy, wait TDZ, applied-eof) → **Addressed** + +### Phase 3 (Round 2) + +#### Codex +- **Concern**: 200 + `res.text()` reject must be mismatch → **Addressed** + +#### Claude / opencode +- APPROVE. + +### Phase 3 (Round 3) + +#### Codex +- **Concern**: `applied-retry` must also clear `forceFresh` → **Addressed** in `05b981df5`. Phase was force-advanced; the commit landed after. + +#### Claude / opencode +- APPROVE. + +### Phase 4 (Round 1) + +#### Codex +- **Concern**: workspace/architect status ignored → **Addressed** +- **Concern**: architect `heldMail` ignored → **Addressed** +- **Concern**: empty snapshot hid `MachineFooter` → **Addressed** + +#### Claude / opencode +- APPROVE. + +### Phase 4 (Round 2) + +No concerns raised — Codex, Claude, opencode APPROVE. Gemini COMMENT (skip). + +### Phase 5 (Round 1) + +#### Codex +- **Concern**: phase 5 files untracked during review → **Addressed** (`c3d7153db`) + +#### Claude +- **Concern**: vacuous resume / rust / flatten / idle assertions → **Addressed** + +#### opencode +- APPROVE. + +### Phase 5 (Round 2) + +#### Codex +- **Concern**: resume tests did not record `since`/`stream`/mode or a page sentinel → **Addressed** (`75ce35bda`) +- **Concern**: rust scan vacuous; stalled never checked ochre → **Addressed** +- **Concern**: idle via CDP / fixture byte accounting → **Rebutted**: criteria 12–13 are measured and printed, not asserted +- **Concern**: two-page test checked only one new row → **Addressed** (full `[data-kind]` dump) + +#### Claude / opencode +- APPROVE. + +### Phase 5 (Round 3) + +No concerns raised — Codex, Claude, opencode APPROVE. Gemini COMMENT (skip). + +Architect screenshot before PR: workspace header rendered `ALPHARUN`. **Addressed** (`.ws-plot-name` is flex + 8px gap; name wrapped; two component tests + e2e computed-style). + +### Review (Round 1) + +Merged `origin/main` (spec rev. 12) before addressing. + +#### Codex +- **Concern**: branch behind spec rev. 12 / #97 still open → **Addressed** (merge + plan/review/PR) +- **Concern**: no architect-parented builder coverage → **Addressed** (tree, SiteView, Playwright) +- **Concern**: three untracked context files → **Addressed** +- Vitest EPERM in the review sandbox: environment, not a failure + +#### Claude +- **Concern**: CI never runs `apps/v2` → **Addressed** (`test.yml` unit step) +- **Concern**: `frozen-files.test.ts` freezes a one-PR constraint → **Addressed** (deleted; evidence here) +- **Concern**: `v2-packaging.test.ts` in the unit suite → **Addressed** (renamed `v2-packaging.e2e.test.ts`) +- **Concern**: `buildTree` drops unresolvable parents → **Addressed** (machine-level `parent not in tree`) +- Sourcemap: production `sourcemap: false`. `.map` stays in the allowlist for hashed leftovers; none are emitted. +- Cache-Control: not changed +- `"test": "vitest run"` reason recorded in the PR body + +#### Gemini / opencode +Gemini skipped. opencode APPROVE. + +Frozen C1/C2 `git diff --stat origin/main...HEAD` was empty on all 13 paths at phase 5 close, before this review round merged rev. 12 (spec only). + +## Lessons Learned + +### What Went Well + +The fixture as sole origin removed the live-Tower flake class. Frozen-file diffs against `origin/main` made C1/C2 checkable every phase. + +### Challenges Encountered + +Opencode consult cannot read its temp prompt (`external_directory` under `/var/folders`); that lane's reviews were written against disk. `afx self-refresh` refuses on the opencode harness. Phase 3 needed a force-advance plus a follow-up commit for `applied-retry`. + +### What Would Be Done Differently + +Land the fixture's request log (`since`/`stream`/mode) in the first Playwright pass, not after two review rounds. Screenshot the site view at 1440 before calling phase 4 done. + +### Methodology Improvements + +A harness with no in-session clear should not emit a refresh task that can only refuse. Consult should write the opencode prompt inside the worktree, not `/var/folders`. + +## Architecture Updates + +- Routed: cold — `apps/v2` is an end-user surface at `/v2/`, types-only, packaged as `v2-dist` — added to `codev/resources/arch.md` (quick start, monorepo table, graph, tree) +- HOT `arch-critical.md`: no change. Cap is full. Map already has "Monorepo Structure — adding a package or build wiring" + +## Lessons Learned Updates + +- Routed: cold — UI/UX — sibling stamps in one header need flex + gap; `textContent` will not catch `ALPHARUN` +- HOT `lessons-critical.md`: no change. Cap is full. The fact is a UI recipe, not a cross-cutting decision rule + +## Flaky Tests + +No flaky tests encountered. + +## Follow-up Items + +- Live UX pass on this machine: open `/v2/`, spawn a builder, wait past stall, human `afx cleanup`. Fixture already covers `gone`. +- #98 (dark decided once per connection) and #100 (worktree with no `global.db` row) stay filed. #97 is closed. +- `apps/v2` Playwright is not in the root CI matrix. Vitest is. +- Bare `GET /v2` (no trailing slash) is still 401. D9 matches `/v2/` only. diff --git a/codev/state/spir-83_thread.md b/codev/state/spir-83_thread.md new file mode 100644 index 000000000..453772f58 --- /dev/null +++ b/codev/state/spir-83_thread.md @@ -0,0 +1,27 @@ +# spir-83 thread + +Spec 83 already on disk and approved (rev. 11, 2026-08-24). Issue #83 has no Baked Decisions section. Porch is in plan. + +Plan drafted at `codev/plans/83-v2-client-shell.md`. Five phases: static serving / packaging, reducer, stream client, site view, Playwright fixture. No live-Tower e2e; `gone` is a fixture frame. FR-3 and FR-15 stay unmet per D13/D5 (#97, #98). + +Iter 1 reviews: gemini skipped, claude COMMENT, opencode COMMENT, codex REQUEST_CHANGES. All REQUEST_CHANGES accepted. Plan updated. Rebuttal at `codev/projects/83-v2-client-shell-apps-v2-render/83-plan-iter1-rebuttals.md`. + +Plan-approval: architect:uiv2 under standing delegation. Refresh at enter:implement refused (opencode has no in-session clear). Continuing. + +Phase 1 done: `2c9777a0b` plus NODE_ENV fix `8489c45ca`. Reviews: gemini skip, codex APPROVE, claude COMMENT (fixed), opencode APPROVE. + +Phase 2: validate.ts + reducer.ts. 47 client tests green. `passWithNoTests` removed. + +Phase 3: bootstrap + SSE reader + stream client. 100 v2 tests green. + +Iter 1: gemini skip, codex/claude/opencode REQUEST_CHANGES. All accepted (unreachable vs mismatch, forceFresh, cancel SSE, emit copy, wait TDZ, applied-eof). + +Iter 2: gemini skip, claude APPROVE, codex REQUEST_CHANGES (`res.text()` on 200 → mismatch; fixed). Opencode consult CLI cannot read its temp prompt (external_directory). Review written against disk files: APPROVE. + +Phase 4 on 3ff35f22a. Phase 5: fixture server + 14 Playwright tests green. cold-load-ms=137. + +Phase 5 iter 2: Claude/opencode APPROVE. Codex REQUEST_CHANGES accepted except idle CDP (spec says measure/print, not assert). Resume now records since/stream/mode + sentinel; GATE/STALLED computed colours; two-page tree dump. 14 e2e still green. + +Phase 5 iter 3: Codex/Claude/opencode APPROVE. Refresh at enter:review refused (opencode has no in-session clear). Architect verified phase 5; screenshot defect: workspace header `ALPHARUN`. Fixed `.ws-plot-name` flex+gap; name wrapped; 124 unit tests. Review at `codev/reviews/83-v2-client-shell.md`. idle-Bps=0 recorded as no-polling evidence. + +Review iter 1: gemini skip, opencode APPROVE, Codex/Claude REQUEST_CHANGES. Merged origin/main (spec rev. 12). Accepted CI step, drop frozen-files test, rename packaging to e2e, orphan render, architect-parented coverage, sourcemap off. Cache-Control left. PR #104. diff --git a/packages/codev/package.json b/packages/codev/package.json index 1d9398b7b..7dc76946f 100644 --- a/packages/codev/package.json +++ b/packages/codev/package.json @@ -17,19 +17,21 @@ "skeleton", "templates", "dashboard-dist", + "v2-dist", "scripts/forge", "scripts/postinstall.mjs" ], "scripts": { "clean": "rm -rf dist", "build": "pnpm --filter \"@cluesmith/codev^...\" build && pnpm clean && tsc && pnpm bundle-assets", - "bundle-assets": "pnpm copy-dashboard && pnpm copy-skeleton && node ./scripts/copy-three.mjs", + "bundle-assets": "pnpm copy-dashboard && pnpm copy-v2 && pnpm copy-skeleton && node ./scripts/copy-three.mjs", "copy-dashboard": "rm -rf dashboard-dist && cp -r ../../apps/web/dist dashboard-dist", + "copy-v2": "pnpm --filter @cluesmith/codev-v2 build && rm -rf v2-dist && cp -r ../../apps/v2/dist v2-dist", "dev:dashboard": "cd ../../apps/web && pnpm dev", "copy-skeleton": "rm -rf skeleton && cp -r ../../codev-skeleton skeleton", "dev": "tsx src/cli.ts", "start": "node dist/cli.js", - "test": "vitest", + "test": "vitest run && pnpm --filter @cluesmith/codev-v2 test", "test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts", "test:e2e:watch": "vitest --config vitest.e2e.config.ts", "test:e2e:playwright": "pnpm exec playwright test", @@ -57,6 +59,7 @@ "ws": "^8.18.0" }, "devDependencies": { + "@cluesmith/codev-v2": "workspace:*", "@cluesmith/codev-web": "workspace:*", "@playwright/test": "^1.58.0", "@types/better-sqlite3": "^7.6.13", diff --git a/packages/codev/src/agent-farm/__tests__/v2-packaging.e2e.test.ts b/packages/codev/src/agent-farm/__tests__/v2-packaging.e2e.test.ts new file mode 100644 index 000000000..fc93df18e --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/v2-packaging.e2e.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const codevRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../'); + +describe('v2 packaging (D14)', () => { + it('npm pack includes v2-dist after copy-v2', () => { + execSync('pnpm copy-v2', { + cwd: codevRoot, + stdio: 'pipe', + env: { ...process.env, NODE_ENV: 'production' }, + }); + const output = execSync('npm pack --dry-run 2>&1', { + cwd: codevRoot, + encoding: 'utf-8', + }); + expect(output).toContain('v2-dist/index.html'); + expect(output).toContain('v2-dist/assets/'); + }, 180_000); +}); diff --git a/packages/codev/src/agent-farm/__tests__/v2-public-route.test.ts b/packages/codev/src/agent-farm/__tests__/v2-public-route.test.ts new file mode 100644 index 000000000..1824105a6 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/v2-public-route.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as http from 'node:http'; + +const TEST_KEY = 'a'.repeat(64); + +vi.mock('@cluesmith/codev-core/auth', () => ({ + ensureLocalKey: vi.fn(() => TEST_KEY), + readLocalKey: vi.fn(() => TEST_KEY), +})); + +import { + isPublicRoute, + isRequestAllowed, + resetExpectedKeyCache, +} from '../utils/server-utils.js'; +import { ensureLocalKey } from '@cluesmith/codev-core/auth'; + +function req(method: string, url: string, headers: Record = {}): http.IncomingMessage { + return { method, url, headers: { host: 'localhost:4100', ...headers } } as unknown as http.IncomingMessage; +} + +beforeEach(() => { + resetExpectedKeyCache(); + const mock = ensureLocalKey as unknown as ReturnType; + mock.mockReset(); + mock.mockReturnValue(TEST_KEY); +}); + +describe('v2 public vs keyed (scenario 16)', () => { + it('isPublicRoute: GET /v2/ and GET /v2/assets/* only', () => { + expect(isPublicRoute('GET', '/v2/')).toBe(true); + expect(isPublicRoute('GET', '/v2/assets/index.js')).toBe(true); + expect(isPublicRoute('GET', '/v2/assets/app.css')).toBe(true); + + expect(isPublicRoute('GET', '/v2')).toBe(false); + expect(isPublicRoute('GET', '/v2/events')).toBe(false); + expect(isPublicRoute('GET', '/v2/events')).toBe(false); + expect(isPublicRoute('POST', '/v2/')).toBe(false); + expect(isPublicRoute('POST', '/v2/assets/index.js')).toBe(false); + expect(isPublicRoute('GET', '/v2/nonsense')).toBe(false); + }); + + it('isRequestAllowed: keyless GET /v2/ and assets succeed', () => { + expect(isRequestAllowed(req('GET', '/v2/'))).toBe(true); + expect(isRequestAllowed(req('GET', '/v2/assets/index.js'))).toBe(true); + }); + + it('isRequestAllowed: keyless GET /v2/events is rejected', () => { + expect(isRequestAllowed(req('GET', '/v2/events?scope=%2Ftmp'))).toBe(false); + }); + + it('isRequestAllowed: keyless POST /v2/ is rejected', () => { + expect(isRequestAllowed(req('POST', '/v2/'))).toBe(false); + }); + + it('isRequestAllowed: keyed GET /v2/events is allowed', () => { + expect(isRequestAllowed(req('GET', '/v2/events?scope=%2Ftmp', { 'codev-tower-key': TEST_KEY }))).toBe(true); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/v2-scope-encoding.test.ts b/packages/codev/src/agent-farm/__tests__/v2-scope-encoding.test.ts new file mode 100644 index 000000000..115bfac86 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/v2-scope-encoding.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import http from 'node:http'; +import { EventEmitter } from 'node:events'; +import type { V2Counts, V2Node } from '@cluesmith/codev-types'; +import { handleV2Route, resetV2RoutesForTests, setV2RouteDeps } from '../servers/v2-routes.js'; +import { builderId } from '../servers/v2-ids.js'; + +const WS_A = '/tmp/ws-a'; +const WS_B = '/tmp/ws-b'; +const counts: V2Counts = { workspaces: 2, builders: { total: 2, byStatus: { running: 2 } }, gateWaiting: 0 }; + +function builderNode(ws: string, dir: string): V2Node { + return { + id: builderId(ws, dir), + kind: 'builder', + parentId: `workspace:${ws}`, + name: dir, + status: 'running', + flags: { heldMail: false }, + lastDataAt: null, + }; +} + +function makeReq(method: string, url: string): http.IncomingMessage { + const req = new EventEmitter() as http.IncomingMessage; + (req as http.IncomingMessage & { method: string }).method = method; + (req as http.IncomingMessage & { url: string }).url = url; + req.headers = { host: 'localhost:4100' }; + (req as http.IncomingMessage & { socket: { remoteAddress: string } }).socket = { + remoteAddress: '127.0.0.1', + } as http.IncomingMessage['socket']; + return req; +} + +function makeRes(): { + res: http.ServerResponse; + body: () => string; + statusCode: () => number; +} { + const chunks: string[] = []; + let code = 200; + const res = { + writeHead: vi.fn((status: number) => { + code = status; + }), + setHeader: vi.fn(), + end: vi.fn((data?: string | Buffer) => { + if (data) chunks.push(typeof data === 'string' ? data : data.toString()); + }), + write: vi.fn((data: string) => { + chunks.push(data); + }), + on: vi.fn(), + writableEnded: false, + destroyed: false, + } as unknown as http.ServerResponse; + return { res, body: () => chunks.join(''), statusCode: () => code }; +} + +function frames(body: string): Array<{ type: string; [k: string]: unknown }> { + return body + .split('\n\n') + .map((block) => block.replace(/^data: /, '').trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function urlFor(pathAndQuery: string): URL { + return new URL(pathAndQuery, 'http://localhost:4100'); +} + +function encodeScope(paths: string[]): string { + return paths.map((p) => encodeURIComponent(p)).join(','); +} + +describe('scope encoding (scenario 18)', () => { + beforeEach(() => { + resetV2RoutesForTests(); + setV2RouteDeps({ + listWorkspaces: () => [WS_A, WS_B], + project: () => ({ + nodes: [builderNode(WS_A, 'spir-52'), builderNode(WS_B, 'other')], + counts, + }), + now: () => 1_000, + isReadable: () => true, + }); + }); + + it('literal-comma encoding returns both known paths, not empty+dark', async () => { + const encoded = encodeScope([WS_A, WS_B]); + expect(encoded).toBe(`${encodeURIComponent(WS_A)},${encodeURIComponent(WS_B)}`); + expect(encoded).not.toBe(encodeURIComponent([WS_A, WS_B].join(','))); + const q = `/v2/events?scope=${encoded}`; + const { res, body } = makeRes(); + await handleV2Route(makeReq('GET', q), res, urlFor(q)); + const parsed = frames(body()); + expect(parsed[0].type).toBe('snapshot'); + const nodes = parsed[0].nodes as V2Node[]; + expect(nodes.map((n) => n.id).sort()).toEqual( + [builderId(WS_A, 'spir-52'), builderId(WS_B, 'other')].sort(), + ); + expect(parsed.some((f) => f.type === 'dark')).toBe(false); + }); + + it('encodeURIComponent(join) collapses to one unknown path', async () => { + const q = `/v2/events?scope=${encodeURIComponent([WS_A, WS_B].join(','))}`; + const { res, body } = makeRes(); + await handleV2Route(makeReq('GET', q), res, urlFor(q)); + const parsed = frames(body()); + expect(parsed.map((f) => f.type)).toEqual(['snapshot', 'dark']); + expect(parsed[0].nodes).toEqual([]); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/v2-static.test.ts b/packages/codev/src/agent-farm/__tests__/v2-static.test.ts new file mode 100644 index 000000000..1772c929a --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/v2-static.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type http from 'node:http'; +import { handleV2Route, resetV2RoutesForTests } from '../servers/v2-routes.js'; +import { injectV2Key, setV2DistRoot, setV2InjectedKeyForTests } from '../servers/v2-static.js'; + +const GOOD_KEY = 'ab'.repeat(32); +const SHELL = 'v2
'; + +function makeReq(method: string, url: string): http.IncomingMessage { + return { method, url, headers: { host: 'localhost:4100' } } as http.IncomingMessage; +} + +function makeRes(): { + res: http.ServerResponse; + body: () => string; + statusCode: () => number; + headers: () => Record; + removed: string[]; +} { + const chunks: string[] = []; + let code = 200; + const hdrs: Record = { + 'Access-Control-Allow-Origin': '*', + Vary: 'Origin', + }; + const removed: string[] = []; + const res = { + writeHead: vi.fn((status: number, h?: Record) => { + code = status; + if (h) Object.assign(hdrs, h); + }), + setHeader: vi.fn((k: string, v: string) => { hdrs[k] = v; }), + removeHeader: vi.fn((k: string) => { + removed.push(k); + delete hdrs[k]; + }), + end: vi.fn((data?: string | Buffer) => { + if (data) chunks.push(typeof data === 'string' ? data : data.toString()); + }), + write: vi.fn((data: string) => { chunks.push(data); }), + on: vi.fn(), + writableEnded: false, + destroyed: false, + } as unknown as http.ServerResponse; + return { + res, + body: () => chunks.join(''), + statusCode: () => code, + headers: () => hdrs, + removed, + }; +} + +function urlFor(pathAndQuery: string): URL { + return new URL(pathAndQuery, 'http://localhost:4100'); +} + +function makeDist(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'v2-dist-')); + fs.writeFileSync(path.join(dir, 'index.html'), SHELL); + fs.mkdirSync(path.join(dir, 'assets')); + fs.writeFileSync(path.join(dir, 'assets', 'app.js'), 'console.log(1)'); + fs.writeFileSync(path.join(dir, 'assets', 'app.css'), 'h1{color:red}'); + fs.writeFileSync(path.join(dir, 'assets', 'secret.txt'), 'nope'); + return dir; +} + +describe('injectV2Key', () => { + it('embeds a well-formed 64-hex key via JSON.stringify before ', () => { + const out = injectV2Key(SHELL, GOOD_KEY); + expect(out).toContain(``); + expect(out.indexOf('__CODEV_TOWER_KEY__')).toBeLessThan(out.indexOf('')); + }); + + it('does not inject a malformed key', () => { + expect(injectV2Key(SHELL, 'not-a-key')).toBe(SHELL); + expect(injectV2Key(SHELL, `${GOOD_KEY}ff`)).toBe(SHELL); + expect(injectV2Key(SHELL, `${'a'.repeat(56)}`)).toBe(SHELL); + expect(injectV2Key(SHELL, null)).toBe(SHELL); + expect(injectV2Key(SHELL, GOOD_KEY.toUpperCase())).toBe(SHELL); + }); + + it('does not invent a placeholder when is absent', () => { + const bare = ''; + expect(injectV2Key(bare, GOOD_KEY)).toBe(bare); + }); +}); + +describe('serveV2Static via handleV2Route', () => { + let dist: string; + + beforeEach(() => { + resetV2RoutesForTests(); + dist = makeDist(); + setV2DistRoot(dist); + setV2InjectedKeyForTests(GOOD_KEY); + }); + + afterEach(() => { + setV2DistRoot(null); + setV2InjectedKeyForTests(undefined); + fs.rmSync(dist, { recursive: true, force: true }); + }); + + it('serves GET /v2/ with the key injected and CORS headers stripped', async () => { + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/'), out.res, urlFor('/v2/')); + expect(out.statusCode()).toBe(200); + expect(out.removed).toEqual(['Access-Control-Allow-Origin', 'Vary']); + expect(out.headers()['Access-Control-Allow-Origin']).toBeUndefined(); + expect(out.headers().Vary).toBeUndefined(); + expect(out.body()).toContain(`window.__CODEV_TOWER_KEY__ = ${JSON.stringify(GOOD_KEY)}`); + expect(out.body().indexOf('__CODEV_TOWER_KEY__')).toBeLessThan(out.body().indexOf('')); + }); + + it('does not inject when the key is malformed', async () => { + setV2InjectedKeyForTests('bad'); + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/'), out.res, urlFor('/v2/')); + expect(out.statusCode()).toBe(200); + expect(out.body()).not.toContain('__CODEV_TOWER_KEY__'); + expect(out.removed).toEqual(['Access-Control-Allow-Origin', 'Vary']); + }); + + it('serves an allowlisted asset', async () => { + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/assets/app.js'), out.res, urlFor('/v2/assets/app.js')); + expect(out.statusCode()).toBe(200); + expect(out.body()).toBe('console.log(1)'); + expect(out.removed).toEqual([]); + }); + + it('refuses a non-allowlisted extension', async () => { + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/assets/secret.txt'), out.res, urlFor('/v2/assets/secret.txt')); + expect(out.statusCode()).toBe(404); + expect(out.body()).toBe('Not found'); + }); + + it('refuses traversal via /v2/assets/../../etc/passwd', async () => { + const out = makeRes(); + await handleV2Route( + makeReq('GET', '/v2/assets/../../etc/passwd'), + out.res, + urlFor('/v2/assets/../../etc/passwd'), + ); + expect(out.statusCode()).toBe(404); + }); + + it('refuses a pathname that still contains ..', async () => { + const url = urlFor('/v2/assets/x'); + Object.defineProperty(url, 'pathname', { value: '/v2/assets/foo/../../../etc/passwd' }); + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/assets/foo/../../../etc/passwd'), out.res, url); + expect(out.statusCode()).toBe(404); + }); + + it('404s GET /v2/nonsense', async () => { + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/nonsense'), out.res, urlFor('/v2/nonsense')); + expect(out.statusCode()).toBe(404); + expect(out.body()).toBe('Not found'); + expect(out.removed).toEqual([]); + }); + + it('404s non-GET on /v2/', async () => { + const out = makeRes(); + await handleV2Route(makeReq('POST', '/v2/'), out.res, urlFor('/v2/')); + expect(out.statusCode()).toBe(404); + expect(out.removed).toEqual([]); + }); + + it('404s GET /v2/ when dist is missing', async () => { + setV2DistRoot(path.join(dist, 'does-not-exist')); + const out = makeRes(); + await handleV2Route(makeReq('GET', '/v2/'), out.res, urlFor('/v2/')); + expect(out.statusCode()).toBe(404); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/v2-routes.ts b/packages/codev/src/agent-farm/servers/v2-routes.ts index 02719ca23..fa9d6c64b 100644 --- a/packages/codev/src/agent-farm/servers/v2-routes.ts +++ b/packages/codev/src/agent-farm/servers/v2-routes.ts @@ -15,6 +15,7 @@ import { } from './tower-terminals.js'; import { projectHierarchy, type V2Deps, type V2Projection } from './v2-projection.js'; import { V2Sampler, V2_BUCKET_SLOTS } from './v2-sampler.js'; +import { serveV2Static } from './v2-static.js'; export const V2_EVENTS_PATH = '/v2/events'; export const V2_MAX_CLIENTS = 50; @@ -248,8 +249,7 @@ export async function handleV2Route( url: URL, ): Promise { if (url.pathname !== V2_EVENTS_PATH) { - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not found'); + serveV2Static(req, res, url); return; } if (req.method !== 'GET') { diff --git a/packages/codev/src/agent-farm/servers/v2-static.ts b/packages/codev/src/agent-farm/servers/v2-static.ts new file mode 100644 index 000000000..5107689ad --- /dev/null +++ b/packages/codev/src/agent-farm/servers/v2-static.ts @@ -0,0 +1,129 @@ +/** + * Serves the apps/v2 shell and assets under GET /v2/ and GET /v2/assets/*. + * + * Mirrors injectWebKey / sendKeyInjectedHtml in tower-routes.ts (module-private, + * file frozen by spec 83 C1). Three load-bearing properties, each tested: + * 1. Key is embedded via JSON.stringify only when it matches /^[0-9a-f]{64}$/. + * 2. Access-Control-Allow-Origin and Vary are removed from the HTML response. + * 3. The injection lands before , ahead of the deferred module. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type * as http from 'node:http'; +import { getExpectedKey } from '../utils/server-utils.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DEFAULT_V2_DIST = path.resolve(__dirname, '../../../v2-dist'); +const KEY_RE = /^[0-9a-f]{64}$/; +const ASSET_EXT = new Set(['.js', '.css', '.map', '.svg', '.woff2', '.png', '.ico']); +const MIME: Record = { + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.map': 'application/json', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +let v2DistRoot = DEFAULT_V2_DIST; +let keyOverride: string | null | undefined; + +export function setV2DistRoot(root: string | null): void { + v2DistRoot = root ?? DEFAULT_V2_DIST; +} + +export function setV2InjectedKeyForTests(key: string | null | undefined): void { + keyOverride = key; +} + +function keyToInject(): string | null { + return keyOverride !== undefined ? keyOverride : getExpectedKey(); +} + +export function injectV2Key(html: string, key: string | null): string { + const injection = key && KEY_RE.test(key) + ? `` + : ''; + if (injection && html.includes('')) { + return html.replace('', `${injection}`); + } + return html; +} + +function notFound(res: http.ServerResponse): void { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); +} + +function serveIndex(res: http.ServerResponse): void { + const file = path.join(v2DistRoot, 'index.html'); + let html: string; + try { + html = fs.readFileSync(file, 'utf8'); + } catch { + notFound(res); + return; + } + res.removeHeader('Access-Control-Allow-Origin'); + res.removeHeader('Vary'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(injectV2Key(html, keyToInject())); +} + +function serveAsset(res: http.ServerResponse, pathname: string): void { + const rel = pathname.slice('/v2/assets/'.length); + if ( + rel === '' || + rel.includes('..') || + rel.includes('\0') || + path.isAbsolute(rel) || + rel.includes('\\') + ) { + notFound(res); + return; + } + const ext = path.extname(rel).toLowerCase(); + if (!ASSET_EXT.has(ext)) { + notFound(res); + return; + } + const assetsRoot = path.resolve(v2DistRoot, 'assets'); + const file = path.resolve(assetsRoot, rel); + const rootWithSep = assetsRoot.endsWith(path.sep) ? assetsRoot : assetsRoot + path.sep; + if (!file.startsWith(rootWithSep) && file !== assetsRoot) { + notFound(res); + return; + } + let body: Buffer; + try { + body = fs.readFileSync(file); + } catch { + notFound(res); + return; + } + res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' }); + res.end(body); +} + +export function serveV2Static( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, +): void { + if (req.method !== 'GET') { + notFound(res); + return; + } + if (url.pathname === '/v2/') { + serveIndex(res); + return; + } + if (url.pathname.startsWith('/v2/assets/')) { + serveAsset(res, url.pathname); + return; + } + notFound(res); +} diff --git a/packages/codev/src/agent-farm/utils/server-utils.ts b/packages/codev/src/agent-farm/utils/server-utils.ts index 369567c4a..e1b28dcae 100644 --- a/packages/codev/src/agent-farm/utils/server-utils.ts +++ b/packages/codev/src/agent-farm/utils/server-utils.ts @@ -145,6 +145,8 @@ export function isPublicRoute(method: string, pathname: string): boolean { if (pathname === '/health') return true; if (pathname === '/api/version') return true; if (pathname === '/' || pathname === '/index.html') return true; + if (pathname === '/v2/') return true; + if (pathname.startsWith('/v2/assets/')) return true; // React SPA served under /workspace//... — static assets only. The // trailing subpath is optional: bare /workspace/ serves the SPA shell, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 318cd1d42..cf1a10425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,49 @@ importers: specifier: ^4.0.15 version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) + apps/v2: + dependencies: + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + devDependencies: + '@cluesmith/codev-types': + specifier: workspace:* + version: link:../../packages/types + '@playwright/test': + specifier: ^1.58.0 + version: 1.59.1 + '@testing-library/jest-dom': + specifier: ^6.6.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: ^6.0.0 + version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0) + vitest: + specifier: ^4.0.0 + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) + apps/vscode: dependencies: '@cluesmith/codev-artifact-canvas': @@ -273,6 +316,9 @@ importers: specifier: ^8.18.0 version: 8.20.0 devDependencies: + '@cluesmith/codev-v2': + specifier: workspace:* + version: link:../../apps/v2 '@cluesmith/codev-web': specifier: workspace:* version: link:../../apps/web @@ -5398,7 +5444,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) '@vitest/expect@4.1.4': dependencies: