diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index dd565f2ea..589ad5306 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -184,7 +184,10 @@ const AUTOMATION_WEEKDAY_TOKENS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, }; +const RUN_LOGS_STRING_FLAGS = new Set(['daemon-url', 'since']); +const RUN_LOGS_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const RECOVERABLE_EXIT_CODES = { + 'invalid-input': 2, 'daemon-not-running': 64, 'plugin-not-found': 65, 'snapshot-not-found': 65, @@ -740,6 +743,10 @@ function parseFlags(argv, opts = {}) { continue; } if (stringFlags.has(key)) { + // Generic parser stays permissive on the value side so that + // free-form string inputs (e.g. `--message "--help me"`, + // `--prompt --raw-flag`) keep working for every other command. + // Logs-specific strictness lives in `parseRunLogsArgs()`. const next = argv[i + 1]; if (next == null) { throw new Error(`flag --${key} requires a value`); @@ -759,6 +766,41 @@ function parseFlags(argv, opts = {}) { return out; } +function parseRunLogsArgs(argv) { + const flags = {}; + const positionals = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg?.startsWith('-')) { + positionals.push(arg); + continue; + } + if (!arg.startsWith('--')) { + throw new Error(`Unsupported od run logs flag: ${arg}`); + } + const eq = arg.indexOf('='); + const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2); + if (RUN_LOGS_BOOLEAN_FLAGS.has(key)) { + if (eq >= 0) throw new Error(`flag --${key} does not take a value`); + flags[key] = true; + continue; + } + if (RUN_LOGS_STRING_FLAGS.has(key)) { + if (eq >= 0) { + flags[key] = arg.slice(eq + 1); + continue; + } + const next = argv[i + 1]; + if (next == null || next.startsWith('-')) throw new Error(`flag --${key} requires a value`); + flags[key] = next; + i++; + continue; + } + throw new Error(`Unsupported od run logs flag: --${key}`); + } + return { flags, positionals }; +} + function positionalArgs(argv, stringFlags = new Set()) { const out = []; for (let i = 0; i < argv.length; i++) { @@ -4526,6 +4568,7 @@ async function runRun(args) { [--plugin ] [--inputs ] [--grant-caps a,b] [--agent claude|codex|gemini] [--model ] [--follow] [--json] od run watch ND-JSON event stream on stdout. + od run logs [--since ] Historical run events. od run cancel Request cancellation. od run list [--project ] List recent runs. od run info One run's status. @@ -4537,6 +4580,56 @@ Common options: } const sub = args[0]; const rest = args.slice(1); + if (sub === 'logs') { + let parsed; + try { + parsed = parseRunLogsArgs(rest); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(2); + } + const { flags: logFlags, positionals } = parsed; + const id = positionals[0]; + if (positionals.length > 1) { + console.error('Usage: od run logs [--since ]'); + process.exit(2); + } + if (!id) { + console.error('Usage: od run logs [--since ]'); + process.exit(2); + } + const logBase = (await projectDaemonUrl(logFlags)).replace(/\/$/, ''); + const params = new URLSearchParams(); + if (logFlags.since != null) params.set('since', logFlags.since); + const suffix = params.size ? `?${params.toString()}` : ''; + let resp; + try { + resp = await fetch(`${logBase}/api/runs/${encodeURIComponent(id)}/log${suffix}`); + } catch (err) { + // Network-level failures (refused connection, DNS, etc.) must + // surface as the stable `daemon-not-running` envelope so scripted + // callers can branch on `error.code` instead of an unstructured + // stack trace. + return exitWithStructuredError({ + code: 'daemon-not-running', + message: `Cannot reach daemon at ${logBase}: ${err?.message ?? err}`, + }); + } + if (!resp.ok) { + return structuredHttpFailure( + resp, + resp.status === 404 ? 'run-not-found' + : resp.status === 400 ? 'invalid-input' + : 'daemon-http-error', + ); + } + const data = await resp.json(); + if (logFlags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + for (const record of data?.events ?? []) { + process.stdout.write(JSON.stringify(record) + '\n'); + } + return; + } const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index 41f81cb73..27ff8ff41 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -9,6 +9,8 @@ import { } from './run-tool-bundle.js'; export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']); +const RFC3339_TIMESTAMP_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/; +const RUN_LOG_EVENT_ID_RE = /^(0|[1-9]\d*)$/; function readString(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -23,6 +25,24 @@ function extractErrorDetails(data) { }; } +function isLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function daysInMonth(year, month) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } +} + export function createChatRunService({ createSseResponse, createSseErrorPayload, @@ -39,6 +59,51 @@ export function createChatRunService({ }) { const runs = new Map(); + const parseLogSince = (since) => { + if (since == null) return null; + if (typeof since !== 'string') { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + if (since === '') { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + if (RUN_LOG_EVENT_ID_RE.test(since)) { + const id = Number(since); + if (!Number.isSafeInteger(id)) { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + return { type: 'event-id', id }; + } + const match = RFC3339_TIMESTAMP_RE.exec(since); + if (!match) { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + const [, yearRaw, monthRaw, dayRaw, hourRaw, minuteRaw, secondRaw, zoneRaw, offsetHourRaw, offsetMinuteRaw] = match; + const year = Number(yearRaw); + const month = Number(monthRaw); + const day = Number(dayRaw); + const hour = Number(hourRaw); + const minute = Number(minuteRaw); + const second = Number(secondRaw); + const offsetHour = offsetHourRaw == null ? 0 : Number(offsetHourRaw); + const offsetMinute = offsetMinuteRaw == null ? 0 : Number(offsetMinuteRaw); + if ( + month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month) + || hour > 23 + || minute > 59 + || second > 59 + || (zoneRaw !== 'Z' && (offsetHour > 23 || offsetMinute > 59)) + ) { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + const timestamp = Date.parse(since); + if (!Number.isFinite(timestamp)) { + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); + } + return { type: 'timestamp', timestamp }; + }; + const create = (meta = {}) => { const now = Date.now(); const id = randomUUID(); @@ -215,6 +280,16 @@ export function createChatRunService({ }); }; + const log = (run, { since } = {}) => { + const sinceCursor = parseLogSince(since); + return run.events.filter((record) => ( + sinceCursor == null + || (sinceCursor.type === 'event-id' + ? record.id > sinceCursor.id + : record.timestamp > sinceCursor.timestamp) + )); + }; + const list = ({ projectId, conversationId, status } = {}) => Array.from(runs.values()).filter((run) => { if (typeof projectId === 'string' && projectId && run.projectId !== projectId) return false; if (typeof conversationId === 'string' && conversationId && run.conversationId !== conversationId) return false; @@ -330,6 +405,7 @@ export function createChatRunService({ get, list, stream, + log, cancel, shutdownActive, wait, diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6ea861454..c56d2a34c 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -13580,6 +13580,30 @@ export async function startServer({ design.runs.stream(run, req, res); }); + app.get('/api/runs/:id/log', (req, res) => { + const run = design.runs.get(req.params.id); + if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + const since = req.query.since; + if (since !== undefined && typeof since !== 'string') { + return sendApiError(res, 400, 'BAD_REQUEST', 'since must be a single event id or RFC3339 timestamp'); + } + let events; + try { + events = design.runs.log(run, { since }); + } catch (err) { + return sendApiError( + res, + 400, + 'BAD_REQUEST', + err instanceof Error ? err.message : String(err), + ); + } + const lastEvent = run.events.length > 0 ? run.events[run.events.length - 1] : null; + /** @type {import('@open-design/contracts').ChatRunLogResponse} */ + const body = { runId: run.id, nextSince: lastEvent == null ? null : String(lastEvent.id), events }; + res.json(body); + }); + // Phase 4 / spec §10.3.5 — AG-UI canonical stream. // // Same data plane as /api/runs/:id/events but every record passes diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index 20703069b..19c0e7262 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -59,6 +59,445 @@ describe('CLI startup boundaries', () => { } }); + it('routes od run logs to the requested run id when flags precede the positional id', async () => { + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(req.url ?? ''); + if (req.url?.startsWith('/api/runs/run-1/log')) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + runId: 'run-1', + nextSince: '1', + events: [{ id: 1, event: 'text', data: { kind: 'text', text: 'hello' }, timestamp: 1779148801000 }], + })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'run not found' } })); + }); + + try { + const baseUrl = await listen(server); + const { stdout } = await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--since', + '2026-05-19T00:00:00.000Z', + 'run-1', + '--json', + ], + { cwd: daemonRoot }, + ); + + expect(JSON.parse(stdout)).toMatchObject({ + runId: 'run-1', + events: [{ id: 1, event: 'text' }], + }); + expect(requests[0]).toBe('/api/runs/run-1/log?since=2026-05-19T00%3A00%3A00.000Z'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('keeps od run logs json output available when --since appears before --json', async () => { + const server = http.createServer((req, res) => { + if (req.url?.startsWith('/api/runs/run-1/log')) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + runId: 'run-1', + nextSince: '1', + events: [{ id: 1, event: 'text', data: { kind: 'text', text: 'hello' }, timestamp: 1779148801000 }], + })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'run not found' } })); + }); + + try { + const baseUrl = await listen(server); + const { stdout } = await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--since', + '2026-05-19T00:00:00.000Z', + '--json', + 'run-1', + ], + { cwd: daemonRoot }, + ); + + expect(JSON.parse(stdout)).toMatchObject({ + runId: 'run-1', + events: [{ id: 1, event: 'text' }], + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('reports invalid run log cursors as input errors', async () => { + const server = http.createServer((req, res) => { + if (req.url?.startsWith('/api/runs/run-1/log')) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'BAD_REQUEST', message: 'invalid since: expected an RFC3339 timestamp' } })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'run not found' } })); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--since', + '2026-02-31T00:00:00Z', + 'run-1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run logs unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(2); + expect(readStructuredError(failed.stderr ?? '')).toMatchObject({ + error: { + code: 'invalid-input', + message: 'invalid since: expected an RFC3339 timestamp', + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects od run logs string flags followed by another flag', async () => { + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(req.url ?? ''); + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'UNEXPECTED', message: 'unexpected request' } })); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--since', + '--json', + 'run-1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run logs unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(2); + expect(failed.stderr).toContain('flag --since requires a value'); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects --since on od run subcommands other than logs', async () => { + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(req.url ?? ''); + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'UNEXPECTED', message: 'unexpected request' } })); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'list', + '--daemon-url', + baseUrl, + '--since', + '1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run list unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(1); + expect(failed.stderr).toContain('unknown flag: --since'); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects unsupported od run logs flags instead of treating their values as the run id', async () => { + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(req.url ?? ''); + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'UNEXPECTED', message: 'unexpected request' } })); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--project', + 'project-1', + 'run-1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run logs unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(2); + expect(failed.stderr).toContain('Unsupported od run logs flag: --project'); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('passes event id cursors through to od run logs unchanged', async () => { + const requests: string[] = []; + const server = http.createServer((req, res) => { + requests.push(req.url ?? ''); + if (req.url?.startsWith('/api/runs/run-1/log')) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + runId: 'run-1', + nextSince: '3', + events: [ + { id: 2, event: 'text', data: { kind: 'text', text: 'hello' }, timestamp: 1779148800000 }, + { id: 3, event: 'end', data: { status: 'succeeded' }, timestamp: 1779148800000 }, + ], + })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'run not found' } })); + }); + + try { + const baseUrl = await listen(server); + const { stdout } = await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + '--since', + '1', + 'run-1', + ], + { cwd: daemonRoot }, + ); + + expect(requests[0]).toBe('/api/runs/run-1/log?since=1'); + expect(stdout.trim().split('\n').map((line) => JSON.parse(line))).toEqual([ + expect.objectContaining({ id: 2, event: 'text', timestamp: 1779148800000 }), + expect.objectContaining({ id: 3, event: 'end', timestamp: 1779148800000 }), + ]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('preserves shared parseFlags acceptance of string values that begin with a dash', async () => { + // Regression: the run-logs strict-flag fix initially leaked into the + // shared `parseFlags()`, which broke `--message "--something"` and + // other free-form string inputs across every command. The logs-only + // strictness must stay scoped to `parseRunLogsArgs()`; this guards + // that boundary by driving `od run start --message --weird-value` + // and asserting the value reaches the request body unchanged. + const requests: { url: string; body: unknown }[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let parsedBody: unknown = null; + try { parsedBody = raw ? JSON.parse(raw) : null; } catch { parsedBody = raw; } + requests.push({ url: req.url ?? '', body: parsedBody }); + if (req.url === '/api/runs' && req.method === 'POST') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ runId: 'run-7' })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); + }); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'start', + '--daemon-url', + baseUrl, + '--project', + 'repro', + '--message', + '--weird-value', + ], + { cwd: daemonRoot }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + url: '/api/runs', + body: { projectId: 'repro', message: '--weird-value' }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('surfaces an unreachable daemon as a structured daemon-not-running envelope for od run logs', async () => { + // Regression: the run-logs route used a bare `fetch()`, so a refused + // connection leaked as an unstructured stack trace instead of the + // stable error envelope scripted callers expect. Point the CLI at a + // port that is guaranteed to be closed and assert the envelope shape. + const closedServer = http.createServer(() => undefined); + const baseUrl = await listen(closedServer); + await new Promise((resolve) => closedServer.close(() => resolve())); + + try { + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + 'run-1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run logs unexpectedly succeeded against a closed daemon'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(64); + const envelope = readStructuredError(failed.stderr ?? ''); + expect(envelope).toMatchObject({ + error: { code: 'daemon-not-running' }, + }); + expect(envelope.error.message).toContain(baseUrl); + } + }); + + it('classifies reachable daemon HTTP failures separately from daemon-not-running for od run logs', async () => { + const server = http.createServer((req, res) => { + if (req.url?.startsWith('/api/runs/run-1/log')) { + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'UNEXPECTED', message: 'log storage failed' } })); + return; + } + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); + }); + + try { + const baseUrl = await listen(server); + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'run', + 'logs', + '--daemon-url', + baseUrl, + 'run-1', + ], + { cwd: daemonRoot }, + ); + throw new Error('od run logs unexpectedly succeeded against a failing daemon'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string }; + expect(failed.code).toBe(1); + expect(readStructuredError(failed.stderr ?? '')).toMatchObject({ + error: { + code: 'daemon-http-error', + message: 'log storage failed', + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('uses the token-gated media endpoint without falling back when policy denies generation', async () => { const seen: Array<{ url: string | undefined; authorization: string | undefined }> = []; const server = http.createServer((req, res) => { @@ -130,3 +569,16 @@ describe('CLI startup boundaries', () => { ]); }); }); + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + return `http://127.0.0.1:${address.port}`; +} + +function readStructuredError(stderr: string) { + const line = stderr.split('\n').find((entry) => entry.trim().startsWith('{')); + if (!line) throw new Error(`missing structured error in stderr: ${stderr}`); + return JSON.parse(line); +} diff --git a/apps/daemon/tests/run-log-route.test.ts b/apps/daemon/tests/run-log-route.test.ts new file mode 100644 index 000000000..4ae0ce035 --- /dev/null +++ b/apps/daemon/tests/run-log-route.test.ts @@ -0,0 +1,163 @@ +import type http from 'node:http'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('GET /api/runs/:id/log', () => { + let server: http.Server; + let baseUrl: string; + let shutdown: (() => Promise | void) | undefined; + const originalPath = process.env.PATH; + + beforeAll(async () => { + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise | void; + }; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; + }); + + afterEach(() => { + if (originalPath == null) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + }); + + afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('returns buffered run events and filters strictly after an RFC3339 timestamp', async () => { + process.env.PATH = ''; + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agentId: 'opencode', message: 'hello' }), + }); + expect(createResponse.status).toBe(202); + const { runId } = await createResponse.json() as { runId: string }; + await waitForRunStatus(baseUrl, runId); + + const logsResponse = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/log`); + expect(logsResponse.status).toBe(200); + const logs = await logsResponse.json() as { + runId: string; + nextSince: string | null; + events: Array<{ id: number; event: string; timestamp: number }>; + }; + expect(logs.runId).toBe(runId); + expect(logs.events.some((event) => event.event === 'error')).toBe(true); + expect(logs.events.at(-1)?.event).toBe('end'); + expect(logs.nextSince).toBe(String(logs.events.at(-1)?.id)); + + const newestTimestamp = Math.max(...logs.events.map((event) => event.timestamp)); + const filteredResponse = await fetch( + `${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=${encodeURIComponent(new Date(newestTimestamp).toISOString())}`, + ); + expect(filteredResponse.status).toBe(200); + const filtered = await filteredResponse.json() as { nextSince: string | null; events: unknown[] }; + expect(filtered.nextSince).toBe(String(logs.events.at(-1)?.id)); + expect(filtered.events).toEqual([]); + }); + + it('returns same-millisecond buffered events filtered strictly after an event id cursor', async () => { + process.env.PATH = ''; + const now = Date.parse('2026-05-19T00:00:00.000Z'); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now); + let runId: string; + try { + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agentId: 'opencode', message: 'hello' }), + }); + expect(createResponse.status).toBe(202); + ({ runId } = await createResponse.json() as { runId: string }); + await waitForRunStatus(baseUrl, runId); + } finally { + nowSpy.mockRestore(); + } + + const logsResponse = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/log`); + expect(logsResponse.status).toBe(200); + const logs = await logsResponse.json() as { + events: Array<{ id: number; event: string; timestamp: number }>; + }; + const consecutiveSameMillisecond = logs.events + .map((event, index) => ({ event, next: logs.events[index + 1] })) + .find(({ event, next }) => next && event.timestamp === next.timestamp); + expect(consecutiveSameMillisecond).toBeDefined(); + + const sinceId = consecutiveSameMillisecond!.event.id; + const filteredResponse = await fetch( + `${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=${sinceId}`, + ); + expect(filteredResponse.status).toBe(200); + const filtered = await filteredResponse.json() as { + nextSince: string | null; + events: Array<{ id: number; event: string; timestamp: number }>; + }; + + expect(filtered.events.at(0)).toMatchObject({ + id: consecutiveSameMillisecond!.next!.id, + event: consecutiveSameMillisecond!.next!.event, + timestamp: consecutiveSameMillisecond!.event.timestamp, + }); + expect(filtered.events.every((event) => event.id > sinceId)).toBe(true); + expect(filtered.nextSince).toBe(String(filtered.events.at(-1)?.id)); + }); + + it('rejects invalid since timestamps', async () => { + process.env.PATH = ''; + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agentId: 'opencode', message: 'hello' }), + }); + expect(createResponse.status).toBe(202); + const { runId } = await createResponse.json() as { runId: string }; + + for (const since of ['not-a-date', '2026-02-31T00:00:00Z']) { + const response = await fetch( + `${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=${encodeURIComponent(since)}`, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST' }, + }); + } + }); + + it('rejects empty since cursors', async () => { + process.env.PATH = ''; + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agentId: 'opencode', message: 'hello' }), + }); + expect(createResponse.status).toBe(202); + const { runId } = await createResponse.json() as { runId: string }; + + const response = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=`); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST' }, + }); + }); +}); + +async function waitForRunStatus(baseUrl: string, runId: string): Promise<{ status: string }> { + for (let attempt = 0; attempt < 120; attempt += 1) { + const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`); + const statusBody = await statusResponse.json() as { status: string }; + if (statusBody.status !== 'queued' && statusBody.status !== 'running') return statusBody; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error('run did not reach expected status'); +} diff --git a/apps/daemon/tests/runs.test.ts b/apps/daemon/tests/runs.test.ts index 03e61fdc3..27146f77c 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -63,6 +63,69 @@ describe('chat run service shutdown', () => { ).toEqual([runB]); }); + it('returns historical event records filtered strictly after an RFC3339 cursor', () => { + vi.useFakeTimers(); + try { + const runs = createRuns(); + const run = runs.create({ projectId: 'project-1' }); + + vi.setSystemTime(new Date('2026-05-19T00:00:00.000Z')); + runs.emit(run, 'status', { kind: 'status', label: 'queued' }); + vi.setSystemTime(new Date('2026-05-19T00:00:02.000Z')); + runs.emit(run, 'text', { kind: 'text', text: 'hello' }); + vi.setSystemTime(new Date('2026-05-19T00:00:03.000Z')); + runs.finish(run, 'succeeded', 0, null); + + expect(runs.log(run, { since: '2026-05-19T00:00:02.000Z' })).toEqual([ + expect.objectContaining({ + id: 3, + event: 'end', + data: { code: 0, signal: null, status: 'succeeded' }, + timestamp: Date.parse('2026-05-19T00:00:03.000Z'), + }), + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('returns same-millisecond event records filtered strictly after an event id cursor', () => { + vi.useFakeTimers(); + try { + const runs = createRuns(); + const run = runs.create({ projectId: 'project-1' }); + + vi.setSystemTime(new Date('2026-05-19T00:00:00.000Z')); + runs.emit(run, 'status', { kind: 'status', label: 'queued' }); + runs.emit(run, 'text', { kind: 'text', text: 'hello' }); + runs.finish(run, 'succeeded', 0, null); + + expect(runs.log(run, { since: '1' })).toEqual([ + expect.objectContaining({ + id: 2, + event: 'text', + timestamp: Date.parse('2026-05-19T00:00:00.000Z'), + }), + expect.objectContaining({ + id: 3, + event: 'end', + timestamp: Date.parse('2026-05-19T00:00:00.000Z'), + }), + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects invalid historical event cursors', () => { + const runs = createRuns(); + const run = runs.create(); + + expect(() => runs.log(run, { since: 'not-a-date' })).toThrow(/invalid since/i); + expect(() => runs.log(run, { since: '2026-02-31T00:00:00Z' })).toThrow(/invalid since/i); + expect(() => runs.log(run, { since: '' })).toThrow(/invalid since/i); + }); + it('stores effective media execution policy on run status bodies', () => { const runs = createRuns(); const defaultRun = runs.create({ projectId: 'project-1', conversationId: 'conv-a' }); diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index babba0cb0..404e00160 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -261,6 +261,20 @@ export interface ChatRunListResponse { runs: ChatRunStatusResponse[]; } +export interface ChatRunLogEvent { + id: number; + event: string; + data: unknown; + timestamp: number; +} + +export interface ChatRunLogResponse { + runId: string; + /** Use the last event id as the next `since` cursor for lossless polling. */ + nextSince?: string | null; + events: ChatRunLogEvent[]; +} + export interface ChatRunCancelResponse { ok: true; }