From 5bf6265f6a0138f9b9635a529e34e5f00e4207e0 Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Tue, 19 May 2026 12:00:19 +0800 Subject: [PATCH 1/7] Add historical run log retrieval Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 25 ++++++- apps/daemon/src/runs.ts | 24 +++++++ apps/daemon/src/server.ts | 23 +++++++ apps/daemon/tests/cli-startup.test.ts | 55 +++++++++++++++ apps/daemon/tests/run-log-route.test.ts | 92 +++++++++++++++++++++++++ apps/daemon/tests/runs.test.ts | 33 +++++++++ packages/contracts/src/api/chat.ts | 12 ++++ 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 apps/daemon/tests/run-log-route.test.ts diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 7becdcea3..844494704 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -147,7 +147,7 @@ const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const PROJECT_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json', 'pending-prompt', 'project', 'conversation', 'message', 'path', 'as', - 'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor', + 'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor', 'since', ]); const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']); // `od automation …` mirrors the Automations tab. Same surface, same @@ -4368,6 +4368,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. @@ -4428,6 +4429,28 @@ Common options: await streamRunEvents(base, id); return; } + case 'logs': { + const id = rest.find((a) => !a.startsWith('-') + && a !== flags.since + && a !== flags['daemon-url']); + if (!id) { + console.error('Usage: od run logs [--since ]'); + process.exit(2); + } + const params = new URLSearchParams(); + if (flags.since) params.set('since', flags.since); + const suffix = params.size ? `?${params.toString()}` : ''; + const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/log${suffix}`); + if (!resp.ok) { + return structuredHttpFailure(resp, resp.status === 404 ? 'run-not-found' : 'daemon-not-running'); + } + const data = await resp.json(); + if (flags.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; + } case 'start': { if (!flags.project) { console.error('--project is required'); diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index 245f2cb5e..dda517f0c 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; 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})$/; function readString(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -25,6 +26,21 @@ export function createChatRunService({ }) { const runs = new Map(); + const parseLogSince = (since) => { + if (since == null || since === '') return null; + if (typeof since !== 'string') { + throw new RangeError('invalid since: expected an RFC3339 timestamp'); + } + if (!RFC3339_TIMESTAMP_RE.test(since)) { + throw new RangeError('invalid since: expected an RFC3339 timestamp'); + } + const timestamp = Date.parse(since); + if (!Number.isFinite(timestamp)) { + throw new RangeError('invalid since: expected an RFC3339 timestamp'); + } + return timestamp; + }; + const create = (meta = {}) => { const now = Date.now(); const run = { @@ -158,6 +174,13 @@ export function createChatRunService({ }); }; + const log = (run, { since } = {}) => { + const sinceTimestamp = parseLogSince(since); + return run.events.filter((record) => ( + sinceTimestamp == null || record.timestamp > sinceTimestamp + )); + }; + 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; @@ -250,6 +273,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 98da40cd3..4620f1a1d 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -11950,6 +11950,29 @@ 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 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), + ); + } + /** @type {import('@open-design/contracts').ChatRunLogResponse} */ + const body = { runId: run.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 bb9d07f56..7e252fa4c 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -1,4 +1,5 @@ import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -57,4 +58,58 @@ describe('CLI startup boundaries', () => { await rm(root, { recursive: true, force: true }); } }); + + 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', + 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())); + } + }); }); + +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}`; +} 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..072f4dd85 --- /dev/null +++ b/apps/daemon/tests/run-log-route.test.ts @@ -0,0 +1,92 @@ +import type http from 'node:http'; +import { afterAll, afterEach, beforeAll, describe, expect, it } 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; + 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'); + + 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 { events: unknown[] }; + expect(filtered.events).toEqual([]); + }); + + 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 }; + + const response = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=not-a-date`); + 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 2c8bd517e..330edd6ea 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -43,6 +43,39 @@ 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('rejects invalid historical event cursors', () => { + const runs = createRuns(); + const run = runs.create(); + + expect(() => runs.log(run, { since: 'not-a-date' })).toThrow(/invalid since/i); + }); + it('cancels active runs and terminates their child process during daemon shutdown', async () => { const runs = createRuns(); const child = new FakeChildProcess({ closeOn: 'SIGTERM' }); diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index 6e311eab8..fa174fe89 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -189,6 +189,18 @@ export interface ChatRunListResponse { runs: ChatRunStatusResponse[]; } +export interface ChatRunLogEvent { + id: number; + event: string; + data: unknown; + timestamp: number; +} + +export interface ChatRunLogResponse { + runId: string; + events: ChatRunLogEvent[]; +} + export interface ChatRunCancelResponse { ok: true; } From 41bd79027c7199ef44deb9385250ffa47ec4f542 Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Wed, 20 May 2026 02:29:51 +0800 Subject: [PATCH 2/7] Fix run log cursor validation Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 8 +++- apps/daemon/src/runs.ts | 42 +++++++++++++++++++- apps/daemon/tests/cli-startup.test.ts | 52 +++++++++++++++++++++++++ apps/daemon/tests/run-log-route.test.ts | 14 ++++--- apps/daemon/tests/runs.test.ts | 1 + 5 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 844494704..548f88894 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -180,6 +180,7 @@ const AUTOMATION_WEEKDAY_TOKENS = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, }; const RECOVERABLE_EXIT_CODES = { + 'invalid-input': 2, 'daemon-not-running': 64, 'plugin-not-found': 65, 'snapshot-not-found': 65, @@ -4442,7 +4443,12 @@ Common options: const suffix = params.size ? `?${params.toString()}` : ''; const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/log${suffix}`); if (!resp.ok) { - return structuredHttpFailure(resp, resp.status === 404 ? 'run-not-found' : 'daemon-not-running'); + return structuredHttpFailure( + resp, + resp.status === 404 ? 'run-not-found' + : resp.status === 400 ? 'invalid-input' + : 'daemon-not-running', + ); } const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index dda517f0c..d665607ee 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; 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 RFC3339_TIMESTAMP_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/; function readString(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -17,6 +17,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, @@ -31,7 +49,27 @@ export function createChatRunService({ if (typeof since !== 'string') { throw new RangeError('invalid since: expected an RFC3339 timestamp'); } - if (!RFC3339_TIMESTAMP_RE.test(since)) { + const match = RFC3339_TIMESTAMP_RE.exec(since); + if (!match) { + throw new RangeError('invalid since: expected an 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 RFC3339 timestamp'); } const timestamp = Date.parse(since); diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index 7e252fa4c..3282689b4 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -105,6 +105,52 @@ describe('CLI startup boundaries', () => { 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())); + } + }); }); async function listen(server: http.Server): Promise { @@ -113,3 +159,9 @@ async function listen(server: http.Server): Promise { 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 index 072f4dd85..b8aa9c902 100644 --- a/apps/daemon/tests/run-log-route.test.ts +++ b/apps/daemon/tests/run-log-route.test.ts @@ -73,11 +73,15 @@ describe('GET /api/runs/:id/log', () => { expect(createResponse.status).toBe(202); const { runId } = await createResponse.json() as { runId: string }; - const response = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/log?since=not-a-date`); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST' }, - }); + 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' }, + }); + } }); }); diff --git a/apps/daemon/tests/runs.test.ts b/apps/daemon/tests/runs.test.ts index 330edd6ea..db47a22ef 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -74,6 +74,7 @@ describe('chat run service shutdown', () => { 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); }); it('cancels active runs and terminates their child process during daemon shutdown', async () => { From 96b3f9868835a516f0c19e5e259c142fc08f0b0c Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Wed, 20 May 2026 08:25:48 +0800 Subject: [PATCH 3/7] Fix run log event cursor Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 4 +- apps/daemon/src/runs.ts | 25 +++++++---- apps/daemon/src/server.ts | 5 ++- apps/daemon/tests/cli-startup.test.ts | 55 ++++++++++++++++++++++++- apps/daemon/tests/run-log-route.test.ts | 54 +++++++++++++++++++++++- apps/daemon/tests/runs.test.ts | 28 +++++++++++++ packages/contracts/src/api/chat.ts | 2 + 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 548f88894..92616eba4 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -4369,7 +4369,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 logs [--since ] Historical run events. od run cancel Request cancellation. od run list [--project ] List recent runs. od run info One run's status. @@ -4435,7 +4435,7 @@ Common options: && a !== flags.since && a !== flags['daemon-url']); if (!id) { - console.error('Usage: od run logs [--since ]'); + console.error('Usage: od run logs [--since ]'); process.exit(2); } const params = new URLSearchParams(); diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index d665607ee..b439d23c1 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; 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; @@ -47,11 +48,18 @@ export function createChatRunService({ const parseLogSince = (since) => { if (since == null || since === '') return null; if (typeof since !== 'string') { - throw new RangeError('invalid since: expected an RFC3339 timestamp'); + 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 RFC3339 timestamp'); + 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); @@ -70,13 +78,13 @@ export function createChatRunService({ || second > 59 || (zoneRaw !== 'Z' && (offsetHour > 23 || offsetMinute > 59)) ) { - throw new RangeError('invalid since: expected an RFC3339 timestamp'); + 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 RFC3339 timestamp'); + throw new RangeError('invalid since: expected an event id or RFC3339 timestamp'); } - return timestamp; + return { type: 'timestamp', timestamp }; }; const create = (meta = {}) => { @@ -213,9 +221,12 @@ export function createChatRunService({ }; const log = (run, { since } = {}) => { - const sinceTimestamp = parseLogSince(since); + const sinceCursor = parseLogSince(since); return run.events.filter((record) => ( - sinceTimestamp == null || record.timestamp > sinceTimestamp + sinceCursor == null + || (sinceCursor.type === 'event-id' + ? record.id > sinceCursor.id + : record.timestamp > sinceCursor.timestamp) )); }; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 4620f1a1d..701adae1d 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -11955,7 +11955,7 @@ export async function startServer({ 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 RFC3339 timestamp'); + return sendApiError(res, 400, 'BAD_REQUEST', 'since must be a single event id or RFC3339 timestamp'); } let events; try { @@ -11968,8 +11968,9 @@ export async function startServer({ err instanceof Error ? err.message : String(err), ); } + const lastEvent = events.length > 0 ? events[events.length - 1] : null; /** @type {import('@open-design/contracts').ChatRunLogResponse} */ - const body = { runId: run.id, events }; + const body = { runId: run.id, nextSince: lastEvent == null ? null : String(lastEvent.id), events }; res.json(body); }); diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index 3282689b4..4b6d2a9f1 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -66,8 +66,9 @@ describe('CLI startup boundaries', () => { if (req.url?.startsWith('/api/runs/run-1/log')) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ - runId: 'run-1', - events: [{ id: 1, event: 'text', data: { kind: 'text', text: 'hello' }, timestamp: 1779148801000 }], + runId: 'run-1', + nextSince: '1', + events: [{ id: 1, event: 'text', data: { kind: 'text', text: 'hello' }, timestamp: 1779148801000 }], })); return; } @@ -151,6 +152,56 @@ describe('CLI startup boundaries', () => { 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())); + } + }); }); async function listen(server: http.Server): Promise { diff --git a/apps/daemon/tests/run-log-route.test.ts b/apps/daemon/tests/run-log-route.test.ts index b8aa9c902..3300d26b3 100644 --- a/apps/daemon/tests/run-log-route.test.ts +++ b/apps/daemon/tests/run-log-route.test.ts @@ -1,5 +1,5 @@ import type http from 'node:http'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { startServer } from '../src/server.js'; @@ -48,21 +48,71 @@ describe('GET /api/runs/:id/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 { events: unknown[] }; + const filtered = await filteredResponse.json() as { nextSince: string | null; events: unknown[] }; + expect(filtered.nextSince).toBeNull(); 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`, { diff --git a/apps/daemon/tests/runs.test.ts b/apps/daemon/tests/runs.test.ts index db47a22ef..e6b1962fd 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -69,6 +69,34 @@ describe('chat run service shutdown', () => { } }); + 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(); diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index fa174fe89..1663f94aa 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -198,6 +198,8 @@ export interface ChatRunLogEvent { export interface ChatRunLogResponse { runId: string; + /** Use the last event id as the next `since` cursor for lossless polling. */ + nextSince?: string | null; events: ChatRunLogEvent[]; } From 8da2e1332041ade1d6e0886fff79eacce394f60d Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Wed, 20 May 2026 11:27:07 +0800 Subject: [PATCH 4/7] Fix run log cursor edge cases Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 60 ++++++++++++++++++++++--- apps/daemon/src/runs.ts | 5 ++- apps/daemon/src/server.ts | 2 +- apps/daemon/tests/cli-startup.test.ts | 38 ++++++++++++++++ apps/daemon/tests/run-log-route.test.ts | 19 +++++++- apps/daemon/tests/runs.test.ts | 1 + 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 92616eba4..f857ccdf6 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -179,6 +179,8 @@ 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, @@ -747,6 +749,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) 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 }; +} + async function cliDaemonUrl(flags) { return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] }); } @@ -4431,17 +4468,28 @@ Common options: return; } case 'logs': { - const id = rest.find((a) => !a.startsWith('-') - && a !== flags.since - && a !== flags['daemon-url']); + 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 (flags.since) params.set('since', flags.since); + if (logFlags.since != null) params.set('since', logFlags.since); const suffix = params.size ? `?${params.toString()}` : ''; - const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/log${suffix}`); + const resp = await fetch(`${logBase}/api/runs/${encodeURIComponent(id)}/log${suffix}`); if (!resp.ok) { return structuredHttpFailure( resp, @@ -4451,7 +4499,7 @@ Common options: ); } const data = await resp.json(); - if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + 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'); } diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index b439d23c1..924bfd017 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -46,10 +46,13 @@ export function createChatRunService({ const runs = new Map(); const parseLogSince = (since) => { - if (since == null || since === '') return null; + 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)) { diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 701adae1d..fdc223d65 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -11968,7 +11968,7 @@ export async function startServer({ err instanceof Error ? err.message : String(err), ); } - const lastEvent = events.length > 0 ? events[events.length - 1] : null; + 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); diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index 4b6d2a9f1..f3484ecce 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -153,6 +153,44 @@ describe('CLI startup boundaries', () => { } }); + 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) => { diff --git a/apps/daemon/tests/run-log-route.test.ts b/apps/daemon/tests/run-log-route.test.ts index 3300d26b3..4ae0ce035 100644 --- a/apps/daemon/tests/run-log-route.test.ts +++ b/apps/daemon/tests/run-log-route.test.ts @@ -62,7 +62,7 @@ describe('GET /api/runs/:id/log', () => { ); expect(filteredResponse.status).toBe(200); const filtered = await filteredResponse.json() as { nextSince: string | null; events: unknown[] }; - expect(filtered.nextSince).toBeNull(); + expect(filtered.nextSince).toBe(String(logs.events.at(-1)?.id)); expect(filtered.events).toEqual([]); }); @@ -133,6 +133,23 @@ describe('GET /api/runs/:id/log', () => { }); } }); + + 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 }> { diff --git a/apps/daemon/tests/runs.test.ts b/apps/daemon/tests/runs.test.ts index e6b1962fd..6a1ad1026 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -103,6 +103,7 @@ describe('chat run service shutdown', () => { 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('cancels active runs and terminates their child process during daemon shutdown', async () => { From a2cf23b16890a786784f916b4709ad6d317d0c09 Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Sun, 24 May 2026 17:59:08 +0800 Subject: [PATCH 5/7] Fix run logs flag parsing edge cases Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 82 +++++++++--------- apps/daemon/tests/cli-startup.test.ts | 120 ++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 41 deletions(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index f857ccdf6..ef0b5a712 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -147,7 +147,7 @@ const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const PROJECT_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json', 'pending-prompt', 'project', 'conversation', 'message', 'path', 'as', - 'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor', 'since', + 'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor', ]); const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']); // `od automation …` mirrors the Automations tab. Same surface, same @@ -731,7 +731,7 @@ function parseFlags(argv, opts = {}) { } if (stringFlags.has(key)) { const next = argv[i + 1]; - if (next == null) { + if (next == null || next.startsWith('-')) { throw new Error(`flag --${key} requires a value`); } out[key] = next; @@ -774,7 +774,7 @@ function parseRunLogsArgs(argv) { continue; } const next = argv[i + 1]; - if (next == null) throw new Error(`flag --${key} requires a value`); + if (next == null || next.startsWith('-')) throw new Error(`flag --${key} requires a value`); flags[key] = next; i++; continue; @@ -4418,6 +4418,44 @@ 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()}` : ''; + const resp = await fetch(`${logBase}/api/runs/${encodeURIComponent(id)}/log${suffix}`); + if (!resp.ok) { + return structuredHttpFailure( + resp, + resp.status === 404 ? 'run-not-found' + : resp.status === 400 ? 'invalid-input' + : 'daemon-not-running', + ); + } + 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) { @@ -4467,44 +4505,6 @@ Common options: await streamRunEvents(base, id); return; } - case '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()}` : ''; - const resp = await fetch(`${logBase}/api/runs/${encodeURIComponent(id)}/log${suffix}`); - if (!resp.ok) { - return structuredHttpFailure( - resp, - resp.status === 404 ? 'run-not-found' - : resp.status === 400 ? 'invalid-input' - : 'daemon-not-running', - ); - } - 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; - } case 'start': { if (!flags.project) { console.error('--project is required'); diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index f3484ecce..267b4da30 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -107,6 +107,51 @@ describe('CLI startup boundaries', () => { } }); + 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')) { @@ -153,6 +198,81 @@ describe('CLI startup boundaries', () => { } }); + 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) => { From 3b11245b048f22d99097cbd41265ec7c517d14c5 Mon Sep 17 00:00:00 2001 From: "kami.c" Date: Mon, 25 May 2026 13:02:16 +0800 Subject: [PATCH 6/7] Scope run-logs strict-flag rule and structure logs connection errors Two follow-up fixes from review on a2cf23b1: - Revert the strict `next.startsWith('-')` check from the shared `parseFlags()`. It was a backward-incompatible regression: every command that takes free-form string input (e.g. `--message "--help me"`) suddenly failed locally. The logs-only strict variant in `parseRunLogsArgs()` keeps the original behavior intact for the new `od run logs --since/--daemon-url` paths. - Wrap the `od run logs` HTTP request in the same try/catch that the rest of the CLI uses, so a refused connection or other network-level failure surfaces as the stable `daemon-not-running` envelope instead of an unstructured stack trace. Scripted callers can now branch on `error.code` for the closed-daemon path. Added two regression tests in `tests/cli-startup.test.ts`: - `od run start --message --weird-value` reaches the daemon with the value preserved, guarding the shared `parseFlags()` boundary. - `od run logs` against a closed TCP port exits with code 64 and a `{ error: { code: "daemon-not-running" } }` envelope on stderr. --- apps/daemon/src/cli.ts | 20 +++++- apps/daemon/tests/cli-startup.test.ts | 93 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index ef0b5a712..3fdf48f5b 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -730,8 +730,12 @@ 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 || next.startsWith('-')) { + if (next == null) { throw new Error(`flag --${key} requires a value`); } out[key] = next; @@ -4440,7 +4444,19 @@ Common options: const params = new URLSearchParams(); if (logFlags.since != null) params.set('since', logFlags.since); const suffix = params.size ? `?${params.toString()}` : ''; - const resp = await fetch(`${logBase}/api/runs/${encodeURIComponent(id)}/log${suffix}`); + 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, diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index 267b4da30..a048ea306 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -360,6 +360,99 @@ describe('CLI startup boundaries', () => { 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); + } + }); }); async function listen(server: http.Server): Promise { From 6bf0e3ecb6cd0470c88f54a24232754449f76d3e Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Tue, 26 May 2026 23:04:38 +0800 Subject: [PATCH 7/7] Fix run logs HTTP error classification Co-authored-by: multica-agent --- apps/daemon/src/cli.ts | 2 +- apps/daemon/tests/cli-startup.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 3fdf48f5b..00b464925 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -4462,7 +4462,7 @@ Common options: resp, resp.status === 404 ? 'run-not-found' : resp.status === 400 ? 'invalid-input' - : 'daemon-not-running', + : 'daemon-http-error', ); } const data = await resp.json(); diff --git a/apps/daemon/tests/cli-startup.test.ts b/apps/daemon/tests/cli-startup.test.ts index a048ea306..60407db4a 100644 --- a/apps/daemon/tests/cli-startup.test.ts +++ b/apps/daemon/tests/cli-startup.test.ts @@ -453,6 +453,50 @@ describe('CLI startup boundaries', () => { 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())); + } + }); }); async function listen(server: http.Server): Promise {