mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge a08941b3e6 into 53fb175855
This commit is contained in:
commit
11637994d2
7 changed files with 885 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
163
apps/daemon/tests/run-log-route.test.ts
Normal file
163
apps/daemon/tests/run-log-route.test.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue