This commit is contained in:
kami 2026-05-31 13:25:42 +08:00 committed by GitHub
commit 11637994d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 885 additions and 0 deletions

View file

@ -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 <id>] [--inputs <json>] [--grant-caps a,b]
[--agent claude|codex|gemini] [--model <id>] [--follow] [--json]
od run watch <runId> ND-JSON event stream on stdout.
od run logs <runId> [--since <eventId|RFC3339>] Historical run events.
od run cancel <runId> Request cancellation.
od run list [--project <id>] List recent runs.
od run info <runId> 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 <runId> [--since <eventId|RFC3339>]');
process.exit(2);
}
if (!id) {
console.error('Usage: od run logs <runId> [--since <eventId|RFC3339>]');
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) {

View file

@ -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,

View file

@ -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

View file

@ -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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<string> {
await new Promise<void>((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);
}

View file

@ -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> | 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> | 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<void>((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');
}

View file

@ -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' });

View file

@ -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;
}