From 87213bd18aeac25de8fd8f60d10e3162b0725e72 Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Mon, 31 Aug 2026 12:19:28 +0200 Subject: [PATCH 01/33] feat: add evidence contract, proof config and pilot groundwork --- packages/cli/src/repo-config.test.ts | 87 ++++++ packages/cli/src/repo-config.ts | 47 ++++ packages/contract/src/evidence.test.ts | 256 ++++++++++++++++++ packages/contract/src/evidence.ts | 102 +++++++ packages/contract/src/index.ts | 1 + .../src/components/TaskConversation.test.ts | 33 +++ .../web/src/components/TaskConversation.vue | 69 +---- .../components/composer/QuickReplies.test.ts | 86 ++++++ .../src/components/composer/QuickReplies.vue | 77 ++++++ .../conversations/ChecksChip.test.ts | 87 ++++++ .../components/conversations/ChecksChip.vue | 70 +++++ .../conversations/ConversationRow.test.ts | 12 +- .../conversations/ConversationRow.vue | 43 +-- .../src/components/pilot/PilotLogic.test.ts | 191 +++++++++++++ .../web/src/components/pilot/PilotLogic.ts | 73 +++++ .../web/src/composables/usePilotPrefs.test.ts | 153 +++++++++++ packages/web/src/composables/usePilotPrefs.ts | 109 ++++++++ packages/web/src/i18n.ts | 53 ++++ 18 files changed, 1440 insertions(+), 109 deletions(-) create mode 100644 packages/contract/src/evidence.test.ts create mode 100644 packages/contract/src/evidence.ts create mode 100644 packages/web/src/components/composer/QuickReplies.test.ts create mode 100644 packages/web/src/components/composer/QuickReplies.vue create mode 100644 packages/web/src/components/conversations/ChecksChip.test.ts create mode 100644 packages/web/src/components/conversations/ChecksChip.vue create mode 100644 packages/web/src/components/pilot/PilotLogic.test.ts create mode 100644 packages/web/src/components/pilot/PilotLogic.ts create mode 100644 packages/web/src/composables/usePilotPrefs.test.ts create mode 100644 packages/web/src/composables/usePilotPrefs.ts diff --git a/packages/cli/src/repo-config.test.ts b/packages/cli/src/repo-config.test.ts index e17af0e..01caa2c 100644 --- a/packages/cli/src/repo-config.test.ts +++ b/packages/cli/src/repo-config.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { saveGlobalConfig, saveRepoConfig } from './config.js' import { readChecksConfig, + readProofConfig, readRulesContent, readSyncAutoPush, rulesFilePath, @@ -206,3 +207,89 @@ describe('writeChecksConfig', () => { expect(() => writeChecksConfig(repoDir, checks)).toThrow(/not a JSON object/) }) }) + +describe('readProofConfig', () => { + let repoDir: string + + beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), 'codesema-proofcfg-')) + }) + + afterEach(() => { + rmSync(repoDir, { recursive: true, force: true }) + }) + + const writeRepoConfig = (content: string) => { + mkdirSync(join(repoDir, '.codesema'), { recursive: true }) + writeFileSync(join(repoDir, '.codesema', 'config.json'), content) + } + + test('missing file, invalid json or absent proof key: null', () => { + expect(readProofConfig(repoDir)).toBeNull() + writeRepoConfig('{ not json') + expect(readProofConfig(repoDir)).toBeNull() + writeRepoConfig(JSON.stringify({ agent: 'claude -p' })) + expect(readProofConfig(repoDir)).toBeNull() + writeRepoConfig(JSON.stringify({ proof: 'yes' })) + expect(readProofConfig(repoDir)).toBeNull() + writeRepoConfig(JSON.stringify({ proof: ['npm test'] })) + expect(readProofConfig(repoDir)).toBeNull() + }) + + test('journey missing: the whole record is null', () => { + writeRepoConfig(JSON.stringify({ proof: { url: 'http://localhost:3000' } })) + expect(readProofConfig(repoDir)).toBeNull() + }) + + test('url missing: the whole record is null', () => { + writeRepoConfig(JSON.stringify({ proof: { journey: 'checkout' } })) + expect(readProofConfig(repoDir)).toBeNull() + }) + + test('out-of-range timeoutSeconds and keep fall back to null, not the record', () => { + writeRepoConfig( + JSON.stringify({ + proof: { + journey: 'checkout', + url: 'http://localhost:3000', + timeoutSeconds: -5, + keep: 0, + }, + }), + ) + expect(readProofConfig(repoDir)).toEqual({ + journey: 'checkout', + url: 'http://localhost:3000', + timeoutSeconds: null, + keep: null, + }) + }) + + test('a full proof block is read back field by field', () => { + writeRepoConfig( + JSON.stringify({ + proof: { + journey: 'checkout', + url: 'http://localhost:3000', + timeoutSeconds: 120, + keep: 5, + }, + }), + ) + expect(readProofConfig(repoDir)).toEqual({ + journey: 'checkout', + url: 'http://localhost:3000', + timeoutSeconds: 120, + keep: 5, + }) + }) + + test('keep is capped at 20', () => { + writeRepoConfig( + JSON.stringify({ + proof: { journey: 'checkout', url: 'http://localhost:3000', keep: 999 }, + }), + ) + expect(readProofConfig(repoDir)?.keep).toBe(20) + }) +}) diff --git a/packages/cli/src/repo-config.ts b/packages/cli/src/repo-config.ts index 9863f4c..e02b320 100644 --- a/packages/cli/src/repo-config.ts +++ b/packages/cli/src/repo-config.ts @@ -95,6 +95,53 @@ export function readChecksConfig(repoRoot: string): ChecksConfig | null { } } +export type ProofConfig = { + journey: string + url: string + timeoutSeconds: number | null + keep: number | null +} + +const PROOF_STRING_MAX = 500 +const PROOF_KEEP_MAX = 20 + +/** + * Reads the repo's `proof` key. Same raw re-read pattern as readChecksConfig + * (config.ts's parseConfig whitelists its own fields). journey and url are + * mandatory: without a journey nothing is replayable, so a missing/invalid + * one degrades the whole record to null instead of a partial config. + */ +export function readProofConfig(repoRoot: string): ProofConfig | null { + const path = repoConfigPath(repoRoot) + let raw: unknown + try { + raw = JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } + const proof = (raw as { proof?: unknown } | null)?.proof + if (!proof || typeof proof !== 'object' || Array.isArray(proof)) { + return null + } + const p = proof as Record + const str = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() ? v.trim().slice(0, PROOF_STRING_MAX) : undefined + const journey = str(p.journey) + const url = str(p.url) + if (journey === undefined || url === undefined) { + return null + } + const timeoutSeconds = + Number.isInteger(p.timeoutSeconds) && (p.timeoutSeconds as number) > 0 + ? (p.timeoutSeconds as number) + : null + const keep = + Number.isInteger(p.keep) && (p.keep as number) > 0 + ? Math.min(p.keep as number, PROOF_KEEP_MAX) + : null + return { journey, url, timeoutSeconds, keep } +} + /** * Writes ONLY the `checks` key of .codesema/config.json, keeping every other * key (agent, port, language...) byte-for-byte in place — this is a diff --git a/packages/contract/src/evidence.test.ts b/packages/contract/src/evidence.test.ts new file mode 100644 index 0000000..f9e3628 --- /dev/null +++ b/packages/contract/src/evidence.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from 'bun:test' +import { + EVIDENCE_ITEMS_MAX, + EVIDENCE_PATH_MAX, + EVIDENCE_REASON_MAX, + sanitizeEvidence, + type EvidenceRecord, +} from './evidence.js' + +const FULL_RECORD: EvidenceRecord = { + version: 1, + status: 'passed', + reason: null, + head_sha: 'a1b2c3d4', + items: [ + { + kind: 'screenshot', + path: 'step-1.png', + bytes: 1_024, + turn: 1, + created_at: '2026-08-30T10:00:00Z', + }, + { kind: 'video', path: 'run.mp4', bytes: 2_048, turn: 2, created_at: '2026-08-30T10:05:00Z' }, + ], +} + +test('published bounds are locked to their literal values', () => { + expect(EVIDENCE_ITEMS_MAX).toBe(40) + expect(EVIDENCE_PATH_MAX).toBe(200) + expect(EVIDENCE_REASON_MAX).toBe(2_000) +}) + +describe('sanitizeEvidence', () => { + test('a valid, full record round-trips unchanged', () => { + expect(sanitizeEvidence(structuredClone(FULL_RECORD))).toEqual(FULL_RECORD) + }) + + test('a minimal record (no reason, no head_sha, no items) is honest about the rest', () => { + const out = sanitizeEvidence({ version: 1, status: 'skipped', items: [] }) + expect(out).toEqual({ version: 1, status: 'skipped', reason: null, head_sha: null, items: [] }) + }) + + test('non-object input never throws and returns null', () => { + for (const raw of [null, undefined, 42, 'x', [], Symbol('x'), true]) { + expect(() => sanitizeEvidence(raw)).not.toThrow() + expect(sanitizeEvidence(raw)).toBeNull() + } + }) + + test('an unknown or missing version is refused: the record identity is version 1', () => { + for (const raw of [ + { version: 2, status: 'passed', items: [] }, + { version: '1', status: 'passed', items: [] }, + { status: 'passed', items: [] }, + ]) { + expect(sanitizeEvidence(raw)).toBeNull() + } + }) + + test('a status outside the union is refused', () => { + expect(sanitizeEvidence({ version: 1, status: 'bogus', items: [] })).toBeNull() + }) + + test('reason: over-long is truncated to its published bound, not dropped', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'failed', + reason: 'x'.repeat(EVIDENCE_REASON_MAX + 500), + items: [], + }) + expect(out?.reason).toHaveLength(EVIDENCE_REASON_MAX) + }) + + test('reason: absent or non-string is null, never a placeholder', () => { + for (const bad of [undefined, 42, null, {}, []]) { + const out = sanitizeEvidence({ version: 1, status: 'passed', reason: bad, items: [] }) + expect(out?.reason).toBeNull() + } + }) + + test('head_sha: non-string or empty is null, never a placeholder', () => { + for (const bad of [undefined, 42, null, '', {}, []]) { + const out = sanitizeEvidence({ version: 1, status: 'passed', head_sha: bad, items: [] }) + expect(out?.head_sha).toBeNull() + } + }) + + test('unknown top-level fields are dropped (whitelist)', () => { + const withExtra = { ...structuredClone(FULL_RECORD), evil: 'payload', __proto__: { x: 1 } } + expect(Object.keys(sanitizeEvidence(withExtra) ?? {})).not.toContain('evil') + }) + + test('unknown item fields are dropped (whitelist)', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [ + { + kind: 'screenshot', + path: 'ok.png', + bytes: 1, + turn: 1, + created_at: 'x', + evil: 'payload', + }, + ], + }) + expect(out?.items).toEqual([ + { kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: 'x' }, + ]) + }) + + test('items[]: an entry with an unrecognized kind is dropped, not the whole list', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [ + { kind: 'gif', path: 'a.gif', bytes: 1, turn: 1, created_at: 'x' }, + { kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: 'x' }, + ], + }) + expect(out?.items).toEqual([ + { kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: 'x' }, + ]) + }) + + test('items[].path: a traversal segment ("../x") is rejected outright', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path: '../x', bytes: 1, turn: 1, created_at: 'x' }], + }) + expect(out?.items).toEqual([]) + }) + + test('items[].path: any path containing a slash is rejected', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path: 'dir/file.png', bytes: 1, turn: 1, created_at: 'x' }], + }) + expect(out?.items).toEqual([]) + }) + + test('items[].path: over the max length is rejected outright, never truncated', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [ + { + kind: 'screenshot', + path: `${'a'.repeat(EVIDENCE_PATH_MAX + 1)}.png`, + bytes: 1, + turn: 1, + created_at: 'x', + }, + ], + }) + expect(out?.items).toEqual([]) + }) + + test('items[].path: a path at exactly the max length, matching the whitelist, is kept', () => { + const path = 'a'.repeat(EVIDENCE_PATH_MAX) + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path, bytes: 1, turn: 1, created_at: 'x' }], + }) + expect(out?.items).toEqual([{ kind: 'screenshot', path, bytes: 1, turn: 1, created_at: 'x' }]) + }) + + test('items[].bytes: a negative, non-integer or non-numeric value is rejected, one entry at a time', () => { + for (const bad of [-1, 1.5, 'x', null, undefined, Number.NaN, 2 ** 53]) { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path: 'ok.png', bytes: bad, turn: 1, created_at: 'x' }], + }) + expect(out?.items).toEqual([]) + } + }) + + test('items[].turn: a negative, non-integer or non-numeric value is rejected, one entry at a time', () => { + for (const bad of [-1, 1.5, 'x', null, undefined, Number.NaN]) { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path: 'ok.png', bytes: 1, turn: bad, created_at: 'x' }], + }) + expect(out?.items).toEqual([]) + } + }) + + test('items[].created_at: a non-string or empty value is rejected, one entry at a time', () => { + for (const bad of ['', 42, null, undefined]) { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [{ kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: bad }], + }) + expect(out?.items).toEqual([]) + } + }) + + test('items[]: a non-object entry (null, string, array) is dropped, never thrown on', () => { + const out = sanitizeEvidence({ + version: 1, + status: 'passed', + items: [ + null, + 'x', + [], + { kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: 'x' }, + ], + }) + expect(out?.items).toEqual([ + { kind: 'screenshot', path: 'ok.png', bytes: 1, turn: 1, created_at: 'x' }, + ]) + }) + + test('items[] is capped at EVIDENCE_ITEMS_MAX; the excess is discarded, not the whole list', () => { + const many = Array.from({ length: EVIDENCE_ITEMS_MAX + 1 }, (_, i) => ({ + kind: 'screenshot' as const, + path: `s${i}.png`, + bytes: 1, + turn: i, + created_at: 'x', + })) + const out = sanitizeEvidence({ version: 1, status: 'passed', items: many }) + expect(out?.items).toHaveLength(EVIDENCE_ITEMS_MAX) + }) + + test('items that is not an array yields an empty list, never a throw', () => { + for (const bad of ['not-an-array', 42, null]) { + const out = sanitizeEvidence({ version: 1, status: 'passed', items: bad }) + expect(out?.items).toEqual([]) + } + }) + + test('hostile entry never throws: wrong types everywhere, null and undefined', () => { + const hostile = { + version: '1', + status: 'BOGUS', + reason: { nested: true }, + head_sha: 42, + items: [ + { kind: 'gif', path: 'a.gif', bytes: 1, turn: 1, created_at: 'x' }, + { kind: 'screenshot', path: '../etc/passwd', bytes: -1, turn: 'z', created_at: '' }, + null, + 'x', + ], + } + expect(() => sanitizeEvidence(hostile)).not.toThrow() + expect(sanitizeEvidence(hostile)).toBeNull() + }) +}) diff --git a/packages/contract/src/evidence.ts b/packages/contract/src/evidence.ts new file mode 100644 index 0000000..2b94b63 --- /dev/null +++ b/packages/contract/src/evidence.ts @@ -0,0 +1,102 @@ +import { cutCodePoints } from './ticket.js' + +export type EvidenceKind = 'screenshot' | 'video' + +export type EvidenceItem = { + kind: EvidenceKind + path: string + bytes: number + turn: number + created_at: string +} + +export type EvidenceStatus = 'passed' | 'failed' | 'skipped' + +export type EvidenceRecord = { + version: 1 + status: EvidenceStatus + reason: string | null + head_sha: string | null + items: EvidenceItem[] +} + +export const EVIDENCE_ITEMS_MAX = 40 +export const EVIDENCE_PATH_MAX = 200 +export const EVIDENCE_REASON_MAX = 2_000 + +const EVIDENCE_KINDS: ReadonlySet = new Set(['screenshot', 'video']) +const EVIDENCE_STATUSES: ReadonlySet = new Set(['passed', 'failed', 'skipped']) +const EVIDENCE_PATH_PATTERN = /^[A-Za-z0-9._-]+$/ + +function isNonNegativeInt(v: unknown): v is number { + return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 +} + +function sanitizeEvidenceItem(raw: unknown): EvidenceItem | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + if (!EVIDENCE_KINDS.has(r.kind as EvidenceKind)) { + return null + } + if ( + typeof r.path !== 'string' || + r.path.length > EVIDENCE_PATH_MAX || + !EVIDENCE_PATH_PATTERN.test(r.path) + ) { + return null + } + if (!isNonNegativeInt(r.bytes) || !isNonNegativeInt(r.turn)) { + return null + } + if (typeof r.created_at !== 'string' || r.created_at.length === 0) { + return null + } + return { + kind: r.kind as EvidenceKind, + path: r.path, + bytes: r.bytes, + turn: r.turn, + created_at: r.created_at, + } +} + +function sanitizeEvidenceItems(raw: unknown): EvidenceItem[] { + if (!Array.isArray(raw)) { + return [] + } + const out: EvidenceItem[] = [] + for (const item of raw) { + if (out.length >= EVIDENCE_ITEMS_MAX) { + break + } + const sanitized = sanitizeEvidenceItem(item) + if (sanitized) { + out.push(sanitized) + } + } + return out +} + +export function sanitizeEvidence(raw: unknown): EvidenceRecord | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + if (r.version !== 1) { + return null + } + if (!EVIDENCE_STATUSES.has(r.status as EvidenceStatus)) { + return null + } + const reason = typeof r.reason === 'string' ? cutCodePoints(r.reason, EVIDENCE_REASON_MAX) : null + const headSha = typeof r.head_sha === 'string' && r.head_sha.length > 0 ? r.head_sha : null + return { + version: 1, + status: r.status as EvidenceStatus, + reason, + head_sha: headSha, + items: sanitizeEvidenceItems(r.items), + } +} diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index e607781..aee6d3e 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -16,6 +16,7 @@ import { // All agent input passes through here: whitelist and truncate, never throw. export * from './arm.js' +export * from './evidence.js' export * from './reasons.js' export * from './recap.js' export * from './runbook.js' diff --git a/packages/web/src/components/TaskConversation.test.ts b/packages/web/src/components/TaskConversation.test.ts index 8e89696..b57b24b 100644 --- a/packages/web/src/components/TaskConversation.test.ts +++ b/packages/web/src/components/TaskConversation.test.ts @@ -323,3 +323,36 @@ describe('the user turn renders markdown, like the assistant beside it', () => { expect(html).toContain('plain text') }) }) + +// The quick-reply buttons were extracted into their own component +// (composer/QuickReplies.vue); this proves the wiring still holds end to +// end — the parent still derives `quickReplies` from the active question and +// still passes it down, rather than checking the rendering details already +// covered by QuickReplies.test.ts. +describe('TaskConversation wires the extracted QuickReplies component', () => { + const disjunctiveQuestion: TaskEvent = { + seq: 2, + at: '2026-08-30T09:00:00.000Z', + type: 'question', + data: { question: 'Do you want to use v2 or the optional field?' }, + } + + test('an active question with enumerated options renders the quick-reply buttons', async () => { + const html = await renderConversation({ + record: { status: 'waiting_for_you' }, + events: [disjunctiveQuestion], + }) + expect(html).toContain('→ v2') + expect(html).toContain('→ the optional field') + expect(html).toContain(t('workspace.quickReplyOther')) + }) + + test('the same question renders no quick replies once the task is no longer waiting', async () => { + const html = await renderConversation({ + record: { status: 'running' }, + events: [disjunctiveQuestion, { ...turnStarted, seq: 3 }], + }) + expect(html).not.toContain('→ v2') + expect(html).not.toContain(t('workspace.quickReplyOther')) + }) +}) diff --git a/packages/web/src/components/TaskConversation.vue b/packages/web/src/components/TaskConversation.vue index 87e3731..89426f9 100644 --- a/packages/web/src/components/TaskConversation.vue +++ b/packages/web/src/components/TaskConversation.vue @@ -67,6 +67,7 @@ import type { TaskEvent, TaskStatus as TaskStatus2, } from '../types' +import QuickReplies from './composer/QuickReplies.vue' import PreviewPanel from './PreviewPanel.vue' import TaskEventUser from './task-events/TaskEventUser.vue' @@ -1016,21 +1017,12 @@ const wait = computed(() => -
- - -
+ @@ -1782,53 +1774,6 @@ const wait = computed(() => gap: 2px; } -/* ── Quick replies ────────────────────────────────────────────────────── */ -.cv-quick { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 2px; -} - -/* Amber: answering IS the pending human action. */ -.cv-quick-opt { - font-size: 12.5px; - font-weight: 600; - font-family: inherit; - padding: 8px 14px; - border: 1px solid var(--cs-amber-line); - border-radius: 8px; - background: var(--cs-amber-soft); - color: var(--cs-amber-text); - cursor: pointer; - overflow-wrap: anywhere; - text-align: left; -} - -.cv-quick-opt:hover:not(:disabled) { - border-color: var(--cs-amber); -} - -.cv-quick-opt:disabled { - opacity: 0.5; - cursor: default; -} - -.cv-quick-other { - font-size: 12.5px; - font-family: inherit; - padding: 8px 14px; - border: 1px solid var(--cs-line-3); - border-radius: 8px; - background: transparent; - color: var(--cs-muted); - cursor: pointer; -} - -.cv-quick-other:hover { - color: var(--cs-text); -} - /* ── Composer ─────────────────────────────────────────────────────────── */ .cv-reply { display: flex; diff --git a/packages/web/src/components/composer/QuickReplies.test.ts b/packages/web/src/components/composer/QuickReplies.test.ts new file mode 100644 index 0000000..ee09fcc --- /dev/null +++ b/packages/web/src/components/composer/QuickReplies.test.ts @@ -0,0 +1,86 @@ +// SSR string-render tests, same harness as ChatComposer.test.ts: the click +// handlers (`emit('pick', option)`, `emit('other')`) are not observable here +// since `renderToString` never triggers a DOM event — this file only checks +// the markup a given prop bag renders. +import { describe, expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import { t } from '../../i18n' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +type Props = { + options: string[] + disabled?: boolean +} + +async function render(props: Props): Promise { + const QuickReplies = (await import('./QuickReplies.vue')).default + const app = createSSRApp(QuickReplies, props) + return renderToString(app) +} + +describe('QuickReplies: renders nothing without options', () => { + test('an empty options list renders no markup at all', async () => { + const html = await render({ options: [] }) + expect(html.trim()).toBe('') + }) +}) + +describe('QuickReplies: one button per option, plus the "other" escape hatch', () => { + test('each option becomes its own button, arrow-prefixed', async () => { + const html = await render({ options: ['v2', 'the optional field'] }) + expect(html).toContain('→ v2') + expect(html).toContain('→ the optional field') + }) + + test('the "other" button carries the shared quickReplyOther string', async () => { + const html = await render({ options: ['v2', 'v3'] }) + expect(html).toContain(t('workspace.quickReplyOther')) + }) + + test('option buttons are type="button" so they never submit a form', async () => { + const html = await render({ options: ['A', 'B'] }) + const buttons = [...html.matchAll(/]*class="qr-opt"[^>]*>/g)].map((m) => m[0]) + expect(buttons).toHaveLength(2) + for (const button of buttons) { + expect(button).toContain('type="button"') + } + }) +}) + +describe('QuickReplies: disabled state only reaches the option buttons', () => { + test('disabled marks every option button', async () => { + const html = await render({ options: ['A', 'B'], disabled: true }) + const buttons = [...html.matchAll(/]*class="qr-opt"[^>]*>/g)].map((m) => m[0]) + expect(buttons).toHaveLength(2) + for (const button of buttons) { + expect(button).toContain('disabled') + } + }) + + test('the "other" button is never disabled', async () => { + const html = await render({ options: ['A', 'B'], disabled: true }) + const match = html.match(/]*class="qr-other"[^>]*>/) + expect(match).not.toBeNull() + expect(match?.[0]).not.toContain('disabled') + }) + + test('undefined disabled leaves the option buttons enabled', async () => { + const html = await render({ options: ['A'] }) + const match = html.match(/]*class="qr-opt"[^>]*>/) + expect(match).not.toBeNull() + expect(match?.[0]).not.toContain('disabled') + }) +}) diff --git a/packages/web/src/components/composer/QuickReplies.vue b/packages/web/src/components/composer/QuickReplies.vue new file mode 100644 index 0000000..c6d7167 --- /dev/null +++ b/packages/web/src/components/composer/QuickReplies.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/web/src/components/conversations/ChecksChip.test.ts b/packages/web/src/components/conversations/ChecksChip.test.ts new file mode 100644 index 0000000..28d7d94 --- /dev/null +++ b/packages/web/src/components/conversations/ChecksChip.test.ts @@ -0,0 +1,87 @@ +// Same harness as ConversationRow.test.ts: `vue/compiler-sfc` compiles the SFC +// with its template inlined, then `vue/server-renderer` renders to a STRING +// (no DOM). CSS-only facts are pinned by slicing the raw source, the same +// escape hatch ForgeControlsPanel.test.ts uses for its own chevron rotation. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' +import { createSSRApp } from 'vue' +import { compileScript, parse } from 'vue/compiler-sfc' +import { renderToString } from 'vue/server-renderer' +import type { ReferencePill } from './ConversationsLogic' + +Bun.plugin({ + name: 'vue-sfc-with-template', + setup(build) { + build.onLoad({ filter: /\.vue$/ }, async (args) => { + const source = await Bun.file(args.path).text() + const { descriptor } = parse(source, { filename: args.path }) + const compiled = compileScript(descriptor, { id: args.path, inlineTemplate: true }) + return { contents: compiled.content, loader: 'ts' } + }) + }, +}) + +const SOURCE = readFileSync(join(import.meta.dir, 'ChecksChip.vue'), 'utf8') + +async function render(pill: ReferencePill): Promise { + const ChecksChip = (await import('./ChecksChip.vue')).default + const app = createSSRApp(ChecksChip, { pill }) + return renderToString(app) +} + +describe('glyph and text', () => { + test('a check glyph renders the lucide check icon and the text verbatim', async () => { + const html = await render({ tone: 'green', glyph: 'check', text: 'Checks passed' }) + expect(html).toContain('lucide-check') + expect(html).toContain('Checks passed') + }) + + test('an x glyph renders the lucide x icon', async () => { + const html = await render({ tone: 'red', glyph: 'x', text: 'Checks failed' }) + expect(html).toContain('lucide-x') + expect(html).toContain('Checks failed') + }) + + test('an alert-triangle glyph renders the lucide alert-triangle icon', async () => { + const html = await render({ tone: 'red', glyph: 'alert-triangle', text: 'Merge conflict' }) + expect(html).toContain('lucide-triangle-alert') + expect(html).toContain('Merge conflict') + }) + + test('a dot glyph renders a static dot, never an icon component or a spin class', async () => { + const html = await render({ tone: 'amber', glyph: 'dot', text: 'Checks running' }) + expect(html).toContain('cc-dot') + expect(html).not.toContain('lucide-') + expect(html).not.toContain('spin') + }) +}) + +describe('tone', () => { + test('each tone applies its own class', async () => { + for (const tone of ['red', 'amber', 'green'] as const) { + const html = await render({ tone, glyph: 'check', text: 'x' }) + expect(html).toContain(`cc-pill--${tone}`) + } + }) +}) + +describe('the pill border colour is reserved to a checks state, never decorative', () => { + test('the base pill border is neutral, no state colour of its own', () => { + const rule = SOURCE.slice(SOURCE.indexOf('.cc-pill {'), SOURCE.indexOf('.cc-pill-icon {')) + expect(rule).toContain('border: 1px solid var(--cs-line-2);') + expect(rule).not.toContain('--cs-red') + expect(rule).not.toContain('--cs-amber') + expect(rule).not.toContain('--cs-green') + }) + + test('each tone overrides the border colour with its own state token', () => { + function ruleBody(selector: string): string { + const at = SOURCE.indexOf(selector) + return SOURCE.slice(at, SOURCE.indexOf('}', at)) + } + expect(ruleBody('.cc-pill--red')).toContain('--cs-red-line') + expect(ruleBody('.cc-pill--amber')).toContain('--cs-amber-line') + expect(ruleBody('.cc-pill--green')).toContain('--cs-green-ring') + }) +}) diff --git a/packages/web/src/components/conversations/ChecksChip.vue b/packages/web/src/components/conversations/ChecksChip.vue new file mode 100644 index 0000000..13c66bc --- /dev/null +++ b/packages/web/src/components/conversations/ChecksChip.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/web/src/components/conversations/ConversationRow.test.ts b/packages/web/src/components/conversations/ConversationRow.test.ts index 8615edb..25517ac 100644 --- a/packages/web/src/components/conversations/ConversationRow.test.ts +++ b/packages/web/src/components/conversations/ConversationRow.test.ts @@ -159,14 +159,14 @@ describe('reference pills: ticket and checks, both optional, independent', () => test('shipped never shows a checks pill, even with a failed run recorded', async () => { const state = taskState({ status: 'shipped' }, { checks: checks({ status: 'failed' }) }) const html = await render(state) - expect(html).not.toContain('cvr-pill--red') + expect(html).not.toContain('cc-pill--red') expect(html).not.toContain(t('conversations.checksFailed')) }) test('a failed run: a red pill with the x glyph', async () => { const state = taskState({ status: 'running' }, { checks: checks({ status: 'failed' }) }) const html = await render(state) - expect(html).toContain('cvr-pill--red') + expect(html).toContain('cc-pill--red') expect(html).toContain(t('conversations.checksFailed')) }) @@ -183,15 +183,15 @@ describe('reference pills: ticket and checks, both optional, independent', () => test('checks running: an amber dot, never the spinning glyph class', async () => { const state = taskState({ status: 'running' }, { checks: checks({ status: 'running' }) }) const html = await render(state) - expect(html).toContain('cvr-pill--amber') - expect(html).toContain('cvr-dot--pill') - expect(html).not.toContain('cvr-activity-glyph--spin cvr-dot--pill') + expect(html).toContain('cc-pill--amber') + expect(html).toContain('cc-dot') + expect(html).not.toContain('cvr-activity-glyph--spin cc-dot') }) test('a passed run: a green pill with the check glyph', async () => { const state = taskState({ status: 'running' }, { checks: checks({ status: 'passed' }) }) const html = await render(state) - expect(html).toContain('cvr-pill--green') + expect(html).toContain('cc-pill--green') expect(html).toContain(t('conversations.checksPassed')) }) diff --git a/packages/web/src/components/conversations/ConversationRow.vue b/packages/web/src/components/conversations/ConversationRow.vue index b642a25..363824e 100644 --- a/packages/web/src/components/conversations/ConversationRow.vue +++ b/packages/web/src/components/conversations/ConversationRow.vue @@ -12,7 +12,6 @@ // upstream (ConversationsLogic.ts, §7-8) so this component only renders what // it is handed. import { - AlertTriangle, Check, CircleAlert, Clock, @@ -28,12 +27,12 @@ import { queueSectionOf } from '../../composables/useTaskBoard' import type { TaskState } from '../../composables/useTasks' import { EXECUTION_STATUS } from '../../execution-status' import { t } from '../../i18n' +import ChecksChip from './ChecksChip.vue' import { formatConversationTimestamp, resolveActivityLine, resolveChecksPill, type ActivityGlyph, - type ReferencePillGlyph, } from './ConversationsLogic' const props = defineProps<{ @@ -67,12 +66,6 @@ const ACTIVITY_ICONS: Partial> = { clock: Clock, x: X, } - -const CHECKS_ICONS: Partial> = { - x: X, - 'alert-triangle': AlertTriangle, - check: Check, -} @@ -269,27 +253,4 @@ const CHECKS_ICONS: Partial> = { width: 10px; height: 10px; } - -.cvr-dot--pill { - width: 6px; - height: 6px; -} - -.cvr-pill--red { - color: var(--cs-red-text); - border-color: var(--cs-red-line); - background: var(--cs-red-soft); -} - -.cvr-pill--amber { - color: var(--cs-amber-text); - border-color: var(--cs-amber-line); - background: var(--cs-amber-soft); -} - -.cvr-pill--green { - color: var(--cs-green-text); - border-color: var(--cs-green-ring); - background: var(--cs-green-soft); -} diff --git a/packages/web/src/components/pilot/PilotLogic.test.ts b/packages/web/src/components/pilot/PilotLogic.test.ts new file mode 100644 index 0000000..186d85a --- /dev/null +++ b/packages/web/src/components/pilot/PilotLogic.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'bun:test' +import type { TaskState } from '../../composables/useTasks' +import type { TaskRecord, TaskStatus } from '../../types' +import { + clampCols, + closeLens, + mobilePane, + onEscape, + openLens, + orderCards, + type LensState, +} from './PilotLogic' + +function record(partial: Partial & { id: string }): TaskRecord { + return { + version: 1, + title: partial.id, + status: 'running', + base: 'main', + branch: `codesema/task-${partial.id}`, + worktree: `/wt/${partial.id}`, + agent_session_id: null, + turns: [], + review_ref: null, + work_ms: 0, + wait_ms: 0, + auto_ship: false, + created_at: '2026-08-14T09:00:00.000Z', + updated_at: '2026-08-14T09:00:00.000Z', + ...partial, + } +} + +function state(partial: Partial & { id: string }): TaskState { + return { + projectId: 'aaaa1111', + record: record(partial), + events: [], + liveText: '', + liveMessages: [], + liveTokens: 0, + liveLoadCap: null, + checks: null, + } +} + +describe('clampCols', () => { + test('a value already inside [1, 4] passes through unchanged', () => { + expect(clampCols(1)).toBe(1) + expect(clampCols(3)).toBe(3) + expect(clampCols(4)).toBe(4) + }) + + test('below the minimum is raised to 1', () => { + expect(clampCols(0)).toBe(1) + expect(clampCols(-5)).toBe(1) + }) + + test('above the maximum is lowered to 4', () => { + expect(clampCols(5)).toBe(4) + expect(clampCols(99)).toBe(4) + }) + + test('a non-integer number is rounded before clamping', () => { + expect(clampCols(2.4)).toBe(2) + expect(clampCols(2.6)).toBe(3) + }) + + test('the wrong type falls back to the default (2), not a clamp', () => { + expect(clampCols('3')).toBe(2) + expect(clampCols(null)).toBe(2) + expect(clampCols(undefined)).toBe(2) + expect(clampCols({})).toBe(2) + expect(clampCols(Number.NaN)).toBe(2) + }) +}) + +describe('lens (openLens / closeLens / onEscape)', () => { + test('opening from a closed lens sets the target', () => { + expect(openLens(null, 't1', 'evidence')).toEqual({ taskId: 't1', block: 'evidence' }) + }) + + test('opening a different target while one is open replaces it', () => { + const current: LensState = { taskId: 't1', block: 'evidence' } + expect(openLens(current, 't1', 'checks')).toEqual({ taskId: 't1', block: 'checks' }) + expect(openLens(current, 't2', 'evidence')).toEqual({ taskId: 't2', block: 'evidence' }) + }) + + test('opening the exact same target again toggles it closed', () => { + const current: LensState = { taskId: 't1', block: 'evidence' } + expect(openLens(current, 't1', 'evidence')).toBeNull() + }) + + test('closeLens always returns null, regardless of the current state', () => { + expect(closeLens()).toBeNull() + }) + + test('onEscape closes an open lens', () => { + const current: LensState = { taskId: 't1', block: 'recap' } + expect(onEscape(current)).toBeNull() + }) + + test('onEscape on an already-closed lens stays null', () => { + expect(onEscape(null)).toBeNull() + }) +}) + +describe('orderCards', () => { + test('statuses that require the human come before the rest', () => { + const running = state({ id: 't1', status: 'running', updated_at: '2026-08-20T10:00:00.000Z' }) + const waiting = state({ + id: 't2', + status: 'waiting_for_you', + updated_at: '2026-08-20T08:00:00.000Z', + }) + const shipped = state({ id: 't3', status: 'shipped', updated_at: '2026-08-20T12:00:00.000Z' }) + + const ordered = orderCards([running, shipped, waiting]) + + expect(ordered.map((s) => s.record.id)).toEqual(['t2', 't3', 't1']) + }) + + test('waiting_for_you is the only attention status; it sorts ahead of every other status', () => { + const reviewKo = state({ + id: 'a', + status: 'review_ko', + updated_at: '2026-08-20T09:00:00.000Z', + }) + const interrupted = state({ + id: 'b', + status: 'interrupted', + updated_at: '2026-08-20T07:00:00.000Z', + }) + const waiting = state({ + id: 'c', + status: 'waiting_for_you', + updated_at: '2026-08-20T11:00:00.000Z', + }) + const running = state({ id: 'd', status: 'running', updated_at: '2026-08-20T13:00:00.000Z' }) + const reviewOk = state({ + id: 'e', + status: 'review_ok', + updated_at: '2026-08-20T14:00:00.000Z', + }) + + const ordered = orderCards([running, reviewKo, reviewOk, interrupted, waiting]) + + expect(ordered.map((s) => s.record.id)).toEqual(['c', 'e', 'd', 'a', 'b']) + }) + + test('within each group, the most recently active card comes first', () => { + const older = state({ id: 't1', status: 'shipped', updated_at: '2026-08-20T08:00:00.000Z' }) + const newer = state({ id: 't2', status: 'shipped', updated_at: '2026-08-20T10:00:00.000Z' }) + + expect(orderCards([older, newer]).map((s) => s.record.id)).toEqual(['t2', 't1']) + }) + + test('an empty list stays empty', () => { + expect(orderCards([])).toEqual([]) + }) + + test('a mix of every status covers each TaskStatus without a hardcoded exception', () => { + const statuses: TaskStatus[] = [ + 'queued', + 'running', + 'waiting_for_you', + 'reviewing', + 'review_ok', + 'review_ko', + 'shipped', + 'failed', + 'interrupted', + ] + const states = statuses.map((status, i) => state({ id: `s${i}`, status })) + const ordered = orderCards(states) + expect(ordered).toHaveLength(states.length) + expect(new Set(ordered.map((s) => s.record.id))).toEqual( + new Set(states.map((s) => s.record.id)), + ) + }) +}) + +describe('mobilePane', () => { + test('no selection shows the list', () => { + expect(mobilePane(null)).toBe('list') + }) + + test('a selected task shows the thread', () => { + expect(mobilePane('t1')).toBe('thread') + }) +}) diff --git a/packages/web/src/components/pilot/PilotLogic.ts b/packages/web/src/components/pilot/PilotLogic.ts new file mode 100644 index 0000000..cd61d34 --- /dev/null +++ b/packages/web/src/components/pilot/PilotLogic.ts @@ -0,0 +1,73 @@ +// Pure state math of the pilot workspace: the column count, the lens +// overlay (which task/block a card's evidence lens shows), the card +// ordering (human-blocked first), and the mobile list/thread pane. Split +// out, same doctrine as ComposerLogic.ts / ForgeLogic.ts: PilotShell.vue +// only composes these functions. + +import { compareByActivity } from '../../composables/useTaskBoard' +import type { TaskState } from '../../composables/useTasks' +import { EXECUTION_STATUS } from '../../execution-status' + +export type PilotCols = 1 | 2 | 3 | 4 + +/** + * Tolerant coercion of a persisted column count: a wrong TYPE falls back to + * the default (2), same doctrine as `pickWidth` in useRailPrefs.ts: a + * right-typed value is clamped into [1, 4] rather than rejected. + */ +export function clampCols(raw: unknown): PilotCols { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return 2 + } + return Math.min(4, Math.max(1, Math.round(raw))) as PilotCols +} + +// ── Evidence lens (which card's block is currently expanded) ────────────── + +export type LensBlock = 'evidence' | 'recap' | 'checks' | 'criteria' | 'question' + +export type LensState = { taskId: string; block: LensBlock } | null + +/** + * Opens a card's block in the lens. Clicking the block that is already open + * for that same task toggles it closed instead of leaving it a no-op: the + * lens trigger doubles as its own close button. + */ +export function openLens(state: LensState, taskId: string, block: LensBlock): LensState { + if (state !== null && state.taskId === taskId && state.block === block) { + return null + } + return { taskId, block } +} + +export function closeLens(): LensState { + return null +} + +/** Escape closes an open lens; with none open there is nothing to unwind. */ +export function onEscape(state: LensState): LensState { + return state === null ? state : closeLens() +} + +// ── Card ordering (human-blocked first, then most recently active) ──────── + +function byActivity(a: TaskState, b: TaskState): number { + return compareByActivity(a.record, b.record) +} + +/** + * Cards that need the human first (per `EXECUTION_STATUS[status].attention`, + * never a hardcoded status list), then the rest, each group internally + * ordered by `compareByActivity` (most recently touched first). + */ +export function orderCards(states: readonly TaskState[]): TaskState[] { + const attention = states.filter((state) => EXECUTION_STATUS[state.record.status].attention) + const rest = states.filter((state) => !EXECUTION_STATUS[state.record.status].attention) + return [...attention.toSorted(byActivity), ...rest.toSorted(byActivity)] +} + +// ── Mobile shell (single-column: list or thread, never both) ────────────── + +export function mobilePane(selectedId: string | null): 'list' | 'thread' { + return selectedId === null ? 'list' : 'thread' +} diff --git a/packages/web/src/composables/usePilotPrefs.test.ts b/packages/web/src/composables/usePilotPrefs.test.ts new file mode 100644 index 0000000..a085cff --- /dev/null +++ b/packages/web/src/composables/usePilotPrefs.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test' +import { nextTick } from 'vue' +import { + DEFAULT_PILOT_PREFS, + parsePilotPrefs, + PILOT_PREFS_STORAGE_KEY, + readPilotPrefs, + serializePilotPrefs, + usePilotPrefs, + writePilotPrefs, + type PilotPrefs, +} from './usePilotPrefs' + +describe('parsePilotPrefs', () => { + test('null falls back to the defaults', () => { + expect(parsePilotPrefs(null)).toEqual(DEFAULT_PILOT_PREFS) + }) + + test('a non-object value falls back to the defaults', () => { + expect(parsePilotPrefs(undefined)).toEqual(DEFAULT_PILOT_PREFS) + expect(parsePilotPrefs(42)).toEqual(DEFAULT_PILOT_PREFS) + expect(parsePilotPrefs('pilot')).toEqual(DEFAULT_PILOT_PREFS) + }) + + test('an empty object falls back to the defaults for every field', () => { + expect(parsePilotPrefs({})).toEqual(DEFAULT_PILOT_PREFS) + }) + + test('a partial blob keeps its known fields and defaults the rest', () => { + expect(parsePilotPrefs({ shell: 'classic' })).toEqual({ + ...DEFAULT_PILOT_PREFS, + shell: 'classic', + }) + expect(parsePilotPrefs({ cols: 3 })).toEqual({ ...DEFAULT_PILOT_PREFS, cols: 3 }) + }) + + test('an out-of-range cols is clamped via clampCols, not rejected', () => { + expect(parsePilotPrefs({ cols: 0 }).cols).toBe(1) + expect(parsePilotPrefs({ cols: 99 }).cols).toBe(4) + }) + + test('a mistyped cols falls back to its default instead of a clamp', () => { + expect(parsePilotPrefs({ cols: '3' }).cols).toBe(DEFAULT_PILOT_PREFS.cols) + }) + + test('an unknown shell falls back to its default', () => { + expect(parsePilotPrefs({ shell: 'legacy' }).shell).toBe(DEFAULT_PILOT_PREFS.shell) + expect(parsePilotPrefs({ shell: 42 }).shell).toBe(DEFAULT_PILOT_PREFS.shell) + }) + + test('every known shell round-trips', () => { + for (const shell of ['pilot', 'classic'] as const) { + expect(parsePilotPrefs({ shell }).shell).toBe(shell) + } + }) + + test('round-trips through serializePilotPrefs + JSON.parse', () => { + const prefs: PilotPrefs = { cols: 3, shell: 'classic' } + expect(parsePilotPrefs(JSON.parse(serializePilotPrefs(prefs)))).toEqual(prefs) + }) +}) + +describe('readPilotPrefs / writePilotPrefs (localStorage wrappers)', () => { + test('reads and writes through a working localStorage, and survives a hostile or corrupted one', () => { + const store = new Map() + const stub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + try { + globals.localStorage = stub + expect(readPilotPrefs()).toEqual(DEFAULT_PILOT_PREFS) + + const prefs: PilotPrefs = { cols: 4, shell: 'classic' } + writePilotPrefs(prefs) + expect(store.get(PILOT_PREFS_STORAGE_KEY)).toBe(JSON.stringify(prefs)) + expect(readPilotPrefs()).toEqual(prefs) + + store.set(PILOT_PREFS_STORAGE_KEY, '{not json') + expect(readPilotPrefs()).toEqual(DEFAULT_PILOT_PREFS) + + globals.localStorage = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + } + expect(readPilotPrefs()).toEqual(DEFAULT_PILOT_PREFS) + expect(() => writePilotPrefs(prefs)).not.toThrow() + } finally { + globals.localStorage = previous + } + }) +}) + +describe('usePilotPrefs', () => { + test('mutating a field ref updates the whole blob and persists it', async () => { + const store = new Map() + const stub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + try { + globals.localStorage = stub + const { prefs, cols, shell } = usePilotPrefs() + + expect(prefs.value).toEqual(DEFAULT_PILOT_PREFS) + + cols.value = 3 + expect(prefs.value.cols).toBe(3) + expect(cols.value).toBe(3) + + shell.value = 'classic' + expect(prefs.value).toEqual({ cols: 3, shell: 'classic' }) + expect(shell.value).toBe('classic') + + // The persisting watcher is batched (Vue's default flush), so it only + // runs once the microtask queue drains. + await nextTick() + expect(store.get(PILOT_PREFS_STORAGE_KEY)).toBe(JSON.stringify({ cols: 3, shell: 'classic' })) + } finally { + globals.localStorage = previous + } + }) + + test('two calls each get their own independent store', async () => { + const store = new Map() + const stub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + } + const globals = globalThis as { localStorage?: unknown } + const previous = globals.localStorage + try { + globals.localStorage = stub + const first = usePilotPrefs() + first.cols.value = 4 + await nextTick() + const second = usePilotPrefs() + expect(second.cols.value).toBe(4) + second.cols.value = 1 + expect(first.cols.value).toBe(4) + } finally { + globals.localStorage = previous + } + }) +}) diff --git a/packages/web/src/composables/usePilotPrefs.ts b/packages/web/src/composables/usePilotPrefs.ts new file mode 100644 index 0000000..42bf864 --- /dev/null +++ b/packages/web/src/composables/usePilotPrefs.ts @@ -0,0 +1,109 @@ +// Persisted layout preferences of the pilot workspace: the card grid's +// column count and which shell (pilot vs. classic) the reader last picked. +// One JSON blob in localStorage, same doctrine as useRailPrefs.ts: a pure +// parse function tested on its own, tolerant of an absent, empty, partial or +// corrupted blob, plus a thin try/catch wrapper around the real localStorage +// for the impure edges. The `use*()` factory itself (reactive refs, one per +// field, persisted on every mutation) follows useForgePrefs.ts. + +import { computed, ref, watch, type Ref } from 'vue' +import { clampCols, type PilotCols } from '../components/pilot/PilotLogic' + +export type PilotShell = 'pilot' | 'classic' + +export type PilotPrefs = { + cols: PilotCols + shell: PilotShell +} + +export const DEFAULT_PILOT_PREFS: PilotPrefs = { + cols: 2, + shell: 'pilot', +} + +export const PILOT_PREFS_STORAGE_KEY = 'codesema.pilot.prefs' + +const SHELLS: readonly PilotShell[] = ['pilot', 'classic'] + +function isShell(value: unknown): value is PilotShell { + return typeof value === 'string' && (SHELLS as readonly string[]).includes(value) +} + +/** + * Tolerant parse of an already-JSON.parse'd value (the localStorage string + * itself is decoded by `readPilotPrefs`, below, same split as + * `parseChecksSetup`/`parseSettingsSnapshot`): any field missing, mistyped, + * or the whole value unreadable falls back to its own default rather than + * rejecting the whole blob. + */ +export function parsePilotPrefs(raw: unknown): PilotPrefs { + if (typeof raw !== 'object' || raw === null) { + return DEFAULT_PILOT_PREFS + } + const p = raw as Partial + return { + cols: clampCols(p.cols), + shell: isShell(p.shell) ? p.shell : DEFAULT_PILOT_PREFS.shell, + } +} + +export function serializePilotPrefs(prefs: PilotPrefs): string { + return JSON.stringify(prefs) +} + +// ── localStorage wrappers (best-effort: privacy modes / disabled storage can throw) ── + +export function readPilotPrefs(): PilotPrefs { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_PILOT_PREFS + } + const raw = localStorage.getItem(PILOT_PREFS_STORAGE_KEY) + if (raw === null) { + return DEFAULT_PILOT_PREFS + } + return parsePilotPrefs(JSON.parse(raw)) + } catch { + return DEFAULT_PILOT_PREFS + } +} + +export function writePilotPrefs(prefs: PilotPrefs): void { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(PILOT_PREFS_STORAGE_KEY, serializePilotPrefs(prefs)) + } + } catch { + // Best-effort, like every other persisted UI preference in this app. + } +} + +// ── Reactive store (one ref per field, whole blob persisted on mutation) ── + +export type PilotPrefsStore = { + /** The whole blob, for callers that want it as one value. */ + prefs: Ref + cols: Ref + shell: Ref +} + +export function usePilotPrefs(): PilotPrefsStore { + const prefs = ref(readPilotPrefs()) + + watch(prefs, (next) => writePilotPrefs(next), { deep: true }) + + /** One field of the blob as a read/write ref: every write replaces the + * whole blob, which is what the deep watcher above persists. */ + function field(key: K): Ref { + return computed({ + get: () => prefs.value[key], + set: (value: PilotPrefs[K]) => (prefs.value = { ...prefs.value, [key]: value }), + }) + } + + return { + prefs, + cols: field('cols'), + shell: field('shell'), + } +} diff --git a/packages/web/src/i18n.ts b/packages/web/src/i18n.ts index c92248c..c1b1585 100644 --- a/packages/web/src/i18n.ts +++ b/packages/web/src/i18n.ts @@ -891,6 +891,32 @@ const en = { 'time.monthOct': 'Oct', 'time.monthNov': 'Nov', 'time.monthDec': 'Dec', + + 'pilot.toggle.classic': 'Switch to classic view', + 'pilot.toggle.grid': 'Switch to grid view', + 'pilot.cols.aria': 'Choose the number of columns', + 'pilot.lens.close': 'Close', + 'pilot.lens.aria': 'Conversation lens', + 'pilot.evidence.title': 'Evidence', + 'pilot.evidence.none': 'No evidence yet.', + 'pilot.evidence.screenshotAlt': 'Screenshot of the run', + 'pilot.evidence.videoLabel': 'Video', + 'pilot.evidence.failed': 'The run failed. The reason is shown next to it.', + 'pilot.evidence.turn': 'turn {n}', + 'pilot.recap.title': 'Recap', + 'pilot.recap.pending': 'The recap arrives once this ships.', + 'pilot.recap.changes': 'Changes', + 'pilot.recap.decisions': 'Decisions', + 'pilot.recap.files': 'Files', + 'pilot.recap.tests': 'Tests', + 'pilot.checks.title': 'Checks', + 'pilot.criteria.title': 'Criteria', + 'pilot.criteria.none': 'No acceptance criteria yet.', + 'pilot.question.waiting': 'Waiting for your answer', + 'pilot.mobile.needsYou': 'Needs you', + 'pilot.mobile.back': '← Back', + 'pilot.mobile.title': 'Pilot', + 'pilot.grid.empty': 'No conversation here yet. Launch one to see it take shape in the grid.', } export type MessageKey = keyof typeof en @@ -1750,6 +1776,33 @@ const fr: Record = { 'time.monthOct': 'oct.', 'time.monthNov': 'nov.', 'time.monthDec': 'déc.', + + 'pilot.toggle.classic': "Passer à l'ancienne interface", + 'pilot.toggle.grid': 'Passer à la grille', + 'pilot.cols.aria': 'Choix du nombre de colonnes', + 'pilot.lens.close': 'Fermer', + 'pilot.lens.aria': 'Loupe de la conversation', + 'pilot.evidence.title': 'Preuves', + 'pilot.evidence.none': "Aucune preuve pour l'instant.", + 'pilot.evidence.screenshotAlt': "Capture d'écran du parcours", + 'pilot.evidence.videoLabel': 'Vidéo', + 'pilot.evidence.failed': 'Le parcours a échoué. La raison est affichée à côté.', + 'pilot.evidence.turn': 'tour {n}', + 'pilot.recap.title': 'Récapitulatif', + 'pilot.recap.pending': 'Le récapitulatif arrive à la publication.', + 'pilot.recap.changes': 'Changements', + 'pilot.recap.decisions': 'Décisions', + 'pilot.recap.files': 'Fichiers', + 'pilot.recap.tests': 'Tests', + 'pilot.checks.title': 'Checks', + 'pilot.criteria.title': 'Critères', + 'pilot.criteria.none': "Aucun critère d'acceptation pour l'instant.", + 'pilot.question.waiting': 'En attente de ta réponse', + 'pilot.mobile.needsYou': 'Besoin de toi', + 'pilot.mobile.back': '← Retour', + 'pilot.mobile.title': 'Pilote', + 'pilot.grid.empty': + 'Aucune conversation pour le moment. Lance-en une pour la voir prendre forme dans la grille.', } /** From b551961f0ebb591bab6f51addf5329d6a642c369 Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Mon, 31 Aug 2026 12:25:20 +0200 Subject: [PATCH 02/33] feat: replay the journey proof in the verify vm and store its evidence --- packages/cli/src/task-evidence.test.ts | 219 ++++++++++++++++++ packages/cli/src/task-evidence.ts | 179 ++++++++++++++ packages/cli/src/task-proof.test.ts | 177 ++++++++++++++ packages/cli/src/task-proof.ts | 97 ++++++++ packages/cli/src/task-runner.test.ts | 33 +++ packages/cli/src/task-runner.ts | 37 ++- packages/cli/src/task-verification.test.ts | 75 ++++++ packages/cli/src/task-verification.ts | 6 + packages/web/src/composables/useTasks.test.ts | 196 +++++++++++++++- packages/web/src/composables/useTasks.ts | 91 ++++++++ packages/web/src/types.ts | 27 +++ 11 files changed, 1130 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/task-evidence.test.ts create mode 100644 packages/cli/src/task-evidence.ts create mode 100644 packages/cli/src/task-proof.test.ts create mode 100644 packages/cli/src/task-proof.ts diff --git a/packages/cli/src/task-evidence.test.ts b/packages/cli/src/task-evidence.test.ts new file mode 100644 index 0000000..4d2a427 --- /dev/null +++ b/packages/cli/src/task-evidence.test.ts @@ -0,0 +1,219 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import type { EvidenceItem, EvidenceRecord } from './contract.js' +import { + EVIDENCE_MAX_BYTES, + evidenceDir, + ingestEvidenceFiles, + readTaskEvidence, + writeTaskEvidence, +} from './task-evidence.js' +import { taskDir } from './tasks-store.js' + +const cleanups: string[] = [] + +afterEach(() => { + for (const dir of cleanups.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'codesema-task-evidence-')) + cleanups.push(dir) + return dir +} + +const TASK_ID = 'abcdef123456' + +function baseRecord(overrides: Partial = {}): EvidenceRecord { + return { + version: 1, + status: 'passed', + reason: null, + head_sha: null, + items: [], + ...overrides, + } +} + +describe('readTaskEvidence / writeTaskEvidence', () => { + test('a task with no evidence.json reads as null', () => { + const repo = makeDir() + expect(readTaskEvidence(repo, TASK_ID)).toBeNull() + }) + + test('write then read round-trips the sanitized record', () => { + const repo = makeDir() + const item: EvidenceItem = { + kind: 'screenshot', + path: 'shot.png', + bytes: 12, + turn: 1, + created_at: '2026-08-01T00:00:00.000Z', + } + const written = writeTaskEvidence(repo, TASK_ID, baseRecord({ items: [item] })) + expect(written).toEqual(baseRecord({ items: [item] })) + expect(readTaskEvidence(repo, TASK_ID)).toEqual(baseRecord({ items: [item] })) + }) + + test('an unknown task id reads as null and refuses to write', () => { + const repo = makeDir() + expect(readTaskEvidence(repo, 'not-a-task-id')).toBeNull() + expect(() => writeTaskEvidence(repo, 'not-a-task-id', baseRecord())).toThrow() + }) + + test('a malformed file on disk reads back as null rather than throwing', () => { + const repo = makeDir() + mkdirSync(taskDir(repo, TASK_ID), { recursive: true }) + writeFileSync(join(taskDir(repo, TASK_ID), 'evidence.json'), 'not json') + expect(readTaskEvidence(repo, TASK_ID)).toBeNull() + }) +}) + +describe('ingestEvidenceFiles', () => { + function makeIncoming(): string { + const dir = mkdtempSync(join(tmpdir(), 'codesema-evidence-incoming-')) + return dir + } + + test('png and webm are ingested, including from a subfolder, other extensions are ignored', () => { + const repo = makeDir() + const incoming = makeIncoming() + writeFileSync(join(incoming, 'shot.png'), 'png-bytes') + writeFileSync(join(incoming, 'notes.txt'), 'not evidence') + mkdirSync(join(incoming, 'videos'), { recursive: true }) + writeFileSync(join(incoming, 'videos', 'clip.webm'), 'webm-bytes') + + const record = ingestEvidenceFiles(repo, TASK_ID, incoming, { + turn: 3, + status: 'passed', + reason: null, + head_sha: 'deadbeef', + keep: null, + }) + + expect(record.status).toBe('passed') + expect(record.head_sha).toBe('deadbeef') + expect(record.items).toHaveLength(2) + const kinds = record.items.map((item) => item.kind).toSorted() + expect(kinds).toEqual(['screenshot', 'video']) + for (const item of record.items) { + expect(item.turn).toBe(3) + expect(existsSync(join(evidenceDir(repo, TASK_ID), item.path))).toBe(true) + } + }) + + test('a file over the size limit is ignored', () => { + const repo = makeDir() + const incoming = makeIncoming() + writeFileSync(join(incoming, 'huge.png'), Buffer.alloc(EVIDENCE_MAX_BYTES + 1)) + writeFileSync(join(incoming, 'small.png'), 'ok') + + const record = ingestEvidenceFiles(repo, TASK_ID, incoming, { + turn: 1, + status: 'passed', + reason: null, + head_sha: null, + keep: null, + }) + + expect(record.items).toHaveLength(1) + expect(record.items[0]?.path.endsWith('.png')).toBe(true) + }) + + test('empty incoming yields zero items while status and reason are still applied', () => { + const repo = makeDir() + const incoming = makeIncoming() + + const record = ingestEvidenceFiles(repo, TASK_ID, incoming, { + turn: 1, + status: 'failed', + reason: 'the checkout journey timed out', + head_sha: null, + keep: null, + }) + + expect(record.items).toEqual([]) + expect(record.status).toBe('failed') + expect(record.reason).toBe('the checkout journey timed out') + }) + + test('incomingDir is removed once ingestion completes', () => { + const repo = makeDir() + const incoming = makeIncoming() + writeFileSync(join(incoming, 'shot.png'), 'png-bytes') + + ingestEvidenceFiles(repo, TASK_ID, incoming, { + turn: 1, + status: 'passed', + reason: null, + head_sha: null, + keep: null, + }) + + expect(existsSync(incoming)).toBe(false) + }) + + test('keep-N purges the oldest items and drops them from disk and from the record', () => { + const repo = makeDir() + const dir = evidenceDir(repo, TASK_ID) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'old-0.png'), 'a') + writeFileSync(join(dir, 'old-1.png'), 'b') + writeFileSync(join(dir, 'old-2.png'), 'c') + writeTaskEvidence( + repo, + TASK_ID, + baseRecord({ + items: [ + { + kind: 'screenshot', + path: 'old-0.png', + bytes: 1, + turn: 1, + created_at: '2020-01-01T00:00:00.000Z', + }, + { + kind: 'screenshot', + path: 'old-1.png', + bytes: 1, + turn: 1, + created_at: '2020-01-01T00:00:01.000Z', + }, + { + kind: 'screenshot', + path: 'old-2.png', + bytes: 1, + turn: 1, + created_at: '2020-01-01T00:00:02.000Z', + }, + ], + }), + ) + + const incoming = makeIncoming() + writeFileSync(join(incoming, 'new-0.png'), 'x') + writeFileSync(join(incoming, 'new-1.png'), 'y') + writeFileSync(join(incoming, 'new-2.webm'), 'z') + + const record = ingestEvidenceFiles(repo, TASK_ID, incoming, { + turn: 2, + status: 'passed', + reason: null, + head_sha: null, + keep: 4, + }) + + expect(record.items).toHaveLength(4) + const paths = record.items.map((item) => item.path) + expect(paths).toContain('old-2.png') + expect(paths).not.toContain('old-0.png') + expect(paths).not.toContain('old-1.png') + expect(existsSync(join(dir, 'old-0.png'))).toBe(false) + expect(existsSync(join(dir, 'old-1.png'))).toBe(false) + expect(existsSync(join(dir, 'old-2.png'))).toBe(true) + }) +}) diff --git a/packages/cli/src/task-evidence.ts b/packages/cli/src/task-evidence.ts new file mode 100644 index 0000000..07931d2 --- /dev/null +++ b/packages/cli/src/task-evidence.ts @@ -0,0 +1,179 @@ +import { + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + type Dirent, +} from 'node:fs' +import { extname, join, relative } from 'node:path' +import { writeJsonAtomic } from './atomic-write.js' +import { + isTaskId, + sanitizeEvidence, + type EvidenceItem, + type EvidenceKind, + type EvidenceRecord, + type EvidenceStatus, +} from './contract.js' +import { taskDir } from './tasks-store.js' + +export const EVIDENCE_MAX_BYTES = 64 * 1024 * 1024 +const EVIDENCE_DEFAULT_KEEP = 5 + +const EVIDENCE_EXTENSION_KIND: Readonly> = { + '.png': 'screenshot', + '.webm': 'video', +} + +export type IngestEvidenceMeta = { + turn: number + status: EvidenceStatus + reason: string | null + head_sha: string | null + keep: number | null +} + +export function evidenceDir(cwd: string, id: string): string { + return join(taskDir(cwd, id), 'evidence') +} + +/** + * Latest evidence record of a task (.codesema/tasks//evidence.json), + * calque of readTaskChecks/readTaskVerification. Null on unknown id, + * unreadable file or unusable content, never a throw. + */ +export function readTaskEvidence(cwd: string, id: string): EvidenceRecord | null { + if (!isTaskId(id)) { + return null + } + const path = join(taskDir(cwd, id), 'evidence.json') + let raw: unknown + try { + raw = JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } + return sanitizeEvidence(raw) +} + +/** + * Atomic rewrite of evidence.json, calque of writeTaskChecks/writeTaskVerification. + * Sanitized before writing so the file on disk is always bounded; the + * sanitized copy is returned so the caller broadcasts exactly what was + * persisted. + */ +export function writeTaskEvidence(cwd: string, id: string, record: EvidenceRecord): EvidenceRecord { + if (!isTaskId(id)) { + throw new Error(`invalid task id: ${id}`) + } + const clean = sanitizeEvidence(record) + if (!clean) { + throw new Error('invalid task evidence') + } + writeJsonAtomic(join(taskDir(cwd, id), 'evidence.json'), clean) + return clean +} + +function collectIncomingFiles(dir: string, base = dir): string[] { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } + const files: string[] = [] + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...collectIncomingFiles(full, base)) + } else if (entry.isFile()) { + files.push(full) + } + } + return files.toSorted((a, b) => relative(base, a).localeCompare(relative(base, b))) +} + +/** + * Ingests every .png/.webm found recursively under incomingDir (Playwright + * writes videos into a subfolder) into /evidence, merges the result + * with the previous record's surviving items, purges down to meta.keep + * (default 5, most recent created_at first), and persists the merged record. + * incomingDir is removed once ingestion completes, whether or not it held + * any usable file. + */ +export function ingestEvidenceFiles( + cwd: string, + id: string, + incomingDir: string, + meta: IngestEvidenceMeta, +): EvidenceRecord { + if (!isTaskId(id)) { + throw new Error(`invalid task id: ${id}`) + } + const targetDir = evidenceDir(cwd, id) + mkdirSync(targetDir, { recursive: true }) + + const candidates = collectIncomingFiles(incomingDir) + const epochMs = Date.now() + const createdAt = new Date(epochMs).toISOString() + const newItems: EvidenceItem[] = [] + let index = 0 + for (const file of candidates) { + const ext = extname(file).toLowerCase() + const kind = EVIDENCE_EXTENSION_KIND[ext] + if (!kind) { + continue + } + if (statSync(file).size > EVIDENCE_MAX_BYTES) { + continue + } + const filename = `t${meta.turn}-${epochMs}-${index}${ext}` + const target = join(targetDir, filename) + renameSync(file, target) + newItems.push({ + kind, + path: filename, + bytes: statSync(target).size, + turn: meta.turn, + created_at: createdAt, + }) + index += 1 + } + + const previousItems = readTaskEvidence(cwd, id)?.items ?? [] + const combined = [...previousItems, ...newItems] + const keepN = meta.keep ?? EVIDENCE_DEFAULT_KEEP + const sorted = combined.toSorted((a, b) => b.created_at.localeCompare(a.created_at)) + const toKeep = sorted.slice(0, keepN) + const toDrop = sorted.slice(keepN) + + for (const item of toDrop) { + try { + unlinkSync(join(targetDir, item.path)) + } catch { + continue + } + } + + const survivors = toKeep.filter((item) => { + try { + statSync(join(targetDir, item.path)) + return true + } catch { + return false + } + }) + + rmSync(incomingDir, { recursive: true, force: true }) + + return writeTaskEvidence(cwd, id, { + version: 1, + status: meta.status, + reason: meta.reason, + head_sha: meta.head_sha, + items: survivors, + }) +} diff --git a/packages/cli/src/task-proof.test.ts b/packages/cli/src/task-proof.test.ts new file mode 100644 index 0000000..303684d --- /dev/null +++ b/packages/cli/src/task-proof.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import type { + SandboxExecOptions, + SandboxExecResult, + SandboxHandle, + SandboxMetrics, +} from './microsandbox-driver.js' +import { captureProof, type CaptureProofOptions } from './task-proof.js' + +type Call = { method: string; args: unknown[] } + +const ok = (over: Partial = {}): SandboxExecResult => ({ + code: 0, + stdout: '', + stderr: '', + timedOut: false, + ...over, +}) + +function fakeHandle(opts: { + script?: (command: string, execOpts: SandboxExecOptions) => SandboxExecResult + copyToHostFails?: boolean +}): { handle: SandboxHandle; calls: Call[] } { + const calls: Call[] = [] + const script = opts.script ?? (() => ok()) + const handle: SandboxHandle = { + name: 'fake', + exec: (command, args, execOpts) => { + calls.push({ method: 'exec', args: [command, args] }) + return Promise.resolve(script(command, execOpts)) + }, + shell: (command, execOpts) => { + calls.push({ method: 'shell', args: [command] }) + return Promise.resolve(script(command, execOpts)) + }, + copyFromHost: () => Promise.resolve(), + copyToHost: (guestPath, hostPath) => { + calls.push({ method: 'copyToHost', args: [guestPath, hostPath] }) + if (opts.copyToHostFails) { + return Promise.reject(new Error('copy failed')) + } + return Promise.resolve() + }, + writeFile: () => Promise.resolve(), + readFile: () => Promise.resolve(''), + metrics: (): Promise => + Promise.resolve({ memoryHostResidentBytes: null, memoryBytes: null, cpuPercent: null }), + stop: () => Promise.resolve(), + } + return { handle, calls } +} + +function baseOpts(overrides: Partial = {}): CaptureProofOptions { + return { + journey: 'journeys/login.spec.ts', + url: 'http://localhost:3000', + timeoutMs: 5000, + guestWorkDir: '/work', + guestProofDir: '/work/.proof', + hostIncomingDir: '/host/incoming', + ...overrides, + } +} + +describe('captureProof', () => { + test('a passing replay copies the proof dir, in mkdir -> replay -> copyToHost order', async () => { + const { handle, calls } = fakeHandle({}) + const result = await captureProof(handle, baseOpts()) + expect(result).toEqual({ status: 'passed', reason: null }) + expect(calls.map((c) => c.method)).toEqual(['shell', 'shell', 'copyToHost']) + expect(calls[0]?.args[0]).toBe('mkdir -p /work/.proof') + const replay = calls[1]?.args[0] as string + expect(replay).toContain("CODESEMA_BASE_URL='http://localhost:3000'") + expect(replay).toContain('CODESEMA_PROOF_DIR=/work/.proof') + expect(replay).toContain("npx playwright test 'journeys/login.spec.ts'") + expect(replay).toContain('--output=/work/.proof') + expect(calls[2]).toEqual({ + method: 'copyToHost', + args: ['/work/.proof', '/host/incoming'], + }) + }) + + test('a failing replay attempts a fallback screenshot and reports the tail as reason', async () => { + const { handle, calls } = fakeHandle({ + script: (command) => + command.includes('npx playwright test') + ? ok({ code: 1, stderr: 'assertion failed' }) + : ok(), + }) + const result = await captureProof(handle, baseOpts()) + expect(result.status).toBe('failed') + expect(result.reason).toContain('assertion failed') + const shellCommands = calls.filter((c) => c.method === 'shell').map((c) => c.args[0] as string) + expect(shellCommands[2]).toContain('npx playwright screenshot --full-page') + expect(shellCommands[2]).toContain("'http://localhost:3000'") + expect(shellCommands[2]).toContain('/work/.proof/fallback.png') + expect(calls.some((c) => c.method === 'copyToHost')).toBe(true) + }) + + test('a timed out replay reports an explicit timeout reason and still attempts the fallback', async () => { + const { handle, calls } = fakeHandle({ + script: (command) => + command.includes('npx playwright test') ? ok({ timedOut: true, code: null }) : ok(), + }) + const result = await captureProof(handle, baseOpts({ timeoutMs: 1234 })) + expect(result.status).toBe('failed') + expect(result.reason).toContain('timed out') + expect(result.reason).toContain('1234') + const shellCommands = calls.filter((c) => c.method === 'shell').map((c) => c.args[0] as string) + expect(shellCommands[2]).toContain('npx playwright screenshot') + }) + + test('an apostrophe in the url is refused without ever running the replay shell', async () => { + const { handle, calls } = fakeHandle({}) + const result = await captureProof(handle, baseOpts({ url: "http://localhost:3000/it's" })) + expect(result.status).toBe('failed') + expect(result.reason).toContain('url') + expect(result.reason).toContain('single quote') + const shellCommands = calls.filter((c) => c.method === 'shell').map((c) => c.args[0] as string) + expect(shellCommands).toEqual(['mkdir -p /work/.proof']) + expect(calls.some((c) => c.method === 'copyToHost')).toBe(true) + }) + + test('an apostrophe in the journey is refused the same way', async () => { + const { handle, calls } = fakeHandle({}) + const result = await captureProof(handle, baseOpts({ journey: "journeys/it's-broken.spec.ts" })) + expect(result.status).toBe('failed') + expect(result.reason).toContain('journey') + const shellCommands = calls.filter((c) => c.method === 'shell').map((c) => c.args[0] as string) + expect(shellCommands).toEqual(['mkdir -p /work/.proof']) + }) + + test('a copy failure after a passing replay degrades the verdict to failed', async () => { + const { handle } = fakeHandle({ copyToHostFails: true }) + const result = await captureProof(handle, baseOpts()) + expect(result.status).toBe('failed') + expect(result.reason).toContain('copy') + }) + + test('a copy failure after an already-failing replay keeps the original failure reason', async () => { + const { handle } = fakeHandle({ + script: (command) => + command.includes('npx playwright test') ? ok({ code: 1, stderr: 'boom' }) : ok(), + copyToHostFails: true, + }) + const result = await captureProof(handle, baseOpts()) + expect(result.status).toBe('failed') + expect(result.reason).toContain('boom') + }) + + test('the reason tail is bounded to 2000 characters', async () => { + const { handle } = fakeHandle({ + script: (command) => + command.includes('npx playwright test') ? ok({ code: 1, stderr: 'x'.repeat(3000) }) : ok(), + }) + const result = await captureProof(handle, baseOpts()) + expect(result.reason).toHaveLength(2000) + }) + + test('no exception ever escapes captureProof, even when the sandbox rejects', async () => { + const handle: SandboxHandle = { + name: 'broken', + exec: () => Promise.reject(new Error('exec unavailable')), + shell: () => Promise.reject(new Error('sandbox is gone')), + copyFromHost: () => Promise.resolve(), + copyToHost: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + readFile: () => Promise.resolve(''), + metrics: (): Promise => + Promise.resolve({ memoryHostResidentBytes: null, memoryBytes: null, cpuPercent: null }), + stop: () => Promise.resolve(), + } + const result = await captureProof(handle, baseOpts()) + expect(result.status).toBe('failed') + expect(result.reason).toContain('sandbox is gone') + }) +}) diff --git a/packages/cli/src/task-proof.ts b/packages/cli/src/task-proof.ts new file mode 100644 index 0000000..26eff1c --- /dev/null +++ b/packages/cli/src/task-proof.ts @@ -0,0 +1,97 @@ +import type { SandboxExecOptions, SandboxHandle } from './microsandbox-driver.js' + +export type ProofCaptureResult = { + status: 'passed' | 'failed' + reason: string | null +} + +export type CaptureProofOptions = { + journey: string + url: string + timeoutMs: number + guestWorkDir: string + guestProofDir: string + hostIncomingDir: string +} + +const PROOF_REASON_TAIL_MAX = 2_000 + +function shellQuote(value: string): string | null { + if (value.includes("'")) { + return null + } + return `'${value}'` +} + +function tail(text: string): string { + return text.slice(-PROOF_REASON_TAIL_MAX) +} + +async function runReplay( + handle: SandboxHandle, + opts: CaptureProofOptions, + quotedJourney: string, + quotedUrl: string, +): Promise { + const execOpts: SandboxExecOptions = { timeoutMs: opts.timeoutMs, cwd: opts.guestWorkDir } + const replayCommand = `CODESEMA_BASE_URL=${quotedUrl} CODESEMA_PROOF_DIR=${opts.guestProofDir} npx playwright test ${quotedJourney} --output=${opts.guestProofDir}` + const replay = await handle.shell(replayCommand, execOpts) + if (!replay.timedOut && replay.code === 0) { + return { status: 'passed', reason: null } + } + const reason = replay.timedOut + ? `replay timed out after ${opts.timeoutMs}ms` + : tail(replay.stdout + replay.stderr) + await handle + .shell( + `npx playwright screenshot --full-page ${quotedUrl} ${opts.guestProofDir}/fallback.png`, + execOpts, + ) + .catch(() => undefined) + return { status: 'failed', reason } +} + +async function captureProofUnsafe( + handle: SandboxHandle, + opts: CaptureProofOptions, +): Promise { + await handle.shell(`mkdir -p ${opts.guestProofDir}`, { timeoutMs: opts.timeoutMs }) + + const quotedJourney = shellQuote(opts.journey) + const quotedUrl = shellQuote(opts.url) + + let result: ProofCaptureResult + if (quotedJourney === null || quotedUrl === null) { + const badField = quotedJourney === null ? 'journey' : 'url' + result = { + status: 'failed', + reason: `refusing to run the replay: ${badField} contains a single quote, which cannot be safely quoted for the shell`, + } + } else { + result = await runReplay(handle, opts, quotedJourney, quotedUrl) + } + + try { + await handle.copyToHost(opts.guestProofDir, opts.hostIncomingDir) + } catch { + if (result.status === 'passed') { + return { + status: 'failed', + reason: 'proof capture passed but copying the evidence to the host failed', + } + } + } + + return result +} + +export async function captureProof( + handle: SandboxHandle, + opts: CaptureProofOptions, +): Promise { + try { + return await captureProofUnsafe(handle, opts) + } catch (err) { + return { status: 'failed', reason: err instanceof Error ? err.message : String(err) } + } +} diff --git a/packages/cli/src/task-runner.test.ts b/packages/cli/src/task-runner.test.ts index 6697703..14ceb77 100644 --- a/packages/cli/src/task-runner.test.ts +++ b/packages/cli/src/task-runner.test.ts @@ -34,6 +34,7 @@ import { createLoadCap, type LoadCap } from './load-cap.js' import type { SandboxDriver } from './microsandbox-driver.js' import type { RunMicrovmTurnOptions } from './microvm-turn.js' import { projectIdFor } from './projects.js' +import type { ProofConfig } from './repo-config.js' import { CAGE_FORWARDED_ENV, type RunContainerTurnOptions } from './task-isolation.js' import { activeTask, @@ -177,6 +178,38 @@ describe('buildTaskPrompt / parseTaskQuestion', () => { }) }) +function sampleProof(): ProofConfig { + return { + journey: 'tests/e2e/main-flow.spec.ts', + url: 'http://localhost:3000', + timeoutSeconds: 30, + keep: 5, + } +} + +describe('buildTaskPrompt with a proof config', () => { + test('microvm isolation with a proof config adds the journey bullet', () => { + const task = { title: 'Add rate limiting', isolation: 'microvm' } as TaskRecord + const prompt = buildTaskPrompt(task, { proof: sampleProof() }) + expect(prompt).toContain('tests/e2e/main-flow.spec.ts') + expect(prompt).toContain('CODESEMA_BASE_URL') + expect(prompt).toContain('Playwright') + }) + + test('non-microvm isolation never adds the bullet, even with a proof config', () => { + const containerTask = { title: 'Add rate limiting', isolation: 'container' } as TaskRecord + expect(buildTaskPrompt(containerTask, { proof: sampleProof() })).not.toContain('Playwright') + const policyTask = { title: 'Add rate limiting', isolation: 'policy' } as TaskRecord + expect(buildTaskPrompt(policyTask, { proof: sampleProof() })).not.toContain('Playwright') + }) + + test('microvm isolation without a proof config adds nothing', () => { + const task = { title: 'Add rate limiting', isolation: 'microvm' } as TaskRecord + expect(buildTaskPrompt(task)).not.toContain('Playwright') + expect(buildTaskPrompt(task, { proof: null })).not.toContain('Playwright') + }) +}) + describe('parseTaskBranchProposal', () => { test('the first line names the branch and leaves the reply', () => { expect(parseTaskBranchProposal('BRANCH: fix-preview-rename\n\nDid the thing.')).toEqual({ diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index e537757..53f87ef 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -65,7 +65,7 @@ import { import type { SandboxDriver, SandboxSecret } from './microsandbox-driver.js' import { runMicrovmTurn, type RunMicrovmTurnOptions } from './microvm-turn.js' import { projectIdFor } from './projects.js' -import type { ChecksConfig } from './repo-config.js' +import { readProofConfig, type ChecksConfig, type ProofConfig } from './repo-config.js' import { RUNNER_FALLBACK_GIT_IDENTITY } from './runner-secrets.js' import { bootstrapWorktreeInstall, @@ -249,8 +249,15 @@ function criteriaDraftInstruction(): string { * `askBranchName` adds the one-line BRANCH protocol, asked on the FIRST turn * of a forked task only (see parseTaskBranchProposal): once the branch has the * agent's name, re-asking would only invite a rename mid-conversation. + * + * `proof` adds the Playwright journey bullet, only when the task's isolation + * is 'microvm': the standing rules never ask a caged or policy-isolated turn + * for a browser test it has no way to run. */ -export function buildTaskPrompt(task: TaskRecord, opts: { askBranchName?: boolean } = {}): string { +export function buildTaskPrompt( + task: TaskRecord, + opts: { askBranchName?: boolean; proof?: ProofConfig | null } = {}, +): string { const lines = [ 'You are an autonomous coding agent working on a task in a dedicated git worktree of this repository (your current directory).', '', @@ -270,6 +277,11 @@ export function buildTaskPrompt(task: TaskRecord, opts: { askBranchName?: boolea : []), '- Follow the existing code style and conventions of the repository.', '- If the repo has cheap checks (typecheck, unit tests, lint), run them and fix what YOUR changes broke before finishing.', + ...(task.isolation === 'microvm' && opts.proof + ? [ + `- This repository keeps a Playwright end-to-end journey at ${opts.proof.journey}, covering the main user flow this task touches: create it if it does not exist yet, and update it if this task changes the interface it exercises. It reads the base URL from the CODESEMA_BASE_URL environment variable, the Playwright config enables video 'on', and the test ends with a full-page screenshot. Commit the file with the rest of your changes, and make sure the test passes locally before you finish this turn.`, + ] + : []), `- Language: ${taskLanguageRule()}. This covers your summary and any 'QUESTION: ' line; code identifiers, file paths and commit messages stay as they are.`, "- If you cannot proceed without a human decision, end your reply with a single final line of the exact form 'QUESTION: ' (nothing after that line). Ask only when truly blocked.", '- Otherwise end your reply with a short plain-text summary of what you did and how you verified it (no code fences).', @@ -1442,16 +1454,23 @@ function attachedRepositoriesNote(record: TaskRecord): string { * First turn: standing instructions + the task prompt. Later turns: claude * resumes its session so the reply alone is enough; other providers get a * one-shot run with the transcript replayed. + * + * `repoRoot` is the main repo, not the task's own worktree: the proof config + * lives in that repo's .codesema/config.json (repo-config.ts), the same file + * every other project-config read in this file re-reads from, never from the + * per-task checkout. Re-read on every call rather than cached on the record, + * so a config edited mid-task takes effect on the very next turn. */ -function composeTurnPrompt(record: TaskRecord, command: string): string { +function composeTurnPrompt(record: TaskRecord, command: string, repoRoot: string): string { const message = record.turns.at(-1)?.prompt ?? '' const repositories = attachedRepositoriesNote(record) const withRepositories = (text: string): string => repositories ? `${repositories}\n\n${text}` : text + const proof = record.isolation === 'microvm' ? readProofConfig(repoRoot) : null if (record.turns.length <= 1) { // A work-on conversation is not asked to name anything: it works on the // user's own pre-existing branch, which is never renamed. - const standing = buildTaskPrompt(record, { askBranchName: !record.work_on }) + const standing = buildTaskPrompt(record, { askBranchName: !record.work_on, proof }) const draft = taskCriteria(record).length === 0 ? `\n\n${criteriaDraftInstruction()}` : '' return withRepositories(`${standing}${draft}\n\n${message}`) } @@ -1459,7 +1478,13 @@ function composeTurnPrompt(record: TaskRecord, command: string): string { return withRepositories(message) } return withRepositories( - [buildTaskPrompt(record), '', transcript(record), '', `New instruction: ${message}`].join('\n'), + [ + buildTaskPrompt(record, { proof }), + '', + transcript(record), + '', + `New instruction: ${message}`, + ].join('\n'), ) } @@ -2529,7 +2554,7 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { return runTaskTurn({ cwd: record.worktree, task: record, - prompt: composeTurnPrompt(record, taskCommand), + prompt: composeTurnPrompt(record, taskCommand, opts.cwd), command: opts.command, timeoutMs: opts.timeoutMs, ...(opts.watchdog ? { watchdog: opts.watchdog } : {}), diff --git a/packages/cli/src/task-verification.test.ts b/packages/cli/src/task-verification.test.ts index fe9108c..19a1e10 100644 --- a/packages/cli/src/task-verification.test.ts +++ b/packages/cli/src/task-verification.test.ts @@ -478,6 +478,81 @@ describe('verifyTask', () => { expect(destroy?.args[0]).toBe('codesema-verify-t1') }) + test('captureProof runs after healthchecks pass and before runbook.tests', async () => { + const repo = makeRepo() + const sha = commitSha(repo) + const { driver, calls } = fakeDriver(() => ok()) + const result = await verifyTask({ + driver, + worktree: repo, + projectId: 'p1', + taskId: 't1', + headSha: 'headsha1', + runbook: baseRunbook({ healthchecks: ['curl -f http://localhost:3000'] }), + runbookSha: '0123456789abcdef', + validatedSha: sha, + snapshotName: 'codesema-p1-hash', + timeoutMs: 5000, + captureProof: async (handle) => { + await handle.shell('proof-marker', { timeoutMs: 1000, cwd: '/work' }) + }, + }) + expect(result.status).toBe('passed') + const shellCommands = calls.filter((c) => c.method === 'shell').map((c) => c.args[0]) + expect(shellCommands).toEqual(['curl -f http://localhost:3000', 'proof-marker', 'npm test']) + }) + + test('captureProof is not called when healthchecks never pass', async () => { + const repo = makeRepo() + const sha = commitSha(repo) + const { driver } = fakeDriver((command) => + command === 'curl -f http://localhost:3000' ? ok({ code: 1, stderr: 'not up' }) : ok(), + ) + let called = false + const result = await verifyTask({ + driver, + worktree: repo, + projectId: 'p1', + taskId: 't1', + headSha: 'headsha1', + runbook: baseRunbook({ healthchecks: ['curl -f http://localhost:3000'] }), + runbookSha: '0123456789abcdef', + validatedSha: sha, + snapshotName: 'codesema-p1-hash', + timeoutMs: 5000, + healthcheckDeadlineMs: 10, + healthcheckRetryDelayMs: 1, + captureProof: async () => { + called = true + }, + }) + expect(result.status).toBe('error') + expect(called).toBe(false) + }) + + test('an exception from captureProof is swallowed and never changes the verdict', async () => { + const repo = makeRepo() + const sha = commitSha(repo) + const { driver } = fakeDriver(() => ok()) + const result = await verifyTask({ + driver, + worktree: repo, + projectId: 'p1', + taskId: 't1', + headSha: 'headsha1', + runbook: baseRunbook(), + runbookSha: '0123456789abcdef', + validatedSha: sha, + snapshotName: 'codesema-p1-hash', + timeoutMs: 5000, + captureProof: async () => { + throw new Error('proof capture boom') + }, + }) + expect(result.status).toBe('passed') + expect(result.checks).toHaveLength(1) + }) + test('carries head_sha and runbook_sha through, on every status', async () => { const repo = makeRepo() const sha = commitSha(repo) diff --git a/packages/cli/src/task-verification.ts b/packages/cli/src/task-verification.ts index 1165ffc..8bdc225 100644 --- a/packages/cli/src/task-verification.ts +++ b/packages/cli/src/task-verification.ts @@ -45,6 +45,8 @@ export type VerifyTaskOptions = { healthcheckDeadlineMs?: number /** Delay between healthcheck retries; default 1s. */ healthcheckRetryDelayMs?: number + /** Best-effort browser proof capture, run after healthchecks pass and before `runbook.tests`; never affects the verdict. */ + captureProof?: (handle: SandboxHandle) => Promise } const VERIFY_WORK_DIR = '/work' @@ -209,6 +211,10 @@ export async function verifyTask(opts: VerifyTaskOptions): Promise undefined) + } + const checks: TaskCheckResult[] = [] for (const command of opts.runbook.tests) { const testStartedAt = Date.now() diff --git a/packages/web/src/composables/useTasks.test.ts b/packages/web/src/composables/useTasks.test.ts index 5046087..f6f4f96 100644 --- a/packages/web/src/composables/useTasks.test.ts +++ b/packages/web/src/composables/useTasks.test.ts @@ -1,8 +1,16 @@ import { describe, expect, test } from 'bun:test' import { ref } from 'vue' -import type { ForgeMr, GitWorktree, TaskPlan, TaskRecord } from '../types' +import type { + EvidenceRecord, + ForgeMr, + GitWorktree, + RecapRecord, + TaskPlan, + TaskRecord, +} from '../types' import { applyTaskMetaFrame, + evidenceFileUrl, taskKey, taskStreamHandlers, upsertRecord, @@ -208,6 +216,192 @@ describe('taskStreamHandlers (the stream wiring itself)', () => { ) expect(state.checks).toBeNull() }) + + test('a task_recap frame stores the recap on the task state', () => { + const { store, handlers } = seeded() + const state = store.get(taskKey('p1', 'x'))! + const recap: RecapRecord = { + version: 1, + summary: 'did the thing', + changes: [], + decisions: [], + files: [], + tests: [], + branch: 'codesema/task-x', + } + handlers.task_recap?.( + frame({ project_id: 'p1', task_id: 'x', event: { name: 'task_recap', data: recap } }), + ) + expect(state.recap).toEqual(recap) + }) + + test('a task_evidence frame stores the evidence on the task state', () => { + const { store, handlers } = seeded() + const state = store.get(taskKey('p1', 'x'))! + const evidence: EvidenceRecord = { + version: 1, + status: 'passed', + reason: null, + head_sha: 'abc123', + items: [], + } + handlers.task_evidence?.( + frame({ project_id: 'p1', task_id: 'x', event: { name: 'task_evidence', data: evidence } }), + ) + expect(state.evidence).toEqual(evidence) + }) +}) + +// Per-task on-demand fetch of the recap/evidence records: same posture as +// hydrateChecksStore (404 vs network failure vs a real payload), but for the +// two fields that additionally distinguish "never asked" (absent) from +// "asked, and there is none" (null) — see TaskState's own doc comments. +describe('hydrateRecap / hydrateEvidence (per-task on-demand fetch)', () => { + type Route = { status: number; body: unknown } | 'reject' + + function seedTaskState(tasks: ReturnType, projectId: string, id: string): void { + tasks.store.set(taskKey(projectId, id), { + projectId, + record: record({ id }), + events: [], + liveText: '', + liveMessages: [], + liveTokens: 0, + liveLoadCap: null, + checks: null, + }) + } + + function installRoutedFetch(routes: Record): { restore: () => void } { + const original = globalThis.fetch + globalThis.fetch = ((url: string) => { + const path = url.split('?')[0] ?? url + const route = routes[path] + if (route === undefined) { + return Promise.reject(new Error(`unrouted fetch in test: ${url}`)) + } + if (route === 'reject') { + return Promise.reject(new Error('network down')) + } + const { status, body } = route + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as unknown as Response) + }) as unknown as typeof fetch + return { + restore: () => { + globalThis.fetch = original + }, + } + } + + const RECAP: RecapRecord = { + version: 1, + summary: 'did the thing', + changes: [], + decisions: [], + files: [], + tests: [], + branch: 'codesema/task-x', + } + + const EVIDENCE: EvidenceRecord = { + version: 1, + status: 'passed', + reason: null, + head_sha: 'abc123', + items: [], + } + + test('hydrateRecap on 200 stores the record as-is', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ '/api/tasks/x/recap': { status: 200, body: RECAP } }) + try { + await tasks.hydrateRecap('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.recap).toEqual(RECAP) + } finally { + restore() + } + }) + + test('hydrateRecap on 404 sets recap to null, distinct from never asked', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ + '/api/tasks/x/recap': { status: 404, body: { error: 'not found' } }, + }) + try { + expect(tasks.store.get(taskKey('p1', 'x'))?.recap).toBeUndefined() + await tasks.hydrateRecap('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.recap).toBeNull() + } finally { + restore() + } + }) + + test('hydrateRecap on a network failure leaves recap unset', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ '/api/tasks/x/recap': 'reject' }) + try { + await tasks.hydrateRecap('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.recap).toBeUndefined() + } finally { + restore() + } + }) + + test('hydrateEvidence on 200 stores the record as-is', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ + '/api/tasks/x/evidence': { status: 200, body: EVIDENCE }, + }) + try { + await tasks.hydrateEvidence('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.evidence).toEqual(EVIDENCE) + } finally { + restore() + } + }) + + test('hydrateEvidence on 404 sets evidence to null, distinct from never asked', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ + '/api/tasks/x/evidence': { status: 404, body: { error: 'not found' } }, + }) + try { + expect(tasks.store.get(taskKey('p1', 'x'))?.evidence).toBeUndefined() + await tasks.hydrateEvidence('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.evidence).toBeNull() + } finally { + restore() + } + }) + + test('hydrateEvidence on a network failure leaves evidence unset', async () => { + const tasks = useTasks('tok-123') + seedTaskState(tasks, 'p1', 'x') + const { restore } = installRoutedFetch({ '/api/tasks/x/evidence': 'reject' }) + try { + await tasks.hydrateEvidence('p1', 'x') + expect(tasks.store.get(taskKey('p1', 'x'))?.evidence).toBeUndefined() + } finally { + restore() + } + }) +}) + +describe('evidenceFileUrl', () => { + test('builds the per-task evidence file route, encoding the path', () => { + expect(evidenceFileUrl('task-1', 'screenshots/turn-3 final.png')).toBe( + '/api/tasks/task-1/evidence/screenshots%2Fturn-3%20final.png', + ) + }) }) // mrsLoadByProject carries the FACT behind mrsByProject's flattened `[]`: a diff --git a/packages/web/src/composables/useTasks.ts b/packages/web/src/composables/useTasks.ts index c78505d..14785f4 100644 --- a/packages/web/src/composables/useTasks.ts +++ b/packages/web/src/composables/useTasks.ts @@ -13,6 +13,7 @@ import { computed, reactive, ref, type Ref } from 'vue' import type { DiscoverResponse, + EvidenceRecord, ForgeMr, ForgeMrsResult, ForgeMrStateFilter, @@ -22,6 +23,7 @@ import type { Project, ProjectCandidate, ProjectsResponse, + RecapRecord, TaskChecks, TaskEnvelope, TaskEvent, @@ -78,6 +80,15 @@ export type TaskState = { * GET /api/tasks/:id/checks on demand, updated by 'task_checks' frames. * Null until either happened — which is NOT proof no checks ever ran. */ checks: TaskChecks | null + /** Task recap (volatile mirror of recap.json): hydrated by GET + * /api/tasks/:id/recap on demand, updated by 'task_recap' frames. Absent + * (key not set) means never loaded; null means loaded and there is none. */ + recap?: RecapRecord | null + /** Run evidence (volatile mirror of evidence.json): hydrated by GET + * /api/tasks/:id/evidence on demand, updated by 'task_evidence' frames. + * Absent (key not set) means never loaded; null means loaded and there is + * none. */ + evidence?: EvidenceRecord | null } export type ApiResult = { ok: true } | { ok: false; status: number; error: string } @@ -297,6 +308,20 @@ export function taskStreamHandlers( current.checks = envelope.event.data } }, + task_recap: (e) => { + const envelope = parseFrame<'task_recap'>(e) + const current = store.get(taskKey(envelope.project_id, envelope.task_id)) + if (current) { + current.recap = envelope.event.data + } + }, + task_evidence: (e) => { + const envelope = parseFrame<'task_evidence'>(e) + const current = store.get(taskKey(envelope.project_id, envelope.task_id)) + if (current) { + current.evidence = envelope.event.data + } + }, // Agent-assisted setup: the run's progress and its final proposal. The // frame is project-scoped (no task_id) and its payload is parsed // defensively — a bare proposal object reads as "ready" just like a state @@ -418,6 +443,70 @@ async function hydrateChecksStore(store: TaskStore, projectId: string, id: strin } } +/** + * Loads the persisted recap of one task. 404 = no recap yet: the state's + * `recap` becomes `null`, distinguishing "loaded, none" from "never asked". + * Any other failure (transport, or a non-404 error status) leaves the state + * untouched, the same posture as `hydrateChecksStore`. + */ +async function hydrateRecapStore(store: TaskStore, projectId: string, id: string): Promise { + try { + const res = await fetch( + `/api/tasks/${encodeURIComponent(id)}/recap?project=${encodeURIComponent(projectId)}`, + ) + const current = store.get(taskKey(projectId, id)) + if (!current) { + return + } + if (res.status === 404) { + current.recap = null + return + } + if (!res.ok) { + return + } + current.recap = (await res.json()) as RecapRecord + } catch { + // Local server stopped: keep the last known state, the stream will retry. + } +} + +/** + * Loads the persisted run evidence of one task. Same posture as + * `hydrateRecapStore`: 404 sets `evidence` to `null`, any other failure + * leaves the state untouched. + */ +async function hydrateEvidenceStore( + store: TaskStore, + projectId: string, + id: string, +): Promise { + try { + const res = await fetch( + `/api/tasks/${encodeURIComponent(id)}/evidence?project=${encodeURIComponent(projectId)}`, + ) + const current = store.get(taskKey(projectId, id)) + if (!current) { + return + } + if (res.status === 404) { + current.evidence = null + return + } + if (!res.ok) { + return + } + current.evidence = (await res.json()) as EvidenceRecord + } catch { + // Local server stopped: keep the last known state, the stream will retry. + } +} + +/** URL of one evidence file (a screenshot or a video) captured for a task. */ +export function evidenceFileUrl(taskId: string, path: string): string { + return `/api/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(path)}` +} + /** Loads the full journal of one task (the stream only carries live events). */ async function hydrateStore(store: TaskStore, projectId: string, id: string): Promise { try { @@ -937,6 +1026,8 @@ export function useTasks(token: string) { // Manual re-run of the sandboxed checks (409 while running or commit-less). runChecks: (projectId: string, id: string) => postAction(token, actionPath(projectId, id, 'checks')), + hydrateRecap: (projectId: string, id: string) => hydrateRecapStore(store, projectId, id), + hydrateEvidence: (projectId: string, id: string) => hydrateEvidenceStore(store, projectId, id), // ── Agent-assisted checks setup, per project ────────────────────────── checksSetup, loadChecksSetup: (projectId: string) => loadChecksSetupStore(checksSetup, projectId), diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index c9356a7..bcc48e7 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -947,6 +947,8 @@ export type TaskEnvelope = // Agent-assisted checks setup: PROJECT-scoped, no task_id — the proposal // belongs to the repo, not to a conversation. | { project_id: string; event: { name: 'checks_proposal'; data: unknown } } + | { project_id: string; task_id: string; event: { name: 'task_recap'; data: RecapRecord } } + | { project_id: string; task_id: string; event: { name: 'task_evidence'; data: EvidenceRecord } } // Mirrors packages/cli/src/projects.ts (global project registry) and the // /api/projects endpoints. @@ -1088,3 +1090,28 @@ export type RecapRecord = { /** The merge/pull request URL opened at ship time. Absent before the task has shipped — never a placeholder. */ mr_url?: string } + +// Mirrors packages/contract/src/evidence.ts: the normalized run evidence +// (screenshots/videos captured while proving a task's outcome). Same +// doctrine as RecapRecord above: sanitized on the server. + +export type EvidenceKind = 'screenshot' | 'video' + +export type EvidenceItem = { + kind: EvidenceKind + path: string + bytes: number + turn: number + created_at: string +} + +export type EvidenceStatus = 'passed' | 'failed' | 'skipped' + +/** The normalized evidence of one task (.codesema/tasks//evidence.json). */ +export type EvidenceRecord = { + version: 1 + status: EvidenceStatus + reason: string | null + head_sha: string | null + items: EvidenceItem[] +} From 5fcacecb3df4a8b43acaede26242f6a9d66eb4c7 Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Mon, 31 Aug 2026 13:06:23 +0200 Subject: [PATCH 03/33] feat: serve and stream task recap and evidence --- packages/cli/src/serve.test.ts | 153 +++++++++++- packages/cli/src/serve.ts | 91 ++++++- packages/cli/src/task-server.test.ts | 349 ++++++++++++++++++++++++++- packages/cli/src/task-server.ts | 173 +++++++++++-- 4 files changed, 737 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/serve.test.ts b/packages/cli/src/serve.test.ts index 28dd4fc..207ce57 100644 --- a/packages/cli/src/serve.test.ts +++ b/packages/cli/src/serve.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import { + mkdirSync, mkdtempSync, readdirSync, readFileSync, @@ -12,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, test } from 'bun:test' -import { sanitizeRecord, type ReviewRecord } from './contract.js' +import { sanitizeRecord, type RecapRecord, type ReviewRecord } from './contract.js' import type { ForgeMrsResult } from './forge-mrs.js' import type { MrReviewMode, @@ -36,6 +37,8 @@ import { type LiveSession, type SessionEvent, } from './serve.js' +import { evidenceDir, writeTaskEvidence } from './task-evidence.js' +import { writeTaskRecap } from './task-recap.js' describe('isLoopbackHost', () => { test('accepts loopback hosts, with and without a port', () => { @@ -1127,6 +1130,154 @@ describe('project-scoped repo routes (?project=)', () => { }) }) +describe('task recap and evidence routes (?project=)', () => { + let configDir: string + let repoDir: string + let projectId: string + let projectPath: string + let port: number + let stop: () => Promise + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + + const TASK_ID = 'abcdef123456' + const OTHER_TASK_ID = '0123456789ab' + + function minimalRecap(): RecapRecord { + return { + version: 1, + summary: 'did the thing', + changes: [], + decisions: [], + files: [], + tests: [], + branch: 'codesema/task-x', + } + } + + beforeAll(async () => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-evidence-config-')) + process.env.CODESEMA_CONFIG_DIR = configDir + repoDir = mkdtempSync(join(tmpdir(), 'codesema-evidence-repo-')) + execFileSync('git', ['init', '-b', 'main'], { cwd: repoDir, stdio: 'ignore' }) + const added = addProject(repoDir) + if (!added.ok) { + throw new Error('failed to register test repo') + } + projectId = added.project.id + projectPath = added.project.path + + writeTaskRecap(projectPath, TASK_ID, minimalRecap()) + writeTaskEvidence(projectPath, TASK_ID, { + version: 1, + status: 'passed', + reason: null, + head_sha: null, + items: [], + }) + + const dir = evidenceDir(projectPath, TASK_ID) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'shot.png'), 'a-small-png') + writeFileSync(join(dir, 'clip.webm'), 'a-small-webm') + writeFileSync(join(dir, 'huge.png'), Buffer.alloc(64 * 1024 * 1024 + 1)) + + const started = await startServer(createSession(), { cwd: repoDir, port: 4990 }) + port = started.port + stop = started.stop + }) + + afterAll(async () => { + await stop() + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + rmSync(configDir, { recursive: true, force: true }) + rmSync(repoDir, { recursive: true, force: true }) + }) + + test('200s the recap of a task that has one', async () => { + const res = await rawRequest(port, `/api/tasks/${TASK_ID}/recap?project=${projectId}`) + expect(res.status).toBe(200) + expect(res.contentType).toBe('application/json; charset=utf-8') + expect(JSON.parse(res.body)).toMatchObject({ summary: 'did the thing' }) + }) + + test('404s a task with no recap', async () => { + const res = await rawRequest(port, `/api/tasks/${OTHER_TASK_ID}/recap?project=${projectId}`) + expect(res.status).toBe(404) + }) + + test('404s an invalid task id on recap', async () => { + const res = await rawRequest(port, `/api/tasks/not-a-task-id/recap?project=${projectId}`) + expect(res.status).toBe(404) + }) + + test('200s the evidence record of a task that has one', async () => { + const res = await rawRequest(port, `/api/tasks/${TASK_ID}/evidence?project=${projectId}`) + expect(res.status).toBe(200) + expect(res.contentType).toBe('application/json; charset=utf-8') + expect(JSON.parse(res.body)).toMatchObject({ status: 'passed', items: [] }) + }) + + test('404s a task with no evidence record', async () => { + const res = await rawRequest(port, `/api/tasks/${OTHER_TASK_ID}/evidence?project=${projectId}`) + expect(res.status).toBe(404) + }) + + test('404s an invalid task id on evidence', async () => { + const res = await rawRequest(port, `/api/tasks/not-a-task-id/evidence?project=${projectId}`) + expect(res.status).toBe(404) + }) + + test('serves a png evidence file with the right content type', async () => { + const res = await rawRequest( + port, + `/api/tasks/${TASK_ID}/evidence/shot.png?project=${projectId}`, + ) + expect(res.status).toBe(200) + expect(res.contentType).toBe('image/png') + expect(res.nosniff).toBe('nosniff') + }) + + test('serves a webm evidence file with the right content type', async () => { + const res = await rawRequest( + port, + `/api/tasks/${TASK_ID}/evidence/clip.webm?project=${projectId}`, + ) + expect(res.status).toBe(200) + expect(res.contentType).toBe('video/webm') + expect(res.nosniff).toBe('nosniff') + }) + + test('413s an evidence file over the size limit', async () => { + const res = await rawRequest( + port, + `/api/tasks/${TASK_ID}/evidence/huge.png?project=${projectId}`, + ) + expect(res.status).toBe(413) + }) + + test('404s an absent evidence file', async () => { + const res = await rawRequest( + port, + `/api/tasks/${TASK_ID}/evidence/nope.png?project=${projectId}`, + ) + expect(res.status).toBe(404) + }) + + test('404s a traversing evidence file name', async () => { + const encoded = await rawRequest( + port, + `/api/tasks/${TASK_ID}/evidence/..%2Fpackage.json?project=${projectId}`, + ) + expect(encoded.status).toBe(404) + const nested = await rawRequest(port, `/api/tasks/${TASK_ID}/evidence/a/b?project=${projectId}`) + expect(nested.status).toBe(404) + }) +}) + // GET /api/mrs beyond the default open state (D2 states beyond open). Kept in // its own describe/repo rather than folded into the scoped describe above: // this one asserts on the `state` argument itself, which the scoped describe's diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 2562039..7622a96 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { extname, join, resolve, sep } from 'node:path' @@ -60,6 +60,8 @@ import { import { startRunnerDaemon, type RunnerDaemonHandle } from './runner-daemon.js' import { loadSyncCredentials } from './sync.js' import { applyTaskCriteria } from './task-criteria.js' +import { EVIDENCE_MAX_BYTES, evidenceDir, readTaskEvidence } from './task-evidence.js' +import { readTaskRecap } from './task-recap.js' import type { TaskActionResult } from './task-runner.js' import type { CreateTaskManagerInput, TaskEnvelope, TaskManager } from './task-server.js' import { @@ -232,6 +234,7 @@ const MIME_BY_EXTENSION: Record = { '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', + '.webm': 'video/webm', } /** @@ -1460,11 +1463,58 @@ async function serveStaticFile(res: ServerResponse, pathname: string): Promise { + const projectId = requiredProjectParam(params) + if (!projectId) { + return sendText(res, 400, 'bad request') + } + if (!isTaskId(taskId) || !EVIDENCE_FILENAME_RE.test(filename)) { + return sendText(res, 404, 'not found') + } + const project = getProject(projectId) + if (!project) { + return sendText(res, 404, 'not found') + } + const filePath = resolveStaticPath(evidenceDir(project.path, taskId), `/${filename}`) + if (!filePath) { + return sendText(res, 404, 'not found') + } + let size: number + try { + size = statSync(filePath).size + } catch { + return sendText(res, 404, 'not found') + } + if (size > EVIDENCE_MAX_BYTES) { + return sendText(res, 413, 'payload too large') + } + let content: Buffer + try { + content = await readFile(filePath) + } catch { + return sendText(res, 404, 'not found') + } + const evidenceMime = + MIME_BY_EXTENSION[extname(filePath).toLowerCase()] ?? 'application/octet-stream' + res.writeHead(200, { 'content-type': evidenceMime, 'x-content-type-options': 'nosniff' }) + res.end(content) +} + const TASK_ACTION_RE = /^\/api\/tasks\/([^/]+)\/(reply|ship|interrupt|abandon|checks|resume|criteria|attach)$/ const TASK_GET_RE = /^\/api\/tasks\/([^/]+)$/ const TASK_CHECKS_RE = /^\/api\/tasks\/([^/]+)\/checks$/ const TASK_REVIEW_RE = /^\/api\/tasks\/([^/]+)\/review$/ +const TASK_RECAP_RE = /^\/api\/tasks\/([^/]+)\/recap$/ +const TASK_EVIDENCE_RE = /^\/api\/tasks\/([^/]+)\/evidence$/ +const TASK_EVIDENCE_FILE_RE = /^\/api\/tasks\/([^/]+)\/evidence\/(.+)$/ const PROJECT_DELETE_RE = /^\/api\/projects\/([^/]+)$/ const PROJECT_CHECKS_SETUP_RE = /^\/api\/projects\/([^/]+)\/checks-setup$/ const PROJECT_CHECKS_APPLY_RE = /^\/api\/projects\/([^/]+)\/checks-apply$/ @@ -1788,6 +1838,45 @@ function createRequestHandler(handlerOpts: { } return sendJson(res, 200, review) } + const taskRecapGet = TASK_RECAP_RE.exec(pathname) + if (taskRecapGet?.[1]) { + const projectId = requiredProjectParam(searchParams) + if (!projectId) { + return sendText(res, 400, 'bad request') + } + const project = getProject(projectId) + const recap = + project && isTaskId(taskRecapGet[1]) ? readTaskRecap(project.path, taskRecapGet[1]) : null + if (!recap) { + return sendText(res, 404, 'not found') + } + return sendJson(res, 200, recap) + } + const taskEvidenceGet = TASK_EVIDENCE_RE.exec(pathname) + if (taskEvidenceGet?.[1]) { + const projectId = requiredProjectParam(searchParams) + if (!projectId) { + return sendText(res, 400, 'bad request') + } + const project = getProject(projectId) + const evidence = + project && isTaskId(taskEvidenceGet[1]) + ? readTaskEvidence(project.path, taskEvidenceGet[1]) + : null + if (!evidence) { + return sendText(res, 404, 'not found') + } + return sendJson(res, 200, evidence) + } + const taskEvidenceFileGet = TASK_EVIDENCE_FILE_RE.exec(pathname) + if (taskEvidenceFileGet?.[1] && taskEvidenceFileGet[2]) { + return void serveTaskEvidenceFile( + res, + taskEvidenceFileGet[1], + taskEvidenceFileGet[2], + searchParams, + ) + } const taskGet = TASK_GET_RE.exec(pathname) if (taskGet?.[1]) { if (!tasks) { diff --git a/packages/cli/src/task-server.test.ts b/packages/cli/src/task-server.test.ts index 44b060d..5aa0663 100644 --- a/packages/cli/src/task-server.test.ts +++ b/packages/cli/src/task-server.test.ts @@ -40,7 +40,7 @@ import { import type { ForgeCli, ForgeCliOutcome, ForgeIssuesExecFn } from './forge-issues.js' import { t as translate } from './i18n.js' import { createLoadCap } from './load-cap.js' -import type { SandboxDriver, SandboxSweepOutcome } from './microsandbox-driver.js' +import type { SandboxDriver, SandboxHandle, SandboxSweepOutcome } from './microsandbox-driver.js' import type { ProjectSnapshot } from './microvm-snapshot.js' import type { RunMicrovmTurnOptions } from './microvm-turn.js' import { addProject, listProjects, projectsPath, scratchProject, type Project } from './projects.js' @@ -49,6 +49,7 @@ import { readChecksConfig } from './repo-config.js' import { runbookSha as computeRunbookSha } from './runbook-setup.js' import { createSession, startServer } from './serve.js' import type { MicrovmStepExecutorOptions, RunChecksOptions, StepExecutor } from './task-checks.js' +import { evidenceDir, readTaskEvidence } from './task-evidence.js' import { AUTO_FIX_EXHAUSTED_NAME, AUTO_FIX_JOURNAL_DAMAGED_NAME, @@ -74,7 +75,7 @@ import { resetQueueDegradedReports, } from './task-queue.js' import { RECAP_MARKER_PREFIX } from './task-recap-publish.js' -import { writeTaskRecap } from './task-recap.js' +import { readTaskRecap, writeTaskRecap } from './task-recap.js' import type { TaskRetentionOutcome } from './task-retention.js' import { readTaskReview, type CreateTaskReviewerOptions } from './task-review.js' import { @@ -2358,6 +2359,78 @@ describe('manager.ship', () => { expect(loadTask(cwd, record.id)?.status).toBe('review_ok') expect(loadTask(cwd, record.id)?.cycle_step).toBeUndefined() }) + + describe('task_recap frame', () => { + function minimalRecap(branch: string) { + return { + version: 1 as const, + summary: 'Rewired the worktree cleanup.', + changes: ['worktree: prune before delete'], + decisions: [], + files: ['src/task-worktree.ts'], + tests: [{ command: 'bun test', status: 'passed' as const }], + branch, + } + } + + test('a recap present on disk after a successful push emits task_recap', async () => { + const project = register(makeRepo()) + const cwd = project.path + const record = seedShippable(cwd) + const stub = shipStub({ + pushed: true, + mrUrl: 'https://github.com/o/r/pull/9', + note: null, + }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + const written = writeTaskRecap(cwd, record.id, minimalRecap(record.branch)) + const envelopes: TaskEnvelope[] = [] + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(await manager.ship(project.id, record.id)).toEqual({ ok: true }) + + const recapEnvelope = envelopes.find((e) => e.event.name === 'task_recap') + expect(recapEnvelope?.event.data).toEqual(written) + }) + + test('no recap on disk after a successful push: no task_recap frame', async () => { + const project = register(makeRepo()) + const cwd = project.path + const record = seedShippable(cwd) + const stub = shipStub({ pushed: true, mrUrl: 'https://github.com/o/r/pull/9', note: null }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + const envelopes: TaskEnvelope[] = [] + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(await manager.ship(project.id, record.id)).toEqual({ ok: true }) + + expect(envelopes.some((e) => e.event.name === 'task_recap')).toBe(false) + }) + + test('a recap withheld from the MR description for carrying a secret never rides the SSE frame either', async () => { + const project = register(makeRepo()) + const cwd = project.path + const record = seedShippable(cwd) + const stub = shipStub({ + pushed: true, + mrUrl: 'https://github.com/o/r/pull/9', + note: 'recap withheld: looked like a secret', + recapState: 'recap_blocked_secrets', + }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + writeTaskRecap(cwd, record.id, minimalRecap(record.branch)) + const envelopes: TaskEnvelope[] = [] + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(await manager.ship(project.id, record.id)).toEqual({ ok: true }) + + // The recap DOES exist on disk (generateAndPersist runs before the + // secret scan) — proving the frame's silence comes from `recapState`, + // not from an absent file. + expect(readTaskRecap(cwd, record.id)).not.toBeNull() + expect(envelopes.some((e) => e.event.name === 'task_recap')).toBe(false) + }) + }) }) // --- D20: cycle_step ship/merge -------------------------------------------- @@ -12369,6 +12442,278 @@ describe('microvm wiring (lot C7)', () => { expect(verifyCalls[0]?.validatedSha).toBe('abc1234abc1234ab') void worktree }) + + describe('proof capture wiring', () => { + function fakeSandboxHandle(): SandboxHandle { + return { + name: 'fake-proof-handle', + exec: () => Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false }), + shell: () => Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false }), + copyFromHost: () => Promise.resolve(), + copyToHost: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + readFile: () => Promise.resolve(''), + metrics: () => + Promise.resolve({ memoryHostResidentBytes: null, memoryBytes: null, cpuPercent: null }), + stop: () => Promise.resolve(), + } + } + + function writeProofConfig(cwd: string): void { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + join(cwd, '.codesema', 'config.json'), + JSON.stringify({ + proof: { + journey: 'proof/checkout.spec.ts', + url: 'http://localhost:3000', + timeoutSeconds: 30, + keep: 3, + }, + }), + ) + } + + test('proof configured and the spec is present: ingest runs, evidence.json and a task_evidence frame land', async () => { + const project = register(makeRepo()) + const { record, worktree } = seedInterruptedMicrovmTask(project.path) + writeProofConfig(project.path) + mkdirSync(join(worktree, 'proof'), { recursive: true }) + writeFileSync(join(worktree, 'proof', 'checkout.spec.ts'), 'test()\n') + + const runbook = baseRunbook() + const validation = validRunbookValidation(runbook) + const verification: TaskVerification = { + head_sha: 'proofsha1', + runbook_sha: computeRunbookSha(runbook), + started_at: '2026-01-01T00:00:00.000Z', + finished_at: '2026-01-01T00:05:00.000Z', + status: 'passed', + checks: [ + { command: 'npm test', status: 'passed', exit_code: 0, duration_ms: 5, tail: '' }, + ], + integrity_ok: true, + changed_dependency_files: [], + error: null, + } + const verifyCalls: VerifyTaskOptions[] = [] + const incomingDir = join(evidenceDir(project.path, record.id), '.incoming') + const envelopes: TaskEnvelope[] = [] + const manager = createTaskManager({ + ...managerOpts, + sandboxDriverFn: () => fakeDriver, + readRunbookConfigFn: () => runbook, + readRunbookValidationFn: () => validation, + resolveProjectSnapshotFn: () => + Promise.resolve({ kind: 'cold', reason: 'test' } as ProjectSnapshot), + verifyTaskFn: async (opts) => { + verifyCalls.push(opts) + mkdirSync(incomingDir, { recursive: true }) + writeFileSync(join(incomingDir, 'shot.png'), 'fake-png') + await opts.captureProof?.(fakeSandboxHandle()) + return verification + }, + captureProofFn: () => Promise.resolve({ status: 'passed', reason: null }), + runChecksFn: () => Promise.resolve(finishedChecks()), + reviewTurnFn: async (r, io) => { + r.status = 'review_ok' + io.persist() + }, + runMicrovmTurnFn: (options: RunMicrovmTurnOptions) => { + writeFileSync(join(options.worktree, 'feature.txt'), 'from the vm\n') + const raw = claudeStream('all done') + options.onText?.(raw) + return Promise.resolve(raw) + }, + }) + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(manager.resume(project.id, record.id)).toEqual({ ok: true }) + await until(() => loadTask(project.path, record.id)?.status === 'review_ok') + + expect(verifyCalls).toHaveLength(1) + expect(typeof verifyCalls[0]?.captureProof).toBe('function') + + const evidence = readTaskEvidence(project.path, record.id) + expect(evidence?.status).toBe('passed') + expect(evidence?.reason).toBeNull() + expect(evidence?.head_sha).toBe('proofsha1') + expect(evidence?.items).toHaveLength(1) + expect(evidence?.items[0]?.kind).toBe('screenshot') + + expect( + envelopes.some( + (e) => e.event.name === 'task_evidence' && e.event.data.status === 'passed', + ), + ).toBe(true) + }) + + test('proof configured but the spec is missing from the worktree: a skipped evidence record is written and a frame emitted', async () => { + const project = register(makeRepo()) + const { record } = seedInterruptedMicrovmTask(project.path) + writeProofConfig(project.path) + + const runbook = baseRunbook() + const validation = validRunbookValidation(runbook) + const verification: TaskVerification = { + head_sha: 'proofsha2', + runbook_sha: computeRunbookSha(runbook), + started_at: '2026-01-01T00:00:00.000Z', + finished_at: '2026-01-01T00:05:00.000Z', + status: 'passed', + checks: [], + integrity_ok: true, + changed_dependency_files: [], + error: null, + } + const verifyCalls: VerifyTaskOptions[] = [] + const envelopes: TaskEnvelope[] = [] + const manager = createTaskManager({ + ...managerOpts, + sandboxDriverFn: () => fakeDriver, + readRunbookConfigFn: () => runbook, + readRunbookValidationFn: () => validation, + resolveProjectSnapshotFn: () => + Promise.resolve({ kind: 'cold', reason: 'test' } as ProjectSnapshot), + verifyTaskFn: (opts) => { + verifyCalls.push(opts) + return Promise.resolve(verification) + }, + runChecksFn: () => Promise.resolve(finishedChecks()), + reviewTurnFn: async (r, io) => { + r.status = 'review_ok' + io.persist() + }, + runMicrovmTurnFn: (options: RunMicrovmTurnOptions) => { + writeFileSync(join(options.worktree, 'feature.txt'), 'from the vm\n') + const raw = claudeStream('all done') + options.onText?.(raw) + return Promise.resolve(raw) + }, + }) + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(manager.resume(project.id, record.id)).toEqual({ ok: true }) + await until(() => loadTask(project.path, record.id)?.status === 'review_ok') + + expect(verifyCalls[0]?.captureProof).toBeUndefined() + + const evidence = readTaskEvidence(project.path, record.id) + expect(evidence?.status).toBe('skipped') + expect(evidence?.reason).toContain('proof/checkout.spec.ts') + expect(evidence?.items).toEqual([]) + + expect( + envelopes.some( + (e) => e.event.name === 'task_evidence' && e.event.data.status === 'skipped', + ), + ).toBe(true) + }) + + test('proof not configured: no evidence record, no frame', async () => { + const project = register(makeRepo()) + const { record } = seedInterruptedMicrovmTask(project.path) + + const runbook = baseRunbook() + const validation = validRunbookValidation(runbook) + const verification: TaskVerification = { + head_sha: 'proofsha3', + runbook_sha: computeRunbookSha(runbook), + started_at: '2026-01-01T00:00:00.000Z', + finished_at: '2026-01-01T00:05:00.000Z', + status: 'passed', + checks: [], + integrity_ok: true, + changed_dependency_files: [], + error: null, + } + const envelopes: TaskEnvelope[] = [] + const manager = createTaskManager({ + ...managerOpts, + sandboxDriverFn: () => fakeDriver, + readRunbookConfigFn: () => runbook, + readRunbookValidationFn: () => validation, + resolveProjectSnapshotFn: () => + Promise.resolve({ kind: 'cold', reason: 'test' } as ProjectSnapshot), + verifyTaskFn: () => Promise.resolve(verification), + runChecksFn: () => Promise.resolve(finishedChecks()), + reviewTurnFn: async (r, io) => { + r.status = 'review_ok' + io.persist() + }, + runMicrovmTurnFn: (options: RunMicrovmTurnOptions) => { + writeFileSync(join(options.worktree, 'feature.txt'), 'from the vm\n') + const raw = claudeStream('all done') + options.onText?.(raw) + return Promise.resolve(raw) + }, + }) + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(manager.resume(project.id, record.id)).toEqual({ ok: true }) + await until(() => loadTask(project.path, record.id)?.status === 'review_ok') + + expect(readTaskEvidence(project.path, record.id)).toBeNull() + expect(envelopes.some((e) => e.event.name === 'task_evidence')).toBe(false) + }) + + test('an ingest error is swallowed: the turn still settles, no frame goes out', async () => { + const project = register(makeRepo()) + const { record, worktree } = seedInterruptedMicrovmTask(project.path) + writeProofConfig(project.path) + mkdirSync(join(worktree, 'proof'), { recursive: true }) + writeFileSync(join(worktree, 'proof', 'checkout.spec.ts'), 'test()\n') + // A plain FILE sits where ingestEvidenceFiles needs to mkdir a + // directory: its `mkdirSync(targetDir, { recursive: true })` throws. + mkdirSync(taskDir(project.path, record.id), { recursive: true }) + writeFileSync(join(taskDir(project.path, record.id), 'evidence'), 'not a directory') + + const runbook = baseRunbook() + const validation = validRunbookValidation(runbook) + const verification: TaskVerification = { + head_sha: 'proofsha4', + runbook_sha: computeRunbookSha(runbook), + started_at: '2026-01-01T00:00:00.000Z', + finished_at: '2026-01-01T00:05:00.000Z', + status: 'passed', + checks: [], + integrity_ok: true, + changed_dependency_files: [], + error: null, + } + const envelopes: TaskEnvelope[] = [] + const manager = createTaskManager({ + ...managerOpts, + sandboxDriverFn: () => fakeDriver, + readRunbookConfigFn: () => runbook, + readRunbookValidationFn: () => validation, + resolveProjectSnapshotFn: () => + Promise.resolve({ kind: 'cold', reason: 'test' } as ProjectSnapshot), + verifyTaskFn: async (opts) => { + await opts.captureProof?.(fakeSandboxHandle()) + return verification + }, + captureProofFn: () => Promise.resolve({ status: 'passed', reason: null }), + runChecksFn: () => Promise.resolve(finishedChecks()), + reviewTurnFn: async (r, io) => { + r.status = 'review_ok' + io.persist() + }, + runMicrovmTurnFn: (options: RunMicrovmTurnOptions) => { + writeFileSync(join(options.worktree, 'feature.txt'), 'from the vm\n') + const raw = claudeStream('all done') + options.onText?.(raw) + return Promise.resolve(raw) + }, + }) + manager.subscribe((envelope) => envelopes.push(envelope)) + + expect(manager.resume(project.id, record.id)).toEqual({ ok: true }) + await until(() => loadTask(project.path, record.id)?.status === 'review_ok') + + expect(envelopes.some((e) => e.event.name === 'task_evidence')).toBe(false) + }) + }) }) describe('D8: the validated runbook is read from the PROJECT root, never the task worktree', () => { diff --git a/packages/cli/src/task-server.ts b/packages/cli/src/task-server.ts index ecf83fe..1242065 100644 --- a/packages/cli/src/task-server.ts +++ b/packages/cli/src/task-server.ts @@ -11,6 +11,7 @@ // across N projects ride one EventSource. import { existsSync } from 'node:fs' +import { join } from 'node:path' import { knownAgent, type AgentRunOptions, type WatchdogBudgets } from './agent.js' import { createChecksSetupRunner, @@ -36,7 +37,9 @@ import { TASK_TURN_TEXT_MAX, type AcceptanceCriterion, type ArmTicket, + type EvidenceRecord, type ReasonCode, + type RecapRecord, type ReviewRecord, type RunbookConfig, type TaskChecks, @@ -69,6 +72,7 @@ import { createMicrosandboxDriver, sweepOrphanedSandboxes as sweepOrphanedSandboxesImpl, type SandboxDriver, + type SandboxHandle, type SandboxSweepOutcome, } from './microsandbox-driver.js' import { buildProjectSnapshot, resolveProjectSnapshot } from './microvm-snapshot.js' @@ -79,7 +83,7 @@ import { scratchProject, type Project, } from './projects.js' -import { readChecksConfig } from './repo-config.js' +import { readChecksConfig, readProofConfig } from './repo-config.js' import { runbookSha as computeRunbookSha, readRunbookConfig, @@ -88,6 +92,12 @@ import { import { loadSyncCredentials } from './sync.js' import { microvmStepExecutor, runChecks } from './task-checks.js' import { criteriaBlockKind } from './task-criteria-gate.js' +import { + evidenceDir, + ingestEvidenceFiles, + readTaskEvidence, + writeTaskEvidence, +} from './task-evidence.js' import { applyFixLoopDecision, AUTO_FIX_EXHAUSTED_NAME, @@ -138,8 +148,10 @@ import { import { effectiveMergePolicyIsAuto, mergeTask, type MergeOutcome } from './task-merge.js' import { resolveTaskPlan, type TaskPlanDeps, type TaskPreviewResult } from './task-plan.js' import { replayChecksOnDefaultBranch } from './task-post-merge-checks.js' +import { captureProof, type ProofCaptureResult } from './task-proof.js' import { createTaskQueue, type TaskQueue } from './task-queue.js' import { publishTaskRecap } from './task-recap-publish.js' +import { readTaskRecap } from './task-recap.js' import { applyTaskRetention, DEFAULT_TASK_RETENTION, @@ -246,6 +258,8 @@ export type TaskEnvelope = // PROJECT-scoped, hence no task_id: the checks setup agent proposes a // configuration for the whole repo, not for one conversation. | { project_id: string; event: { name: 'checks_proposal'; data: ChecksSetupState } } + | { project_id: string; task_id: string; event: { name: 'task_evidence'; data: EvidenceRecord } } + | { project_id: string; task_id: string; event: { name: 'task_recap'; data: RecapRecord } } /** * T2.4: the raw issue reference as it arrives from the wire — everything @@ -657,6 +671,8 @@ export type CreateTaskManagerOptions = { buildProjectSnapshotFn?: typeof buildProjectSnapshot /** Test seam: the default replays `runbook.tests` in a fresh sandbox (lot C7). */ verifyTaskFn?: typeof verifyTask + /** Test seam: the default replays the configured `proof.journey` and screenshots the fallback. */ + captureProofFn?: typeof captureProof /** Test seam: the default builds task-checks.ts's own microvm executor (lot C7). */ microvmStepExecutorFn?: typeof microvmStepExecutor headShaFn?: typeof resolveHeadSha @@ -2139,6 +2155,19 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { record.updated_at = new Date().toISOString() saveTask(cwd, record) emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) + // `recap.json` is written to disk whether or not it rides in the MR + // description (generateAndPersist runs before the secret scan) — + // `outcome.recapState` is what actually says "withheld", so silence on + // it is required here too: emitting on `recap` alone would leak a + // secret-blocked recap over SSE that the description itself refused. + const recap = readTaskRecap(cwd, id) + if (recap && !outcome.recapState) { + emit({ + project_id: projectId, + task_id: record.id, + event: { name: 'task_recap', data: recap }, + }) + } // T3.7, AFTER the write and the frame: this transition never reaches // `onTask` (ship persists and broadcasts on its own), so it is mirrored // here or nowhere. Not awaited — the ship's own answer must not wait on @@ -2738,64 +2767,101 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } } + /** + * The guest path `verifyTask` copies the worktree into before any command + * runs (task-verification.ts's own unexported `VERIFY_WORK_DIR`) — kept in + * sync by hand since a proof replay runs on the same handle, in the same + * worktree, after the same copy. + */ + const PROOF_VERIFY_GUEST_WORK_DIR = '/work' + + /** + * What a turn's proof capture attempt came to, for `onTurnDone` to fold + * into evidence.json without re-deriving `verifyAfterCommit`'s own checks: + * `'not_attempted'` covers both "proof isn't configured" and "verification + * itself never reached the capture seam" (no commit, no runbook, stale + * runbook, isolation not `'microvm'`) — in every one of those cases nothing + * was captured, so nothing is recorded and no frame goes out. + */ + type ProofCaptureOutcome = + | { kind: 'not_attempted' } + | { kind: 'spec_missing'; journey: string } + | { + kind: 'attempted' + result: ProofCaptureResult + hostIncomingDir: string + keep: number | null + } + /** * The mechanical verification (lot C7): a `'microvm'` task whose worktree * carries a validated runbook gets `runbook.tests` replayed in a FRESH VM * restored from the project snapshot, right after the same commit checks - * would verify. Null when there is nothing to verify — no commit from - * THIS turn, no runbook, no local validation record, or the task is not - * `'microvm'` — never a signal of failure by itself. A runbook whose sha - * no longer matches its own local validation record (edited by hand, or - * simply re-scanned since) is REFUSED outright rather than silently - * verified against expectations it no longer meets. + * would verify. `verification` is null when there is nothing to verify — + * no commit from THIS turn, no runbook, no local validation record, or the + * task is not `'microvm'` — never a signal of failure by itself. A runbook + * whose sha no longer matches its own local validation record (edited by + * hand, or simply re-scanned since) is REFUSED outright rather than + * silently verified against expectations it no longer meets. * * `validatedSha`: read from `.codesema/runbook.validation.json` at the * PROJECT root — written by the scan (runbook-runner.ts) right alongside * `.codesema/runbook.json` itself. Never derived from git history: that * file is gitignored and never committed, so no commit ever touches it. + * + * `proof`: when the project's `proof` config names a journey spec present + * in the worktree, a `captureProof` closure rides along on `verifyTask`'s + * own seam — it only fires once the healthchecks are green (verifyTask's + * own contract), so `'attempted'` here means the app was proven alive + * first. `hostIncomingDir` is FORCED under `evidenceDir` (never a scratch + * dir elsewhere): `ingestEvidenceFiles`' merge relies on a same-filesystem + * `renameSync`. */ const verifyAfterCommit = async ( ctx: ProjectContext, record: TaskRecord, timeoutMs: number, - ): Promise => { + ): Promise<{ verification: TaskVerification | null; proof: ProofCaptureOutcome }> => { if (record.isolation !== 'microvm') { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } try { const commits = readTaskEvents(ctx.project.path, record.id).filter( (event) => event.type === 'commit', ) if (commits.at(-1)?.data.turn !== record.turns.length) { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } const runbook = resolveTaskRunbook(ctx.project.path) if (!runbook) { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } const readValidation = opts.readRunbookValidationFn ?? readRunbookValidation const validation = readValidation(ctx.project.path) if (!validation) { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } const getHeadSha = opts.headShaFn ?? resolveHeadSha const headSha = getHeadSha(record.worktree) if (!headSha) { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } const runbookSha = computeRunbookSha(runbook) if (validation.runbook_sha !== runbookSha) { const startedAt = new Date().toISOString() return { - head_sha: headSha, - runbook_sha: runbookSha, - started_at: startedAt, - finished_at: new Date().toISOString(), - status: 'refused', - checks: [], - integrity_ok: false, - changed_dependency_files: [], - error: 'runbook changed since its validation, rerun codesema runbook scan', + verification: { + head_sha: headSha, + runbook_sha: runbookSha, + started_at: startedAt, + finished_at: new Date().toISOString(), + status: 'refused', + checks: [], + integrity_ok: false, + changed_dependency_files: [], + error: 'runbook changed since its validation, rerun codesema runbook scan', + }, + proof: { kind: 'not_attempted' }, } } const build = await resolveMicrovmBuild(record, { @@ -2804,8 +2870,31 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { timeoutMs, command: ctx.command, }) + const proofConfig = readProofConfig(ctx.project.path) + let proofOutcome: ProofCaptureOutcome = { kind: 'not_attempted' } + let captureProofOption: ((handle: SandboxHandle) => Promise) | undefined + if (proofConfig) { + if (!existsSync(join(record.worktree, proofConfig.journey))) { + proofOutcome = { kind: 'spec_missing', journey: proofConfig.journey } + } else { + const hostIncomingDir = join(evidenceDir(ctx.project.path, record.id), '.incoming') + const guestProofDir = `${PROOF_VERIFY_GUEST_WORK_DIR}/.codesema-proof` + const proofTimeoutMs = (proofConfig.timeoutSeconds ?? 120) * 1000 + captureProofOption = async (handle) => { + const result = await (opts.captureProofFn ?? captureProof)(handle, { + journey: proofConfig.journey, + url: proofConfig.url, + timeoutMs: proofTimeoutMs, + guestWorkDir: PROOF_VERIFY_GUEST_WORK_DIR, + guestProofDir, + hostIncomingDir, + }) + proofOutcome = { kind: 'attempted', result, hostIncomingDir, keep: proofConfig.keep } + } + } + } const run = opts.verifyTaskFn ?? verifyTask - return await run({ + const verification = await run({ driver: build.driver, worktree: record.worktree, projectId: ctx.project.id, @@ -2816,9 +2905,11 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { validatedSha: validation.validated_sha, snapshotName: build.snapshotName, timeoutMs, + ...(captureProofOption ? { captureProof: captureProofOption } : {}), }) + return { verification, proof: proofOutcome } } catch { - return null + return { verification: null, proof: { kind: 'not_attempted' } } } } @@ -2988,7 +3079,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // the review — a `'microvm'` task with a validated runbook gets // `runbook.tests` replayed in a fresh VM. Null (no runbook, no commit // from this turn, or not a 'microvm' task) means nothing to fold in. - const verification = await verifyAfterCommit(ctx, record, timeoutMs) + const { verification, proof } = await verifyAfterCommit(ctx, record, timeoutMs) if (verification) { const cleanVerification = writeTaskVerification(cwd, record.id, verification) const verificationBlocking = @@ -3022,6 +3113,38 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } } } + // Folds a turn's proof capture attempt into evidence.json, right + // beside the verification block above: 'not_attempted' covers both + // "proof isn't configured" and "verification never reached the + // capture seam", so nothing is written and no frame goes out. + if (proof.kind !== 'not_attempted') { + try { + const evidence = + proof.kind === 'attempted' + ? ingestEvidenceFiles(cwd, record.id, proof.hostIncomingDir, { + turn: record.turns.length, + status: proof.result.status, + reason: proof.result.reason, + head_sha: verification?.head_sha ?? null, + keep: proof.keep, + }) + : writeTaskEvidence(cwd, record.id, { + version: 1, + status: 'skipped', + reason: `proof journey spec not found in the worktree: ${proof.journey}`, + head_sha: verification?.head_sha ?? null, + items: readTaskEvidence(cwd, record.id)?.items ?? [], + }) + emit({ + project_id: projectId, + task_id: record.id, + event: { name: 'task_evidence', data: evidence }, + }) + } catch { + // Best-effort, same doctrine as the mechanical verification above: + // a failed ingest or write must never fail the turn. + } + } // T3.3: whether THIS review got as far as archiving a verdict. A review // that crashed leaves `record.review_ref` pointing at a PREVIOUS turn's // archive, and a fix turn built from it would ask the agent to re-fix From a608103a76fc8f3f89c8bd9f45073af17d19aab7 Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Mon, 31 Aug 2026 13:06:33 +0200 Subject: [PATCH 04/33] feat: make the pilot grid the default workspace shell --- packages/web/src/App.vue | 13 +- .../src/components/WorkspaceHeader.test.ts | 9 + .../web/src/components/WorkspaceHeader.vue | 21 +- .../web/src/components/WorkspaceView.test.ts | 13 + packages/web/src/components/WorkspaceView.vue | 2 + .../src/components/pilot/AgentCard.test.ts | 237 +++++++++ .../web/src/components/pilot/AgentCard.vue | 284 +++++++++++ .../src/components/pilot/ChecksBlock.test.ts | 109 +++++ .../web/src/components/pilot/ChecksBlock.vue | 124 +++++ .../components/pilot/CriteriaBlock.test.ts | 100 ++++ .../src/components/pilot/CriteriaBlock.vue | 98 ++++ .../components/pilot/EvidenceBlock.test.ts | 154 ++++++ .../src/components/pilot/EvidenceBlock.vue | 113 +++++ .../web/src/components/pilot/Lens.test.ts | 82 ++++ packages/web/src/components/pilot/Lens.vue | 113 +++++ .../src/components/pilot/MobileList.test.ts | 167 +++++++ .../web/src/components/pilot/MobileList.vue | 199 ++++++++ .../src/components/pilot/MobileThread.test.ts | 165 +++++++ .../web/src/components/pilot/MobileThread.vue | 237 +++++++++ .../src/components/pilot/PilotView.test.ts | 210 ++++++++ .../web/src/components/pilot/PilotView.vue | 448 ++++++++++++++++++ .../components/pilot/QuestionBlock.test.ts | 71 +++ .../src/components/pilot/QuestionBlock.vue | 54 +++ .../src/components/pilot/RecapBlock.test.ts | 128 +++++ .../web/src/components/pilot/RecapBlock.vue | 172 +++++++ 25 files changed, 3321 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/components/pilot/AgentCard.test.ts create mode 100644 packages/web/src/components/pilot/AgentCard.vue create mode 100644 packages/web/src/components/pilot/ChecksBlock.test.ts create mode 100644 packages/web/src/components/pilot/ChecksBlock.vue create mode 100644 packages/web/src/components/pilot/CriteriaBlock.test.ts create mode 100644 packages/web/src/components/pilot/CriteriaBlock.vue create mode 100644 packages/web/src/components/pilot/EvidenceBlock.test.ts create mode 100644 packages/web/src/components/pilot/EvidenceBlock.vue create mode 100644 packages/web/src/components/pilot/Lens.test.ts create mode 100644 packages/web/src/components/pilot/Lens.vue create mode 100644 packages/web/src/components/pilot/MobileList.test.ts create mode 100644 packages/web/src/components/pilot/MobileList.vue create mode 100644 packages/web/src/components/pilot/MobileThread.test.ts create mode 100644 packages/web/src/components/pilot/MobileThread.vue create mode 100644 packages/web/src/components/pilot/PilotView.test.ts create mode 100644 packages/web/src/components/pilot/PilotView.vue create mode 100644 packages/web/src/components/pilot/QuestionBlock.test.ts create mode 100644 packages/web/src/components/pilot/QuestionBlock.vue create mode 100644 packages/web/src/components/pilot/RecapBlock.test.ts create mode 100644 packages/web/src/components/pilot/RecapBlock.vue diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 637b93e..bb7e903 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,9 +1,11 @@