diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 328e714..080692e 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -1,7 +1,14 @@ import minimist from "minimist"; import { detectClients as realDetectClients } from "./detect.js"; import { executePrompt as realExecutePrompt } from "./execute.js"; -import { startJob as realStartJob } from "./jobs.js"; +import { + startJob as realStartJob, + getJob as realGetJob, + getJobResult as realGetJobResult, + cancelJob as realCancelJob, + listJobs as realListJobs, + cleanupJobs as realCleanupJobs, +} from "./jobs.js"; import { resolveClient as realResolveClient } from "./dispatch.js"; import { resolveConfig as realResolveConfig } from "./config.js"; import { CLIENT_NAMES } from "./constants.js"; @@ -11,6 +18,7 @@ import { type ExecResult, type DebugInfo, type Job, + type JobStatus, ClientNotFoundError, } from "./types.js"; @@ -33,6 +41,29 @@ export interface CliDeps { resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName; timeout?: number }; stdoutWrite?: (chunk: string) => void; stderrWrite?: (chunk: string) => void; + getJob?: (jobId: string) => Job; + getJobResult?: (jobId: string) => ExecResult; + cancelJob?: (jobId: string) => void; + listJobs?: (options?: { filter?: JobStatus }) => Job[]; + cleanupJobs?: (options?: { maxAgeMs?: number }) => void; +} + +function parseMaxAge(value: string): number | null { + const match = value.match(/^(\d+(?:\.\d+)?)\s*([hmsd]?)$/i); + if (!match) return null; + const num = parseFloat(match[1]); + if (!Number.isFinite(num) || num < 0) return null; + const unit = match[2].toLowerCase(); + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + "": 60 * 60 * 1000, + }; + const multiplier = multipliers[unit]; + if (multiplier === undefined) return null; + return num * multiplier; } function printHelp(): void { @@ -58,13 +89,18 @@ export async function main( const resolveConfig = deps.resolveConfig ?? realResolveConfig; const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c)); const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.write(c)); + const getJob = deps.getJob ?? realGetJob; + const getJobResult = deps.getJobResult ?? realGetJobResult; + const cancelJob = deps.cancelJob ?? realCancelJob; + const listJobs = deps.listJobs ?? realListJobs; + const cleanupJobs = deps.cleanupJobs ?? realCleanupJobs; const rawArgs = argv.slice(2); const parseArgs = rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; const args = minimist(parseArgs, { - string: ["client", "prompt", "timeout"], + string: ["client", "prompt", "timeout", "max-age"], boolean: ["json", "text", "help", "debug", "sync"], alias: { h: "help" }, }); @@ -265,6 +301,227 @@ export async function main( } } + if (command === "start") { + const client = args.client as ClientName | undefined; + const prompt = args.prompt as string | undefined; + + if (!client || !CLIENT_NAMES.includes(client)) { + const message = !client + ? "--client is required" + : `Unknown client: ${client}`; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + if (!prompt) { + const message = "--prompt is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + const config = resolveConfig(); + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; + const timeoutMs = + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; + + try { + const job = await startJob(client, prompt, { + timeoutMs, + debug, + onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined, + }); + if (jsonMode) { + console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2)); + } else { + console.log(`Job ${job.id} started (${job.client}): ${job.status}`); + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + + if (command === "status") { + const jobId = args._[1]; + if (!jobId) { + const message = "job-id is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + try { + const job = getJob(jobId); + if (jsonMode) { + console.log(JSON.stringify(job, null, 2)); + } else { + console.log(`Job ${jobId}: ${job.status}`); + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + + if (command === "results") { + const jobId = args._[1]; + if (!jobId) { + const message = "job-id is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + try { + const result = getJobResult(jobId); + if (jsonMode) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.stdout) stdoutWrite(result.stdout); + if (result.stderr) stderrWrite(result.stderr); + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + + if (command === "cancel") { + const jobId = args._[1]; + if (!jobId) { + const message = "job-id is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + try { + const job = getJob(jobId); + if (job.status !== "running") { + const message = `Job is not running (status: ${job.status})`; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + cancelJob(jobId); + if (jsonMode) { + console.log(JSON.stringify({ jobId, cancelled: true }, null, 2)); + } else { + console.log(`Job ${jobId} cancelled`); + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + + if (command === "list-jobs") { + try { + const filter = args.status as JobStatus | undefined; + const jobs = listJobs({ filter }); + if (jsonMode) { + console.log(JSON.stringify(jobs, null, 2)); + } else { + for (const job of jobs) { + console.log(`${job.id} (${job.client}): ${job.status}`); + } + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + + if (command === "cleanup-jobs") { + const maxAgeRaw = args["max-age"] as string | undefined; + let maxAgeMs: number | undefined; + if (maxAgeRaw !== undefined) { + const parsed = parseMaxAge(maxAgeRaw); + if (parsed === null) { + const message = "Invalid --max-age format. Use: [h|m|s|d], e.g. 24h"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + maxAgeMs = parsed; + } + + try { + const jobsBefore = listJobs(); + cleanupJobs({ maxAgeMs }); + const jobsAfter = listJobs(); + const count = jobsBefore.length - jobsAfter.length; + if (jsonMode) { + console.log(JSON.stringify({ count }, null, 2)); + } else { + console.log(`Cleaned ${count} jobs`); + } + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; + } + } + const message = `Unknown command: ${command}`; if (jsonMode) { console.error(JSON.stringify({ error: message }, null, 2)); diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 4c9ec39..69e2286 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -888,4 +888,544 @@ describe("main", () => { out.restore(); } }); + + // Job lifecycle subcommands (S-302) + + it("start starts job and returns job ID JSON", async () => { + const out = captureOutput(); + try { + const started: { client: ClientName; prompt: string; options?: any }[] = []; + const code = await main( + ["node", "cli.ts", "start", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async (client, prompt, options) => { + started.push({ client, prompt, options }); + return { id: "job-start-1", client, prompt, status: "running", startedAt: new Date().toISOString() }; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0].client, "codex"); + assert.strictEqual(started[0].prompt, "hello"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-start-1"); + assert.strictEqual(parsed.client, "codex"); + assert.strictEqual(parsed.status, "running"); + } finally { + out.restore(); + } + }); + + it("start returns 1 when client is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "start", "--prompt", "hello"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("client")); + } finally { + out.restore(); + } + }); + + it("start returns 1 when prompt is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "start", "--client", "codex"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("prompt")); + } finally { + out.restore(); + } + }); + + it("status returns running job JSON", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-123"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "codex", + prompt: "hello", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + } as any), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.id, "job-123"); + assert.strictEqual(parsed.status, "running"); + assert.strictEqual(parsed.client, "codex"); + } finally { + out.restore(); + } + }); + + it("status returns completed job JSON", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-456"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "claude", + prompt: "write tests", + status: "completed", + startedAt: "2024-01-01T00:00:00Z", + completedAt: "2024-01-01T00:01:00Z", + result: { stdout: "ok", stderr: "", exitCode: 0, client: "claude", durationMs: 100 }, + } as any), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.status, "completed"); + assert.strictEqual(parsed.result.exitCode, 0); + } finally { + out.restore(); + } + }); + + it("status returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "missing-job"], + { + detectClients: () => mockClients, + getJob: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("status returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "status"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("status prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-123", "--text"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "codex", + prompt: "hello", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + } as any), + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("running")); + assert.ok(out.logs[0].includes("job-123")); + } finally { + out.restore(); + } + }); + + it("results returns ExecResult JSON for completed job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "job-789"], + { + detectClients: () => mockClients, + getJobResult: () => ({ + stdout: "output", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 42, + }), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.stdout, "output"); + assert.strictEqual(parsed.exitCode, 0); + } finally { + out.restore(); + } + }); + + it("results returns 1 and prints error JSON for still-running job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "job-running"], + { + detectClients: () => mockClients, + getJobResult: () => { + throw new Error('Job "job-running" result is not available (status: running).'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not available")); + } finally { + out.restore(); + } + }); + + it("results returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "missing-job"], + { + detectClients: () => mockClients, + getJobResult: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("results returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "results"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("results prints text output with --text", async () => { + const out = captureOutput(); + const stdoutChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "results", "job-789", "--text"], + { + detectClients: () => mockClients, + getJobResult: () => ({ + stdout: "hello-out", + stderr: "hello-err", + exitCode: 0, + client: "codex", + durationMs: 42, + }), + stdoutWrite: (chunk) => stdoutChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stdoutChunks.join(""), "hello-out"); + } finally { + out.restore(); + } + }); + + it("cancel returns cancelled confirmation for running job", async () => { + const out = captureOutput(); + try { + let cancelledJobId: string | undefined; + const code = await main( + ["node", "cli.ts", "cancel", "job-abc"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "codex", + prompt: "hello", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + } as any), + cancelJob: (jobId) => { + cancelledJobId = jobId; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(cancelledJobId, "job-abc"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-abc"); + assert.strictEqual(parsed.cancelled, true); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 and prints error JSON for completed job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "job-done"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "codex", + prompt: "hello", + status: "completed", + startedAt: "2024-01-01T00:00:00Z", + completedAt: "2024-01-01T00:01:00Z", + } as any), + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("running")); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "missing-job"], + { + detectClients: () => mockClients, + getJob: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "cancel"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("cancel prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "job-abc", "--text"], + { + detectClients: () => mockClients, + getJob: (jobId) => ({ + id: jobId, + client: "codex", + prompt: "hello", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + } as any), + cancelJob: () => {}, + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("cancelled")); + assert.ok(out.logs[0].includes("job-abc")); + } finally { + out.restore(); + } + }); + + it("list-jobs returns array of all jobs", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "list-jobs"], + { + detectClients: () => mockClients, + listJobs: () => [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + { id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" }, + ] as any[], + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.length, 2); + assert.strictEqual(parsed[0].id, "job-1"); + assert.strictEqual(parsed[1].id, "job-2"); + } finally { + out.restore(); + } + }); + + it("list-jobs filters by --status", async () => { + const out = captureOutput(); + try { + let receivedFilter: string | undefined; + const code = await main( + ["node", "cli.ts", "list-jobs", "--status", "running"], + { + detectClients: () => mockClients, + listJobs: (options) => { + receivedFilter = options?.filter; + return [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + ] as any[]; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedFilter, "running"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].status, "running"); + } finally { + out.restore(); + } + }); + + it("list-jobs prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "list-jobs", "--text"], + { + detectClients: () => mockClients, + listJobs: () => [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + ] as any[], + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("job-1")); + assert.ok(out.logs[0].includes("codex")); + assert.ok(out.logs[0].includes("running")); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs returns count of cleaned jobs", async () => { + const out = captureOutput(); + try { + let callCount = 0; + const jobs = [ + { id: "job-1", client: "codex", prompt: "p1", status: "completed", startedAt: "2024-01-01T00:00:00Z" }, + { id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" }, + ]; + const code = await main( + ["node", "cli.ts", "cleanup-jobs"], + { + detectClients: () => mockClients, + listJobs: () => { + callCount++; + return callCount === 1 ? jobs : []; + }, + cleanupJobs: () => {}, + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.count, 2); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs with --max-age parses hours", async () => { + const out = captureOutput(); + try { + let receivedMaxAgeMs: number | undefined; + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--max-age", "12h"], + { + detectClients: () => mockClients, + listJobs: () => [], + cleanupJobs: (options) => { + receivedMaxAgeMs = options?.maxAgeMs; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedMaxAgeMs, 12 * 60 * 60 * 1000); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs returns 1 for invalid --max-age format", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--max-age", "abc"], + { + detectClients: () => mockClients, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("max-age")); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--text"], + { + detectClients: () => mockClients, + listJobs: () => [], + cleanupJobs: () => {}, + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("Cleaned")); + assert.ok(out.logs[0].includes("0")); + } finally { + out.restore(); + } + }); });