Files
open-design/apps/daemon/tests/cli-startup.test.ts
900d01c61a fix(daemon): initialize automation CLI id splitter before dispatch (#7612)
`od automation create` and `od automation update` both failed with
"ReferenceError: Cannot access 'splitAutomationIds' before initialization",
making the two commands unusable on every flag path.

The CLI dispatches its subcommand from the top level of cli.ts, roughly
10,300 lines before the module body evaluates
`const splitAutomationIds = splitCommaSeparatedIds`. The underlying
function declaration hoists, but the const alias does not, so
automationContextFromFlags always ran while the alias was still in its
temporal dead zone.

Drop the alias and call splitCommaSeparatedIds directly, matching how the
run CLI already calls it. This keeps the splitter a hoisted function
declaration, so it cannot fall back into a dead zone.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 03:01:04 +00:00

666 lines
23 KiB
TypeScript

import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { promisify } from 'node:util';
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
describe('CLI startup boundaries', () => {
it.each([
['doctor', ['doctor', '--help']],
['config', ['config', 'get', 'apiProtocol', '--daemon-url', 'http://127.0.0.1:9']],
['diagnostics', ['diagnostics', 'export', '--daemon-url', 'http://127.0.0.1:9']],
['amr', ['amr', 'status', '--daemon-url', 'http://127.0.0.1:9']],
['automation create', [
'automation', 'create',
'--name', 'nightly',
'--prompt', 'refresh the board',
'--schedule', 'daily:03:00',
'--skill', 'alpha,beta',
'--daemon-url', 'http://127.0.0.1:9',
]],
['automation update', [
'automation', 'update', 'routine-1',
'--name', 'nightly',
'--skill', 'alpha,beta',
'--daemon-url', 'http://127.0.0.1:9',
]],
])('initializes module-level bindings before dispatching od %s', async (_name, args) => {
let output = '';
try {
const result = await execFileAsync(
process.execPath,
['--import', 'tsx', cliEntry, ...args],
{
cwd: daemonRoot,
env: { ...process.env },
},
);
output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
} catch (error: unknown) {
const failed = error as { stdout?: string; stderr?: string };
output = `${failed.stdout ?? ''}${failed.stderr ?? ''}`;
}
expect(output).not.toContain('ReferenceError');
expect(output).not.toContain('before initialization');
expect(output).not.toContain('CONFIG_STRING_FLAGS');
expect(output).not.toContain('DIAGNOSTICS_STRING_FLAGS');
expect(output).not.toContain('AMR_STRING_FLAGS');
expect(output).not.toContain('splitAutomationIds');
});
it('keeps od daemon start alive until SIGTERM and reports the actual listening port', async () => {
const root = await mkdtemp(join(tmpdir(), 'od-cli-daemon-start-'));
const dataDir = join(root, 'data');
await mkdir(dataDir);
const child = spawn(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'daemon',
'start',
'--headless',
'--port',
'0',
],
{
cwd: daemonRoot,
env: {
...process.env,
OD_BIND_HOST: '127.0.0.1',
OD_DATA_DIR: dataDir,
},
},
);
try {
const line = await waitForStdoutLine(child, /\[od\] listening on (http:\/\/[^\s]+) \(headless\)/u);
const match = line.match(/(http:\/\/[^\s]+)/u);
const daemonUrl = match?.[1];
expect(daemonUrl).toBeTruthy();
const parsed = new URL(daemonUrl!);
expect(Number(parsed.port)).toBeGreaterThan(0);
const healthResp = await fetch(`${daemonUrl}/api/health`);
expect(healthResp.status).toBe(200);
const statusResp = await fetch(`${daemonUrl}/api/daemon/status`);
expect(statusResp.status).toBe(200);
const status = await statusResp.json() as { bindHost: string; port: number };
expect(status.bindHost).toBe('127.0.0.1');
expect(status.port).toBe(Number(parsed.port));
expect(child.exitCode).toBeNull();
} finally {
await terminateChild(child);
await rm(root, { recursive: true, force: true });
}
});
it('reconciles a durable running message after a real daemon process restart', { timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'od-cli-daemon-restart-'));
const dataDir = join(root, 'data');
await mkdir(dataDir);
const port = await findFreePort();
const env = {
...process.env,
OD_BIND_HOST: '127.0.0.1',
OD_DATA_DIR: dataDir,
POSTHOG_KEY: '',
POSTHOG_HOST: '',
OPEN_DESIGN_VELA_TELEMETRY: 'off',
OPEN_DESIGN_TELEMETRY_RELAY_URL: '',
LANGFUSE_PUBLIC_KEY: '',
LANGFUSE_SECRET_KEY: '',
};
const args = [
'--import', 'tsx', cliEntry,
'daemon', 'start', '--headless', '--port', String(port),
];
const first = spawn(process.execPath, args, { cwd: daemonRoot, env });
const runId = 'run-real-process-restart';
const messageId = 'message-real-process-restart';
const conversationId = 'conversation-real-process-restart';
const projectId = 'project-real-process-restart';
const runDir = join(dataDir, 'runs', runId);
const statePath = join(runDir, 'state.json');
try {
await waitForStdoutLine(first, /\[od\] listening on (http:\/\/[^\s]+)/u);
const db = new Database(join(dataDir, 'app.sqlite'));
try {
const now = Date.now();
db.exec('PRAGMA foreign_keys = ON');
db.prepare(
`INSERT INTO projects (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
).run(projectId, 'restart fixture', now, now);
db.prepare(
`INSERT INTO conversations (id, project_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
).run(conversationId, projectId, 'restart fixture', now, now);
db.prepare(
`INSERT INTO messages
(id, conversation_id, role, content, run_id, run_status,
events_json, position, created_at, started_at)
VALUES (?, ?, 'assistant', '', ?, 'running', '[]', 0, ?, ?)`,
).run(messageId, conversationId, runId, now, now);
} finally {
db.close();
}
await mkdir(runDir, { recursive: true });
await writeFile(statePath, `${JSON.stringify({
schemaVersion: 1,
id: runId,
projectId,
conversationId,
assistantMessageId: messageId,
agentId: 'claude',
status: 'running',
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
analyticsRecovery: {
context: {},
properties: { project_id: projectId, conversation_id: conversationId, run_id: runId },
insertId: 'restart-fixture-created',
},
})}\n`);
// SIGKILL models the process-loss case; graceful SIGTERM would run the
// normal shutdown path and would not exercise boot reconciliation.
first.kill('SIGKILL');
await waitForExit(first);
const second = spawn(process.execPath, args, { cwd: daemonRoot, env });
try {
const line = await waitForStdoutLine(second, /\[od\] listening on (http:\/\/[^\s]+)/u);
expect(line).toContain(`127.0.0.1:${port}`);
await waitFor(() => {
const state = JSON.parse(readFileSync(statePath, 'utf8')) as {
status?: string;
analyticsRecovery?: { completedAt?: number };
};
const checkDb = new Database(join(dataDir, 'app.sqlite'), { readonly: true });
try {
const row = checkDb.prepare(`SELECT run_status AS status FROM messages WHERE id = ?`).get(messageId) as { status?: string } | undefined;
return state.status === 'failed'
&& row?.status === 'failed'
&& typeof state.analyticsRecovery?.completedAt === 'number';
} finally {
checkDb.close();
}
});
const recoveredState = JSON.parse(await readFile(statePath, 'utf8')) as {
status: string;
errorCode?: string;
terminalRecoveryReason?: string;
analyticsRecovery?: { completedAt?: number };
};
expect(recoveredState).toMatchObject({
status: 'failed',
errorCode: 'DAEMON_RESTARTED',
terminalRecoveryReason: 'daemon_restart',
analyticsRecovery: { completedAt: expect.any(Number) },
});
const checkpoint = recoveredState.analyticsRecovery?.completedAt;
await terminateChild(second);
const reconciliationSentinelId = 'run-third-boot-reconciliation-sentinel';
const reconciliationSentinelDir = join(dataDir, 'runs', reconciliationSentinelId);
const reconciliationSentinelPath = join(reconciliationSentinelDir, 'state.json');
await mkdir(reconciliationSentinelDir, { recursive: true });
await writeFile(reconciliationSentinelPath, `${JSON.stringify({
schemaVersion: 1,
id: reconciliationSentinelId,
projectId: null,
conversationId: null,
assistantMessageId: null,
agentId: null,
status: 'running',
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
langfuseCompletedAt: Date.now(),
})}\n`);
const third = spawn(process.execPath, args, { cwd: daemonRoot, env });
try {
await Promise.all([
waitForStdoutLine(third, /\[od\] listening on (http:\/\/[^\s]+)/u),
waitForStdoutLine(third, /\[runs\] reconciled interrupted run terminals/u),
]);
const replayedState = JSON.parse(await readFile(statePath, 'utf8')) as {
status?: string;
analyticsRecovery?: { completedAt?: number };
};
const sentinelState = JSON.parse(await readFile(reconciliationSentinelPath, 'utf8')) as {
status?: string;
};
expect(sentinelState.status).toBe('failed');
expect(replayedState.status).toBe('failed');
expect(replayedState.analyticsRecovery?.completedAt).toBe(checkpoint);
} finally {
await terminateChild(third);
}
} finally {
await terminateChild(second);
}
} finally {
await terminateChild(first);
await rm(root, { recursive: true, force: true });
}
});
it('does not import daemon startup code for media client commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'od-cli-media-'));
const dataDir = join(root, 'data');
await mkdir(dataDir);
await chmod(dataDir, 0o500);
try {
await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'media',
'generate',
'--project',
'repro',
'--surface',
'image',
'--model',
'gpt-image-2',
'--prompt',
'test',
'--daemon-url',
'http://127.0.0.1:59999',
],
{
cwd: daemonRoot,
env: {
...process.env,
OD_DATA_DIR: dataDir,
},
},
);
throw new Error('media command unexpectedly succeeded');
} catch (error: unknown) {
const failed = error as { code?: number; stderr?: string };
const stderr = failed.stderr ?? '';
expect(failed.code).toBe(3);
expect(JSON.parse(stderr)).toEqual({
error: {
code: 'MEDIA_DISPATCHER_UNREACHABLE',
message: 'local media dispatcher could not be reached',
nextStep: 'retry-later',
},
});
expect(stderr).not.toContain('OD_DATA_DIR');
expect(stderr).not.toContain('127.0.0.1');
} finally {
await chmod(dataDir, 0o700).catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
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) => {
seen.push({
url: req.url,
authorization: req.headers.authorization,
});
req.resume();
if (req.url === '/api/tools/media/generate') {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({
error: {
code: 'MEDIA_EXECUTION_DISABLED',
message: 'media generation is disabled for this run',
},
}));
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected fallback' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const daemonUrl = `http://127.0.0.1:${port}`;
try {
await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'media',
'generate',
'--project',
'project-1',
'--surface',
'image',
'--model',
'gpt-image-2',
'--prompt',
'test',
'--daemon-url',
daemonUrl,
],
{
cwd: daemonRoot,
env: {
...process.env,
OD_TOOL_TOKEN: 'run-token',
},
},
);
throw new Error('media command unexpectedly succeeded');
} catch (error: unknown) {
const failed = error as { code?: number; stderr?: string };
expect(failed.code).toBe(4);
expect(failed.stderr ?? '').toContain('MEDIA_EXECUTION_DISABLED');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
expect(seen).toEqual([
{
url: '/api/tools/media/generate',
authorization: 'Bearer run-token',
},
]);
});
it('prints AMR status JSON from the daemon status endpoint without wallet fallback', async () => {
const seen: Array<{ method: string | undefined; url: string | undefined }> = [];
const server = http.createServer((req, res) => {
seen.push({ method: req.method, url: req.url });
req.resume();
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
loggedIn: true,
profile: 'local',
user: { email: 'amr@example.com' },
account: { plan: 'plus', balanceUsd: '9.5000' },
configPath: '/Users/test/.amr/config.json',
}));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const daemonUrl = `http://127.0.0.1:${port}`;
try {
const result = await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'amr',
'status',
'--daemon-url',
daemonUrl,
'--json',
],
{
cwd: daemonRoot,
env: { ...process.env },
},
);
expect(JSON.parse(result.stdout)).toMatchObject({
loggedIn: true,
profile: 'local',
user: { email: 'amr@example.com' },
account: { plan: 'plus', balanceUsd: '9.5000' },
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
expect(seen).toEqual([
{ method: 'GET', url: '/api/integrations/vela/status' },
]);
});
it('prints AMR wallet fallback status JSON when daemon status has no account balance', async () => {
const seen: Array<{ method: string | undefined; url: string | undefined }> = [];
const server = http.createServer((req, res) => {
seen.push({ method: req.method, url: req.url });
req.resume();
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
loggedIn: true,
profile: 'local',
user: { email: 'amr@example.com' },
configPath: '/Users/test/.amr/config.json',
}));
return;
}
if (req.method === 'GET' && req.url === '/api/integrations/vela/wallet?refresh=1') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
status: 'available',
profile: 'local',
user: { email: 'amr@example.com' },
balanceUsd: '0.1000',
updatedAt: '2026-06-23T06:05:18.782Z',
fetchedAt: '2026-06-23T06:05:19.000Z',
stale: false,
source: 'vela_api',
}));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const daemonUrl = `http://127.0.0.1:${port}`;
try {
const result = await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'amr',
'status',
'--daemon-url',
daemonUrl,
'--refresh',
'--json',
],
{
cwd: daemonRoot,
env: { ...process.env },
},
);
expect(JSON.parse(result.stdout)).toMatchObject({
loggedIn: true,
user: { email: 'amr@example.com' },
account: { balanceUsd: '0.1000' },
wallet: {
status: 'available',
balanceUsd: '0.1000',
source: 'vela_api',
},
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
expect(seen).toEqual([
{ method: 'GET', url: '/api/integrations/vela/status' },
{ method: 'GET', url: '/api/integrations/vela/wallet?refresh=1' },
]);
});
it('prints AMR text status with fallback balance and no fabricated plan', async () => {
const server = http.createServer((req, res) => {
req.resume();
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
loggedIn: true,
profile: 'local',
user: { email: 'amr@example.com' },
configPath: '/Users/test/.amr/config.json',
}));
return;
}
if (req.method === 'GET' && req.url === '/api/integrations/vela/wallet') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
status: 'available',
profile: 'local',
user: { email: 'amr@example.com' },
balanceUsd: '0.1000',
updatedAt: '2026-06-23T06:05:18.782Z',
fetchedAt: '2026-06-23T06:05:19.000Z',
stale: false,
source: 'vela_api',
}));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const daemonUrl = `http://127.0.0.1:${port}`;
try {
const result = await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'amr',
'status',
'--daemon-url',
daemonUrl,
],
{
cwd: daemonRoot,
env: { ...process.env },
},
);
expect(result.stdout).toContain('AMR account\tamr@example.com');
expect(result.stdout).toContain('Profile\tlocal');
expect(result.stdout).toContain('Wallet balance\t$0.1000');
expect(result.stdout).toContain('Source\tvela_api');
expect(result.stdout).not.toContain('Plan\t');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
function waitForStdoutLine(
child: ChildProcessWithoutNullStreams,
pattern: RegExp,
timeoutMs = 15_000,
): Promise<string> {
return new Promise((resolve, reject) => {
let output = '';
const timer = setTimeout(() => {
cleanup();
reject(new Error(`timed out waiting for stdout ${pattern}; output:\n${output}`));
}, timeoutMs);
const onData = (chunk: Buffer) => {
output += chunk.toString('utf8');
const line = output.split(/\r?\n/u).find((candidate) => pattern.test(candidate));
if (line) {
cleanup();
resolve(line);
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
reject(new Error(`child exited before stdout matched ${pattern}: code=${code} signal=${signal}; output:\n${output}`));
};
const cleanup = () => {
clearTimeout(timer);
child.stdout.off('data', onData);
child.stderr.off('data', onData);
child.off('error', onError);
child.off('exit', onExit);
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.on('error', onError);
child.on('exit', onExit);
});
}
async function findFreePort(): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
await new Promise<void>((resolve) => server.close(() => resolve()));
if (!port) throw new Error('failed to allocate a free TCP port');
return port;
}
async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('timed out waiting for daemon restart reconciliation');
}
async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
}
async function terminateChild(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolve) => {
child.once('exit', () => resolve());
});
child.kill('SIGTERM');
const timeout = new Promise<void>((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve();
}, 5_000);
timer.unref?.();
});
await Promise.race([exited, timeout]);
}