Compare commits
34 Commits
e523b34d1b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bea1c590d | |||
| 33c898ff9a | |||
| 017eb1b410 | |||
| afac143cb3 | |||
| edb6611b74 | |||
| 7b886a7b33 | |||
| 48bef5cc7c | |||
| e6f2908624 | |||
| 601f7cce89 | |||
| 6655e2e1e8 | |||
| bd88df7dd2 | |||
| 591829369c | |||
| a2c2b8bf6d | |||
| 51f978db4c | |||
| d061244121 | |||
| 4fe99b8c57 | |||
| 816374cef8 | |||
| 62840b908e | |||
| e11c36b7d8 | |||
| e7b01612c8 | |||
| 9c7d9cbaee | |||
| 3b9ed0cc38 | |||
| aa860a6afd | |||
| abf7726071 | |||
| 21c13562a7 | |||
| bcddb42608 | |||
| 5b78889b09 | |||
| 1983dd82e7 | |||
| 106c7d6425 | |||
| 94389df6f1 | |||
| 32964bf994 | |||
| dc3fe8d6eb | |||
| 5375c83c77 | |||
| 476dd317b3 |
+224
-22
@@ -9,8 +9,10 @@ Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode)
|
||||
- dispatch a prompt to a specific client by name
|
||||
- auto-resolve the best client from prompt keywords
|
||||
- forward arguments natively to each client
|
||||
- run tasks asynchronously as background jobs with lifecycle management
|
||||
- run tasks synchronously when blocking until completion is desired
|
||||
|
||||
The tool is a lightweight sync-only dispatcher. It does not implement streaming, chat sessions, or ACP orchestration. For ACP-based harnesses, see `docs/openclaw-acp-orchestration.md`.
|
||||
The tool supports both async (default) and sync execution modes. Async jobs run as detached background processes and are tracked on disk. For ACP-based harnesses, see `docs/openclaw-acp-orchestration.md`.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -27,8 +29,14 @@ The dispatcher itself requires only Node.js 20+ and `npm`. The actual AI CLI cli
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch list [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch status <job-id> [--json|--text]
|
||||
ai-cli-dispatch results <job-id> [--json|--text]
|
||||
ai-cli-dispatch cancel <job-id> [--json|--text]
|
||||
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
|
||||
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
|
||||
ai-cli-dispatch --help
|
||||
```
|
||||
|
||||
@@ -71,25 +79,30 @@ ai-cli-dispatch list --text
|
||||
|
||||
### `run`
|
||||
|
||||
Execute a prompt directly through a named client.
|
||||
Execute a prompt directly through a named client. By default, this starts a background job and returns immediately.
|
||||
|
||||
```bash
|
||||
# Async (default) — returns a job ID
|
||||
ai-cli-dispatch run --client codex --prompt "refactor this function"
|
||||
ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
|
||||
ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
|
||||
|
||||
# Sync — blocks until the client finishes
|
||||
ai-cli-dispatch run --client claude --prompt "add tests for auth middleware" --sync
|
||||
|
||||
# With custom timeout and debug diagnostics
|
||||
ai-cli-dispatch run --client opencode --prompt "migrate to ESM" --timeout 600000 --debug
|
||||
```
|
||||
|
||||
The prompt is forwarded with each client’s native argument shape:
|
||||
|
||||
| Client | Arguments passed |
|
||||
|---|---|
|
||||
| `codex` | `exec "<prompt>"` |
|
||||
| `claude` | `-p "<prompt>"` |
|
||||
| `opencode` | `"<prompt>"` |
|
||||
| `codex` | `exec --yolo "<prompt>"` |
|
||||
| `claude` | `-p "<prompt>" --dangerously-skip-permissions` |
|
||||
| `opencode` | `run --dangerously-skip-permissions "<prompt>"` |
|
||||
|
||||
### `dispatch`
|
||||
|
||||
Auto-resolve the client from prompt keywords, then execute.
|
||||
Auto-resolve the client from prompt keywords, then execute. Defaults to async; use `--sync` to block.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch dispatch "use claude to write tests"
|
||||
@@ -112,6 +125,106 @@ Override auto-resolution explicitly:
|
||||
ai-cli-dispatch dispatch "fix the bug" --client claude
|
||||
```
|
||||
|
||||
### `start`
|
||||
|
||||
Explicitly start a background job (same as `run` without `--sync`). Useful when you want the async behavior unambiguously.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch start --client codex --prompt "refactor this function"
|
||||
```
|
||||
|
||||
### `status`
|
||||
|
||||
Check the status of a background job.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch status <job-id>
|
||||
```
|
||||
|
||||
JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"client": "codex",
|
||||
"prompt": "refactor this function",
|
||||
"status": "running",
|
||||
"startedAt": "2026-05-19T12:34:56.789Z",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
Statuses: `running`, `completed`, `failed`, `timed_out`, `cancelled`.
|
||||
|
||||
### `results`
|
||||
|
||||
Retrieve the execution result of a completed job.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch results <job-id>
|
||||
```
|
||||
|
||||
JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"exitCode": 0,
|
||||
"client": "codex",
|
||||
"durationMs": 45231
|
||||
}
|
||||
```
|
||||
|
||||
Requires status `completed`. For `failed` or `timed_out` jobs, use `status` to see the captured error.
|
||||
|
||||
### `cancel`
|
||||
|
||||
Cancel a running job.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch cancel <job-id>
|
||||
```
|
||||
|
||||
### `list-jobs`
|
||||
|
||||
List all tracked jobs, newest first.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch list-jobs --json
|
||||
ai-cli-dispatch list-jobs --status running --json
|
||||
```
|
||||
|
||||
### `cleanup-jobs`
|
||||
|
||||
Remove job files older than a threshold. Default unit is hours.
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch cleanup-jobs --max-age 24h
|
||||
ai-cli-dispatch cleanup-jobs --max-age 30m
|
||||
```
|
||||
|
||||
## Async vs Sync Mode
|
||||
|
||||
By default, `run` and `dispatch` are **async**: they start a detached background process, persist a job record to disk, and return a job ID immediately. This is ideal for:
|
||||
|
||||
- Fire-and-forget tasks that may run for minutes
|
||||
- Long-running codegen or migration tasks
|
||||
- Scenarios where the caller should not block
|
||||
|
||||
Use `--sync` when you need:
|
||||
|
||||
- The complete output before the next step
|
||||
- Synchronous composition in shell pipelines or scripts
|
||||
- Immediate error propagation to the calling process
|
||||
|
||||
| Aspect | Async (default) | Sync (`--sync`) |
|
||||
|---|---|---|
|
||||
| Return value | Job ID + status | Full stdout/stderr + exit code |
|
||||
| Process model | Detached child, parent exits immediately | Attached child, parent waits |
|
||||
| Persistence | Job file written to disk | No job file |
|
||||
| Timeout | Enforced via `child.kill()` after `--timeout` | Enforced via `child.kill()` after `--timeout` |
|
||||
|
||||
## Client Discovery
|
||||
|
||||
Discovery searches `PATH` in this order for each client name:
|
||||
@@ -138,7 +251,8 @@ Example:
|
||||
"codex": "/usr/local/bin/codex",
|
||||
"claude": "/opt/homebrew/bin/claude"
|
||||
},
|
||||
"defaultClient": "claude"
|
||||
"defaultClient": "claude",
|
||||
"timeout": 300000
|
||||
}
|
||||
```
|
||||
|
||||
@@ -162,17 +276,54 @@ Supported env vars:
|
||||
|
||||
Default output is JSON. Use `--text` to stream raw `stdout`/`stderr` directly.
|
||||
|
||||
JSON success shape (`run` and `dispatch`):
|
||||
### Sync JSON success shape (`run --sync`, `dispatch --sync`)
|
||||
|
||||
```json
|
||||
{
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"exitCode": 0
|
||||
"exitCode": 0,
|
||||
"client": "codex",
|
||||
"durationMs": 45231
|
||||
}
|
||||
```
|
||||
|
||||
JSON error shape:
|
||||
### Async JSON success shape (`run`, `dispatch`, `start`)
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"client": "codex",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### Job status shape (`status`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"client": "codex",
|
||||
"prompt": "refactor this function",
|
||||
"status": "running",
|
||||
"startedAt": "2026-05-19T12:34:56.789Z",
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
### Job result shape (`results`)
|
||||
|
||||
```json
|
||||
{
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"exitCode": 0,
|
||||
"client": "codex",
|
||||
"durationMs": 45231
|
||||
}
|
||||
```
|
||||
|
||||
### JSON error shape
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -185,7 +336,7 @@ Exit codes:
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success |
|
||||
| `1` | Missing/unknown command, missing argument, unknown client, resolution failure, or execution error |
|
||||
| `1` | Missing/unknown command, missing argument, unknown client, resolution failure, execution error, or job lifecycle error |
|
||||
|
||||
## Error Handling Guidance
|
||||
|
||||
@@ -205,11 +356,11 @@ Meaning: the prompt string was empty or whitespace-only.
|
||||
|
||||
Action: supply a non-empty `--prompt` or positional prompt argument.
|
||||
|
||||
### `Execution timed out after 300000ms`
|
||||
### `Execution timed out after 600000ms`
|
||||
|
||||
Meaning: the client subprocess did not finish within the default 5-minute timeout.
|
||||
Meaning: the client subprocess did not finish within the timeout.
|
||||
|
||||
Action: the client may be waiting for interactive input or the task is too large. Break the prompt into smaller pieces, or run the client directly to diagnose.
|
||||
Action: the client may be waiting for interactive input or the task is too large. Break the prompt into smaller pieces, increase `--timeout`, or run the client directly to diagnose. Async jobs that time out are recorded with status `timed_out`.
|
||||
|
||||
### `Could not resolve client from prompt`
|
||||
|
||||
@@ -217,6 +368,49 @@ Meaning: `dispatch` found no matching keyword and no `defaultClient` is configur
|
||||
|
||||
Action: include a client name in the prompt (e.g., `"use claude to ..."`) or set `defaultClient` in config.
|
||||
|
||||
### `Job "<job-id>" not found`
|
||||
|
||||
Meaning: the requested job ID does not exist in the job store.
|
||||
|
||||
Action: verify the job ID. Job files are stored under `~/.openclaw/ai-cli-dispatch/jobs/`. If the directory was cleaned or the host restarted, old jobs may have been removed.
|
||||
|
||||
### `Job "<job-id>" result is not available (status: <status>)`
|
||||
|
||||
Meaning: `results` was called on a job that has not finished (`running`) or finished unsuccessfully (`failed`, `timed_out`, `cancelled`).
|
||||
|
||||
Action: poll `status` until the job reaches `completed`, or inspect `status` output for the error field.
|
||||
|
||||
## Job Lifecycle Workflows
|
||||
|
||||
### Fire-and-forget
|
||||
|
||||
```bash
|
||||
JOB=$(ai-cli-dispatch run --client codex --prompt "refactor auth" --json | jq -r '.jobId')
|
||||
# caller continues immediately
|
||||
```
|
||||
|
||||
### Poll until completion
|
||||
|
||||
```bash
|
||||
JOB=$(ai-cli-dispatch start --client claude --prompt "write tests" --json | jq -r '.jobId')
|
||||
while [ "$(ai-cli-dispatch status "$JOB" --json | jq -r '.status')" = "running" ]; do
|
||||
sleep 5
|
||||
done
|
||||
ai-cli-dispatch results "$JOB" --json
|
||||
```
|
||||
|
||||
### Sync one-shot
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch run --client opencode --prompt "fix lint" --sync --text
|
||||
```
|
||||
|
||||
### Batch cleanup
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch cleanup-jobs --max-age 24h
|
||||
```
|
||||
|
||||
## Common Flows
|
||||
|
||||
### Check what is installed
|
||||
@@ -225,12 +419,18 @@ Action: include a client name in the prompt (e.g., `"use claude to ..."`) or set
|
||||
ai-cli-dispatch list --json
|
||||
```
|
||||
|
||||
### Run a quick task through a specific client
|
||||
### Run a quick task through a specific client (async)
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch run --client codex --prompt "fix lint errors in src/app.ts"
|
||||
```
|
||||
|
||||
### Run a quick task synchronously
|
||||
|
||||
```bash
|
||||
ai-cli-dispatch run --client codex --prompt "fix lint errors in src/app.ts" --sync
|
||||
```
|
||||
|
||||
### Let the tool pick the client from the prompt
|
||||
|
||||
```bash
|
||||
@@ -245,14 +445,16 @@ ai-cli-dispatch dispatch "review this PR" --client claude
|
||||
|
||||
## Coexistence with ACP
|
||||
|
||||
`ai-cli-dispatch` is a direct subprocess dispatcher. It runs the client binary synchronously and returns its output. It is not an ACP agent and does not participate in ACP orchestration.
|
||||
`ai-cli-dispatch` is a direct subprocess dispatcher. It is not an ACP agent and does not participate in ACP orchestration.
|
||||
|
||||
- Use `ai-cli-dispatch` when you need a quick, local, one-shot CLI execution.
|
||||
- Use `ai-cli-dispatch` when you need a quick, local, one-shot CLI execution or a background job.
|
||||
- Use ACP (`docs/openclaw-acp-orchestration.md`) when you need session-bound coding harnesses with thread context, multi-turn review, or orchestrator-managed verification gates.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dispatcher is TypeScript/Node.js with a single external dependency (`minimist`).
|
||||
- Client arguments are hardcoded per tool to match each client’s stable CLI contract.
|
||||
- The default timeout is 5 minutes (`300_000` ms).
|
||||
- The default timeout is 10 minutes (`600_000` ms); override with `--timeout` or config.
|
||||
- On Windows, discovery uses `where` instead of `which` and `.exe` extensions are assumed.
|
||||
- Async jobs run as detached processes with `stdio: ["ignore", "pipe", "pipe"]` so the dispatcher can exit without waiting.
|
||||
- Job files are written atomically to `~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json`.
|
||||
|
||||
+119
-35
@@ -6,30 +6,36 @@ This document describes the internal design of `ai-cli-dispatch`, the module bre
|
||||
|
||||
```text
|
||||
src/
|
||||
├── cli.ts — Entry point: argument parsing, command routing, I/O formatting
|
||||
├── types.ts — Shared types and error classes
|
||||
├── constants.ts — Client name registry and platform helpers
|
||||
├── config.ts — Layered configuration resolution (flags → env → file → PATH)
|
||||
├── detect.ts — Client discovery: binary lookup and version extraction
|
||||
├── dispatch.ts — Prompt-to-client resolution (explicit flag → keywords → default)
|
||||
└── execute.ts — Subprocess spawning, stdout/stderr capture, timeout handling
|
||||
├── cli.ts — Entry point: argument parsing, command routing, I/O formatting
|
||||
├── cli-helpers.ts — Shared formatting, sync/async run handlers, error reporters
|
||||
├── types.ts — Shared types and error classes
|
||||
├── constants.ts — Client name registry and platform helpers
|
||||
├── config.ts — Layered configuration resolution (flags → env → file → PATH)
|
||||
├── detect.ts — Client discovery: binary lookup and version extraction
|
||||
├── dispatch.ts — Prompt-to-client resolution (explicit flag → keywords → default)
|
||||
├── execute.ts — Synchronous subprocess spawning, stdout/stderr capture, timeout handling
|
||||
└── jobs.ts — Async job lifecycle: detached spawn, disk-backed state, polling API
|
||||
```
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `cli.ts` | Parses `argv` with `minimist`, routes to `list` / `run` / `dispatch`, prints JSON or text output, and controls the process exit code. |
|
||||
| `types.ts` | Defines `ClientName`, `ClientInfo`, `ExecResult`, `ToolConfig`, and the error hierarchy (`ClientNotFoundError`, `ExecError`). |
|
||||
| `cli.ts` | Parses `argv` with `minimist`, routes to all commands, prints JSON or text output, and controls the process exit code. |
|
||||
| `cli-helpers.ts` | Shared helpers for `reportError`, `reportCliError`, `handleSyncRun`, and `handleAsyncRun` to keep `cli.ts` focused on routing. |
|
||||
| `types.ts` | Defines `ClientName`, `ClientInfo`, `ExecResult`, `ToolConfig`, `Job`, `JobRecord`, `JobStatus`, and the error hierarchy (`ClientNotFoundError`, `ExecError`, `JobNotFoundError`, `JobResultUnavailableError`). |
|
||||
| `constants.ts` | Holds the canonical `CLIENT_NAMES` array and `isWindows()` helper used by discovery and config. |
|
||||
| `config.ts` | Resolves per-client binary paths and the optional `defaultClient` from four layered sources. |
|
||||
| `detect.ts` | Locates each client binary on `PATH`, falls back to a manual directory scan, and invokes `--version` to extract a semver string. |
|
||||
| `dispatch.ts` | Chooses the target client from a prompt string using ordered keyword matching, with overrides for explicit `--client` and `defaultClient`. |
|
||||
| `execute.ts` | Spawns the chosen client with its native argument shape, buffers `stdout`/`stderr`, enforces a timeout, and returns an `ExecResult` or throws a typed error. |
|
||||
| `jobs.ts` | Manages background jobs: writes job records to disk, spawns detached child processes, tracks running children in memory, and provides `status`, `results`, `cancel`, `list`, and `cleanup` operations. |
|
||||
|
||||
## Data Flow
|
||||
|
||||
A typical `dispatch` invocation flows through four stages:
|
||||
### Synchronous dispatch (`run --sync`, `dispatch --sync`)
|
||||
|
||||
A sync invocation flows through four stages:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
@@ -41,6 +47,30 @@ A typical `dispatch` invocation flows through four stages:
|
||||
--version fallback default timeout / exitCode
|
||||
```
|
||||
|
||||
### Asynchronous dispatch (`run`, `dispatch`, `start`)
|
||||
|
||||
An async invocation adds the `jobs.ts` stage. The caller receives a job ID immediately; the child process continues in the background.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ detect │ ──► │ config │ ──► │ dispatch │ ──► │ execute │ ──► │ jobs │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │ │ │
|
||||
which/where flags/env/file keyword scan spawn child write job file
|
||||
PATH walk defaultClient --client override capture output detached + unref
|
||||
--version fallback default timeout / exitCode update on close
|
||||
```
|
||||
|
||||
Later, lifecycle commands read from or modify the job store:
|
||||
|
||||
```
|
||||
status <jobId> ──► readJobFile ──► return Job (sans stdout/stderr)
|
||||
results <jobId> ──► readJobFile ──► return ExecResult (completed only)
|
||||
cancel <jobId> ──► readJobFile ──► kill child or PID ──► write cancelled status
|
||||
list-jobs ──► readdir jobDir ──► read each file ──► sort + filter
|
||||
cleanup-jobs ──► readdir jobDir ──► stat mtime ──► unlink old files
|
||||
```
|
||||
|
||||
### 1. Detect
|
||||
|
||||
`detectClients()` iterates over `CLIENT_NAMES` and attempts to locate each binary:
|
||||
@@ -55,9 +85,9 @@ Result: an array of `ClientInfo` objects with `name`, `found`, `path`, and `vers
|
||||
|
||||
`resolveConfig()` builds a `ResolvedConfig` by layering sources (highest to lowest precedence):
|
||||
|
||||
1. **CLI flags** — `--codex-path`, `--claude-path`, `--opencode-path`, `--default-client`
|
||||
1. **CLI flags** — `--codex-path`, `--claude-path`, `--opencode-path`, `--default-client`, `--timeout`
|
||||
2. **Environment variables** — `AI_CLI_CODEX_PATH`, `AI_CLI_CLAUDE_PATH`, `AI_CLI_OPENCODE_PATH`, `AI_CLI_DEFAULT_CLIENT`
|
||||
3. **Config file** — `~/.openclaw/ai-cli-dispatch.json` (`paths` and `defaultClient` keys)
|
||||
3. **Config file** — `~/.openclaw/ai-cli-dispatch.json` (`paths`, `defaultClient`, `timeout` keys)
|
||||
4. **PATH discovery** — `which`/`where` fallback via `defaultWhichSync()`
|
||||
|
||||
Only values for the three known `ClientName` entries are accepted; unknown `defaultClient` values are ignored.
|
||||
@@ -78,30 +108,88 @@ This ordering intentionally prioritizes `"open code"` before `"opencode"` so the
|
||||
|
||||
### 4. Execute
|
||||
|
||||
`executePrompt(client, prompt, options)` runs the selected client:
|
||||
`executePrompt(client, prompt, options)` runs the selected client synchronously:
|
||||
|
||||
1. Reject empty or whitespace-only prompts with `ExecError`.
|
||||
2. Validate that an explicit `clientPath` exists on disk (if provided).
|
||||
3. Map the client to its native argument array via `CLIENT_ARGS`:
|
||||
- `codex` → `["exec", prompt]`
|
||||
- `claude` → `["-p", prompt]`
|
||||
- `opencode` → `[prompt]`
|
||||
- `codex` → `["exec", "--yolo", prompt]`
|
||||
- `claude` → `["-p", prompt, "--dangerously-skip-permissions"]`
|
||||
- `opencode` → `["run", "--dangerously-skip-permissions", prompt]`
|
||||
4. `spawn()` the process with `shell: false`.
|
||||
5. Buffer `stdout` and `stderr` via `"data"` listeners.
|
||||
6. Start a `setTimeout`; if it fires, `child.kill()` is sent.
|
||||
7. On `close`, resolve with `{ stdout, stderr, exitCode }`.
|
||||
7. On `close`, resolve with `{ stdout, stderr, exitCode, client, durationMs }`.
|
||||
8. On `error`, reject with `ClientNotFoundError` for `ENOENT` or `ExecError` for anything else.
|
||||
9. On timeout, reject with `ExecError` containing the buffered output so far.
|
||||
10. If `debug` is enabled, emit a `DebugInfo` object via `onDebug`.
|
||||
|
||||
The default timeout is **5 minutes** (`300_000` ms).
|
||||
The default timeout is **10 minutes** (`600_000` ms).
|
||||
|
||||
### 5. Jobs
|
||||
|
||||
`startJob(client, prompt, options)` launches a background job:
|
||||
|
||||
1. Generate a UUID for the job ID.
|
||||
2. Build the client argument array via `CLIENT_ARGS`.
|
||||
3. `spawn()` the process with `detached: true` and `stdio: ["ignore", "pipe", "pipe"]`.
|
||||
4. Write an initial `JobRecord` to `~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json` with status `running`.
|
||||
5. Update the record with the child `pid` once available.
|
||||
6. Register the child in an in-memory `runningChildren` Map for cancellation and timeout tracking.
|
||||
7. Buffer `stdout`/`stderr` via `"data"` listeners.
|
||||
8. On `close`, finalize the record: write status (`completed`, `failed`, `timed_out`, or `cancelled`), capture stdout/stderr, and record `durationMs`.
|
||||
9. Call `child.unref()` so the dispatcher process can exit without waiting for the child.
|
||||
|
||||
`getJob(jobId)` reads the job file and returns a `Job` (omitting the full stdout/stderr buffers).
|
||||
|
||||
`getJobResult(jobId)` returns the `ExecResult` for a completed job.
|
||||
|
||||
`cancelJob(jobId)` looks up the running child in memory, sends `SIGTERM`, and writes a `cancelled` status. If the child is no longer in memory, it attempts `process.kill(pid, "SIGTERM")` as a fallback.
|
||||
|
||||
`listJobs({ filter })` reads all `.json` files in the job directory, parses them, sorts by `startedAt` descending, and optionally filters by status.
|
||||
|
||||
`cleanupJobs({ maxAgeMs })` deletes job files whose `mtime` exceeds the threshold. Default max age is 24 hours.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Async-First Architecture
|
||||
|
||||
The default execution mode is **async** (background job). Synchronous execution requires an explicit `--sync` flag.
|
||||
|
||||
**Rationale:**
|
||||
- **Primary use case alignment:** Most AI CLI tasks (refactoring, test generation, migration) run for multiple minutes. Blocking the caller for that long is often undesirable in automation and orchestration contexts.
|
||||
- **Resilience:** A detached background job survives an unexpected dispatcher exit. The caller can reconnect later via `status` and `results`.
|
||||
- **Batching:** Multiple jobs can be started in parallel without blocking the dispatcher process.
|
||||
- **Backward compatibility path:** `--sync` preserves the original one-shot behavior for callers that need it, without changing the default.
|
||||
|
||||
### Disk-Backed Job Store
|
||||
|
||||
Job state is persisted as JSON files on disk rather than kept solely in memory.
|
||||
|
||||
**Rationale:**
|
||||
- **Durability across restarts:** If the dispatcher process crashes or the host reboots, job files remain. A caller can still query `status` or `results` after recovery.
|
||||
- **No memory leaks:** Long-running or forgotten jobs do not accumulate in heap. Cleanup is explicit via `cleanup-jobs`.
|
||||
- **External observability:** Operators can inspect `~/.openclaw/ai-cli-dispatch/jobs/` directly without calling the CLI.
|
||||
- **Simplicity:** A file-per-job model avoids the need for an embedded database or external service. It maps cleanly to the Node.js `fs` API and is trivial to mock in tests.
|
||||
|
||||
**Trade-off:** High-frequency job creation could strain the filesystem, but the expected volume is low (tens to hundreds of jobs, not thousands per second).
|
||||
|
||||
### Detached-Process Approach
|
||||
|
||||
Async jobs use `detached: true` with `child.unref()`.
|
||||
|
||||
**Rationale:**
|
||||
- **Parent independence:** The dispatcher can start a job and exit immediately. This is essential for CLI usage where the user or orchestrator should not hold a shell open for the duration of the task.
|
||||
- **Signal isolation:** A detached process group means the child does not receive `SIGINT` or `SIGHUP` sent to the dispatcher terminal session.
|
||||
- **PID tracking:** Even though the child is detached, the `pid` is captured and written to the job file. This enables `cancelJob` to send signals even if the dispatcher has restarted and lost its in-memory `runningChildren` map.
|
||||
|
||||
**Trade-off:** The child is truly independent. If the host reboots, the child is lost (same as any other process). The job file will eventually reflect `timed_out` or remain `running` until `cancel` or `cleanup` is run.
|
||||
|
||||
### Coexistence with ACP
|
||||
|
||||
`ai-cli-dispatch` is intentionally **not** an ACP agent. It is a thin, local subprocess wrapper with no session state, no thread binding, and no orchestrator protocol.
|
||||
|
||||
- Use `ai-cli-dispatch` when you need a quick, one-shot CLI execution on the gateway host.
|
||||
- Use `ai-cli-dispatch` when you need a quick, one-shot CLI execution or a background job on the gateway host.
|
||||
- Use ACP (`docs/openclaw-acp-orchestration.md`) when you need session-bound coding harnesses, multi-turn review, or orchestrator-managed verification gates.
|
||||
|
||||
This separation keeps the dispatcher small and avoids duplicating ACP’s scheduling, context persistence, and review-loop responsibilities.
|
||||
@@ -118,17 +206,6 @@ Client resolution uses deterministic substring matching instead of natural-langu
|
||||
|
||||
The trade-off is that prompts like `"compare codex and claude"` resolve to `codex` because `"codex"` is checked first. Users can always override with `--client`.
|
||||
|
||||
### Sync-Only Initial Release
|
||||
|
||||
The current implementation is entirely synchronous from the caller’s perspective: `executePrompt` returns a promise that resolves only when the child process exits or the timeout fires.
|
||||
|
||||
**Rationale:**
|
||||
- The primary use case is one-shot tasks (refactor, add tests, migrate) where the agent needs the complete output before proceeding.
|
||||
- Streaming would require a different output contract (callbacks, generators, or an event emitter) and complicates the JSON error model.
|
||||
- ACP already covers interactive, streaming, and session-based use cases.
|
||||
|
||||
Streaming is an intentional future extension point (see below).
|
||||
|
||||
### Error Taxonomy
|
||||
|
||||
All runtime failures are represented as typed errors so callers and tests can branch precisely:
|
||||
@@ -136,7 +213,9 @@ All runtime failures are represented as typed errors so callers and tests can br
|
||||
| Error | When thrown | Data carried |
|
||||
|---|---|---|
|
||||
| `ClientNotFoundError` | Binary not on `PATH`, explicit `clientPath` missing, or `ENOENT` from `spawn` | `message` with client name |
|
||||
| `ExecError` | Empty prompt, unknown client, timeout, non-`ENOENT` spawn error, or child exit | `message` + full `ExecResult` (`stdout`, `stderr`, `exitCode`) |
|
||||
| `ExecError` | Empty prompt, unknown client, timeout, non-`ENOENT` spawn error, or child exit | `message` + full `ExecResult` (`stdout`, `stderr`, `exitCode`, `client`, `durationMs`) |
|
||||
| `JobNotFoundError` | Job ID not found in the job store | `message` with job ID |
|
||||
| `JobResultUnavailableError` | `results` called on a non-completed job | `message` with job ID and current status |
|
||||
|
||||
`ExecError` carries the `ExecResult` so that timeout handlers still return partial output. This avoids losing buffered stdout/stderr when a long-running task is killed.
|
||||
|
||||
@@ -151,7 +230,7 @@ Every non-trivial module accepts an `options` bag with injectable dependencies (
|
||||
|
||||
### Minimal Dependency Surface
|
||||
|
||||
The runtime dependency graph contains exactly one external package: `minimist` (argument parsing). Everything else uses Node.js built-ins (`child_process`, `fs`, `os`, `path`).
|
||||
The runtime dependency graph contains exactly one external package: `minimist` (argument parsing). Everything else uses Node.js built-ins (`child_process`, `fs`, `os`, `path`, `crypto`).
|
||||
|
||||
**Rationale:**
|
||||
- Reduces supply-chain risk and install time.
|
||||
@@ -169,7 +248,8 @@ To support a fourth (or fifth) AI CLI client, change four files in `src/` and th
|
||||
3. **`src/execute.ts`** — Add an entry to `CLIENT_ARGS` with the client’s native argument shape.
|
||||
4. **`src/config.ts`** — No change required; the existing loop over `CLIENT_NAMES` automatically picks up the new env/flag/file keys.
|
||||
5. **`src/dispatch.ts`** — Add a keyword check for the new client in `resolveClient`. Decide its precedence relative to existing keywords.
|
||||
6. **Tests** — Add colocated test cases in `tests/dispatch.test.ts`, `tests/execute.test.ts`, and `tests/detect.test.ts`.
|
||||
6. **`src/jobs.ts`** — No change required; `CLIENT_ARGS` is already shared.
|
||||
7. **Tests** — Add colocated test cases in `tests/dispatch.test.ts`, `tests/execute.test.ts`, `tests/detect.test.ts`, and `tests/jobs.test.ts`.
|
||||
|
||||
No changes are needed in `cli.ts` because it iterates over `CLIENT_NAMES` for validation.
|
||||
|
||||
@@ -194,6 +274,8 @@ When `onData` is provided, `executePrompt` would:
|
||||
|
||||
This preserves backward compatibility: existing callers that omit `onData` receive the exact same buffered `ExecResult` they get today.
|
||||
|
||||
For async jobs, `jobs.ts` could store a partial `stdout`/`stderr` in the job file on each chunk (or at a throttled interval) so `status` callers can see progress without waiting for completion.
|
||||
|
||||
### Platform Backends
|
||||
|
||||
The current Windows support is limited to discovery (`where` instead of `which`, `.exe` extension assumptions). If future clients require platform-specific spawn options (e.g., PowerShell quoting rules), the extension point is `CLIENT_ARGS` or a new `CLIENT_SPAWN_OPTIONS` record keyed by `ClientName`.
|
||||
@@ -204,10 +286,12 @@ The test suite in `tests/` mirrors the `src/` structure:
|
||||
|
||||
| Test file | Coverage |
|
||||
|---|---|
|
||||
| `cli.test.ts` | Argument parsing, command routing, JSON/text output modes, exit codes, error formatting |
|
||||
| `cli.test.ts` | Argument parsing, command routing, JSON/text output modes, exit codes, error formatting, sync vs async branches, all job lifecycle commands |
|
||||
| `cli-helpers.test.ts` | `reportError`, `reportCliError`, `handleSyncRun`, `handleAsyncRun` with JSON and text modes |
|
||||
| `config.test.ts` | Layered precedence of flags, env, file, and `which` fallback; malformed JSON tolerance |
|
||||
| `detect.test.ts` | `which` success/failure, PATH directory fallback, version parsing, missing binary handling |
|
||||
| `dispatch.test.ts` | Keyword matching, case insensitivity, `--client` precedence, `defaultClient` fallback, invalid flag handling |
|
||||
| `execute.test.ts` | Successful execution, stderr capture, non-zero exit codes, `ENOENT` → `ClientNotFoundError`, timeout, empty prompt rejection, special-character preservation |
|
||||
| `execute.test.ts` | Successful execution, stderr capture, non-zero exit codes, `ENOENT` → `ClientNotFoundError`, timeout, empty prompt rejection, special-character preservation, debug info emission |
|
||||
| `jobs.test.ts` | Job start, status query, result retrieval, cancellation, listing, cleanup, timeout handling, unknown client fallback, detached process behavior, in-memory vs on-disk consistency |
|
||||
|
||||
All tests use injected mocks; no test spawns real client binaries or reads the real filesystem.
|
||||
|
||||
@@ -26,14 +26,98 @@ npm install
|
||||
|
||||
## Commands
|
||||
|
||||
### `list`
|
||||
|
||||
Discover and report all supported clients.
|
||||
|
||||
```bash
|
||||
scripts/ai-cli-dispatch list --json
|
||||
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
|
||||
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
|
||||
scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM"
|
||||
scripts/ai-cli-dispatch list --text
|
||||
```
|
||||
|
||||
Use `--json` for machine-readable command output.
|
||||
### `start` — start a background job
|
||||
|
||||
Starts a detached background job and returns immediately with a job ID.
|
||||
|
||||
```bash
|
||||
scripts/ai-cli-dispatch start --client codex --prompt "refactor this function"
|
||||
scripts/ai-cli-dispatch start --client claude --prompt "add tests for auth middleware"
|
||||
```
|
||||
|
||||
### `run` — run a task (async by default)
|
||||
|
||||
Runs a prompt through a named client. By default this is **async**: it starts a background job and returns the job ID immediately. Use `--sync` to block until the client finishes and return the full result.
|
||||
|
||||
```bash
|
||||
# Async (default) — returns job ID immediately
|
||||
scripts/ai-cli-dispatch run --client codex --prompt "refactor this function"
|
||||
scripts/ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
|
||||
scripts/ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
|
||||
|
||||
# Sync — blocks until completion and returns stdout/stderr/exitCode
|
||||
scripts/ai-cli-dispatch run --client codex --prompt "fix lint errors" --sync
|
||||
```
|
||||
|
||||
### `dispatch` — auto-resolve client and run (async by default)
|
||||
|
||||
Auto-resolves the client from prompt keywords, then executes. By default this is **async**. Use `--sync` to block until completion.
|
||||
|
||||
```bash
|
||||
# Async (default)
|
||||
scripts/ai-cli-dispatch dispatch "use codex to write tests"
|
||||
scripts/ai-cli-dispatch dispatch "claude: add unit tests for utils.ts"
|
||||
scripts/ai-cli-dispatch dispatch "opencode migrate to ESM"
|
||||
|
||||
# Sync
|
||||
scripts/ai-cli-dispatch dispatch "review this PR" --client claude --sync
|
||||
```
|
||||
|
||||
### Job lifecycle commands
|
||||
|
||||
After starting an async job, manage it with these subcommands:
|
||||
|
||||
```bash
|
||||
# Check job status
|
||||
scripts/ai-cli-dispatch status <job-id>
|
||||
|
||||
# Get results (only when status is completed)
|
||||
scripts/ai-cli-dispatch results <job-id>
|
||||
|
||||
# Cancel a running job
|
||||
scripts/ai-cli-dispatch cancel <job-id>
|
||||
|
||||
# List all jobs, newest first
|
||||
scripts/ai-cli-dispatch list-jobs --json
|
||||
scripts/ai-cli-dispatch list-jobs --status running --json
|
||||
|
||||
# Clean up old job files
|
||||
scripts/ai-cli-dispatch cleanup-jobs --max-age 24h
|
||||
```
|
||||
|
||||
## Async vs Sync Mode
|
||||
|
||||
The dispatcher is **async-first**: `run` and `dispatch` start a detached background job unless you pass `--sync`.
|
||||
|
||||
| Mode | Behavior | When to use |
|
||||
|---|---|---|
|
||||
| **Async** (default) | Starts a detached process, returns a `jobId` immediately, and stores output on disk. | Fire-and-forget tasks, long-running jobs, parallel dispatches, or when you need to poll/check results later. |
|
||||
| **Sync** (`--sync`) | Blocks until the client subprocess exits, then returns `stdout`, `stderr`, and `exitCode` directly. | Short, interactive tasks where you need the result in the same turn. |
|
||||
|
||||
Use `--timeout <ms>` to control how long a job may run before it is terminated (default: 10 minutes / 600,000 ms for both async and sync). Use `--debug` to emit diagnostic metadata to stderr.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--sync` | Run synchronously and block until the client returns. Default is async (starts a background job). |
|
||||
| `--timeout <ms>` | Timeout in milliseconds. Overrides the default and any config value. |
|
||||
| `--debug` | Emit diagnostic JSON to stderr (command, args, PID, duration, exit signal). |
|
||||
| `--json` | Output JSON (default). |
|
||||
| `--text` | Output plain text instead of JSON. |
|
||||
| `--client <name>` | Explicitly set the client (`codex`, `claude`, `opencode`). |
|
||||
| `--prompt <text>` | The prompt to send to the client. |
|
||||
| `--status <status>` | Filter `list-jobs` by status (`running`, `completed`, `failed`). |
|
||||
| `--max-age <number>[h\|m\|s\|d]` | Maximum age for `cleanup-jobs` (default unit: hours). |
|
||||
|
||||
## Client Discovery
|
||||
|
||||
@@ -45,6 +129,73 @@ The skill searches for the following clients in order:
|
||||
|
||||
Run `list` to see which clients are installed and their resolved versions.
|
||||
|
||||
## Job Lifecycle & Storage
|
||||
|
||||
Async jobs run as detached child processes. Each job writes a record to disk at:
|
||||
|
||||
```text
|
||||
~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json
|
||||
```
|
||||
|
||||
A job moves through the following statuses:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `running` | The client subprocess is active. |
|
||||
| `completed` | The subprocess exited with code 0. |
|
||||
| `failed` | The subprocess exited with a non-zero code. |
|
||||
| `timed_out` | The job exceeded `--timeout` and was terminated. |
|
||||
| `cancelled` | The job was cancelled via `cancel <job-id>`. |
|
||||
|
||||
Example async workflow:
|
||||
|
||||
```bash
|
||||
# 1. Start a job
|
||||
scripts/ai-cli-dispatch run --client codex --prompt "refactor auth module"
|
||||
# → { "jobId": "a1b2c3d4...", "client": "codex", "status": "running" }
|
||||
|
||||
# 2. Poll status
|
||||
scripts/ai-cli-dispatch status a1b2c3d4...
|
||||
# → { "id": "a1b2c3d4...", "status": "running", ... }
|
||||
|
||||
# 3. Get results when done
|
||||
scripts/ai-cli-dispatch results a1b2c3d4...
|
||||
# → { "stdout": "...", "stderr": "...", "exitCode": 0, "client": "codex", "durationMs": 42000 }
|
||||
```
|
||||
|
||||
## Background Jobs (Programmatic API)
|
||||
|
||||
For long-running or fire-and-forget tasks, use the programmatic job API:
|
||||
|
||||
```typescript
|
||||
import { startJob, getJob, getJobResult, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js";
|
||||
|
||||
// Start a detached job
|
||||
const job = await startJob("codex", "refactor auth module", { timeoutMs: 300_000 });
|
||||
console.log(job.id); // e.g. "a1b2c3d4..."
|
||||
console.log(job.status); // "running"
|
||||
|
||||
// Poll for completion
|
||||
const latest = getJob(job.id);
|
||||
console.log(latest.status); // "running" | "completed" | "failed" | "timed_out" | "cancelled"
|
||||
|
||||
// Get result (throws if not completed)
|
||||
const result = getJobResult(job.id);
|
||||
console.log(result.stdout, result.exitCode);
|
||||
|
||||
// Cancel a running job
|
||||
cancelJob(job.id);
|
||||
|
||||
// List all jobs (newest first)
|
||||
const jobs = listJobs(); // Job[]
|
||||
const running = listJobs({ filter: "running" });
|
||||
|
||||
// Clean up job files older than 24 hours (default)
|
||||
cleanupJobs({ maxAgeMs: 24 * 60 * 60 * 1000 });
|
||||
```
|
||||
|
||||
Job files include stdout, stderr, exit code, timing, and error state.
|
||||
|
||||
## Output Rules
|
||||
|
||||
- Normal JSON output redacts local file paths and credential metadata.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ClientName, ExecResult, DebugInfo, Job } from "./types.js";
|
||||
|
||||
export interface RunContext {
|
||||
jsonMode: boolean;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
stderrWrite: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export function reportError(err: unknown, jsonMode: boolean): number {
|
||||
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;
|
||||
}
|
||||
|
||||
export function reportCliError(message: string, jsonMode: boolean): number {
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function handleSyncRun(
|
||||
executePrompt: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<ExecResult>,
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
timeoutMs: number | undefined,
|
||||
debug: boolean,
|
||||
ctx: RunContext
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await executePrompt(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (ctx.jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.stdout) ctx.stdoutWrite(result.stdout);
|
||||
if (result.stderr) ctx.stderrWrite(result.stderr);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, ctx.jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAsyncRun(
|
||||
startJob: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<Job>,
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
timeoutMs: number | undefined,
|
||||
debug: boolean,
|
||||
ctx: RunContext
|
||||
): Promise<number> {
|
||||
try {
|
||||
const job = await startJob(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (ctx.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) {
|
||||
return reportError(err, ctx.jsonMode);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import minimist from "minimist";
|
||||
import { detectClients as realDetectClients } from "./detect.js";
|
||||
import { executePrompt as realExecutePrompt } from "./execute.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";
|
||||
@@ -8,22 +16,60 @@ import {
|
||||
type ClientName,
|
||||
type ClientInfo,
|
||||
type ExecResult,
|
||||
type DebugInfo,
|
||||
type Job,
|
||||
type JobStatus,
|
||||
ClientNotFoundError,
|
||||
} from "./types.js";
|
||||
import {
|
||||
reportError,
|
||||
reportCliError,
|
||||
handleSyncRun,
|
||||
handleAsyncRun,
|
||||
} from "./cli-helpers.js";
|
||||
|
||||
export interface CliDeps {
|
||||
detectClients?: () => ClientInfo[];
|
||||
executePrompt?: (
|
||||
client: ClientName,
|
||||
prompt: string
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<ExecResult>;
|
||||
startJob?: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<Job>;
|
||||
resolveClient?: (
|
||||
prompt: string,
|
||||
config?: { client?: ClientName; defaultClient?: ClientName }
|
||||
) => ClientName | null;
|
||||
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName };
|
||||
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; 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<string, number> = {
|
||||
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 {
|
||||
@@ -31,10 +77,25 @@ function printHelp(): void {
|
||||
|
||||
Usage:
|
||||
ai-cli-dispatch list [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch status <job-id> [--json|--text]
|
||||
ai-cli-dispatch results <job-id> [--json|--text]
|
||||
ai-cli-dispatch cancel <job-id> [--json|--text]
|
||||
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
|
||||
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
|
||||
ai-cli-dispatch --help
|
||||
|
||||
Flags:
|
||||
--sync Run synchronously and block until the client returns (default is async)
|
||||
--timeout Timeout in milliseconds (or override via config)
|
||||
--debug Emit diagnostic JSON to stderr
|
||||
--max-age Maximum age for cleanup (default unit: hours, e.g. 24h or 30m)
|
||||
--status Filter jobs by status (running, completed, failed)
|
||||
--json Output JSON (default)
|
||||
--text Output plain text
|
||||
|
||||
Clients: codex, claude, opencode`);
|
||||
}
|
||||
|
||||
@@ -44,22 +105,29 @@ export async function main(
|
||||
): Promise<number> {
|
||||
const detectClients = deps.detectClients ?? realDetectClients;
|
||||
const executePrompt = deps.executePrompt ?? realExecutePrompt;
|
||||
const startJob = deps.startJob ?? realStartJob;
|
||||
const resolveClient = deps.resolveClient ?? realResolveClient;
|
||||
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"],
|
||||
boolean: ["json", "text", "help", "debug"],
|
||||
string: ["client", "prompt", "timeout", "max-age"],
|
||||
boolean: ["json", "text", "help", "debug", "sync"],
|
||||
alias: { h: "help" },
|
||||
});
|
||||
|
||||
const jsonMode = !args.text;
|
||||
const debug = !!args.debug;
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
@@ -93,45 +161,27 @@ export async function main(
|
||||
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;
|
||||
return reportCliError(
|
||||
!client ? "--client is required" : `Unknown client: ${client}`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
const message = "--prompt is required";
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError("--prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executePrompt(client, prompt);
|
||||
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;
|
||||
const config = resolveConfig();
|
||||
const parsedTimeout =
|
||||
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
||||
const timeoutMs =
|
||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||
|
||||
const ctx = { jsonMode, stdoutWrite, stderrWrite };
|
||||
if (args.sync) {
|
||||
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
|
||||
if (command === "dispatch") {
|
||||
@@ -141,13 +191,7 @@ export async function main(
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
const message = "prompt is required";
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError("prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
@@ -158,17 +202,86 @@ export async function main(
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
const message = "Could not resolve client from prompt";
|
||||
return reportCliError("Could not resolve client from prompt", jsonMode);
|
||||
}
|
||||
|
||||
const parsedTimeout =
|
||||
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
||||
const timeoutMs =
|
||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||
|
||||
const ctx = { jsonMode, stdoutWrite, stderrWrite };
|
||||
if (args.sync) {
|
||||
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
|
||||
if (command === "start") {
|
||||
const client = args.client as ClientName | undefined;
|
||||
const prompt = args.prompt as string | undefined;
|
||||
|
||||
if (!client || !CLIENT_NAMES.includes(client)) {
|
||||
return reportCliError(
|
||||
!client ? "--client is required" : `Unknown client: ${client}`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return reportCliError("--prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
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.error(JSON.stringify({ error: message }, null, 2));
|
||||
console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
console.log(`Job ${job.id} started (${job.client}): ${job.status}`);
|
||||
}
|
||||
return 1;
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executePrompt(client, prompt);
|
||||
const job = getJob(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(job, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${jobId}: ${job.status}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "results") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = getJobResult(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
@@ -177,23 +290,84 @@ export async function main(
|
||||
}
|
||||
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;
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
const message = `Unknown command: ${command}`;
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(message);
|
||||
if (command === "cancel") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const job = getJob(jobId);
|
||||
if (job.status !== "running") {
|
||||
return reportCliError(
|
||||
`Job is not running (status: ${job.status})`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
cancelJob(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ jobId, cancelled: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${jobId} cancelled`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return reportCliError(
|
||||
"Invalid --max-age format. Use: <number>[h|m|s|d], e.g. 24h",
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
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) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
return reportError(`Unknown command: ${command}`, jsonMode);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CLIENT_NAMES, isWindows } from "./constants.js";
|
||||
export interface ResolvedConfig {
|
||||
paths: Partial<Record<ClientName, string>>;
|
||||
defaultClient?: ClientName;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ResolveConfigOptions {
|
||||
@@ -89,5 +90,22 @@ export function resolveConfig(
|
||||
) {
|
||||
result.defaultClient = defaultClient as ClientName;
|
||||
}
|
||||
|
||||
const flagTimeout =
|
||||
typeof flags.timeout === "string" ? Number(flags.timeout) : undefined;
|
||||
const envTimeout =
|
||||
typeof env.AI_CLI_TIMEOUT === "string"
|
||||
? Number(env.AI_CLI_TIMEOUT)
|
||||
: undefined;
|
||||
const fileTimeout =
|
||||
typeof fileConfig.timeout === "number" ? fileConfig.timeout : undefined;
|
||||
|
||||
const resolvedTimeout =
|
||||
(Number.isFinite(flagTimeout) ? flagTimeout : undefined) ??
|
||||
(Number.isFinite(envTimeout) ? envTimeout : undefined) ??
|
||||
(Number.isFinite(fileTimeout) ? fileTimeout : undefined) ??
|
||||
600_000;
|
||||
|
||||
result.timeout = resolvedTimeout;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CLIENT_NAMES } from "./constants.js";
|
||||
import type { ClientName } from "./types.js";
|
||||
|
||||
export interface DispatchConfig {
|
||||
@@ -5,8 +6,6 @@ export interface DispatchConfig {
|
||||
client?: ClientName;
|
||||
}
|
||||
|
||||
const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"];
|
||||
|
||||
export function resolveClient(
|
||||
prompt: string,
|
||||
config?: DispatchConfig
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { spawn as defaultSpawn } from "node:child_process";
|
||||
import type { PathLike } from "node:fs";
|
||||
import { existsSync as defaultExistsSync } from "node:fs";
|
||||
import type { ClientName, ExecResult } from "./types.js";
|
||||
import type { ClientName, ExecResult, DebugInfo, ExecuteOptions } from "./types.js";
|
||||
import { ClientNotFoundError, ExecError } from "./types.js";
|
||||
|
||||
const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
|
||||
export const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
|
||||
codex: (p) => ["exec", "--yolo", p],
|
||||
claude: (p) => ["-p", p, "--dangerously-skip-permissions"],
|
||||
opencode: (p) => ["run", "--dangerously-skip-permissions", p],
|
||||
};
|
||||
|
||||
export interface ExecuteOptions {
|
||||
clientPath?: string;
|
||||
timeoutMs?: number;
|
||||
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
|
||||
existsSync?: (path: PathLike) => boolean;
|
||||
/**
|
||||
* Known stderr noise patterns per client.
|
||||
* When exit code is 0, lines matching these patterns are stripped from the
|
||||
* returned stderr to prevent agents from misinterpreting informational
|
||||
* diagnostics as errors. The raw (unfiltered) stderr is preserved in
|
||||
* DebugInfo.rawStderr when --debug is active.
|
||||
*/
|
||||
const STDERR_NOISE_PATTERNS: Partial<Record<ClientName, RegExp[]>> = {
|
||||
codex: [
|
||||
/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/,
|
||||
],
|
||||
};
|
||||
|
||||
function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string {
|
||||
if (exitCode !== 0) return stderr;
|
||||
const patterns = STDERR_NOISE_PATTERNS[client];
|
||||
if (!patterns) return stderr;
|
||||
const lines = stderr.split("\n");
|
||||
const filtered = lines.filter((line) => !patterns.some((p) => p.test(line)));
|
||||
return filtered.join("\n").replace(/\n+$/, "");
|
||||
}
|
||||
|
||||
export async function executePrompt(
|
||||
@@ -28,12 +41,14 @@ export async function executePrompt(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: -1,
|
||||
client,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const spawnImpl = options.spawn ?? defaultSpawn;
|
||||
const existsSyncImpl = options.existsSync ?? defaultExistsSync;
|
||||
const timeoutMs = options.timeoutMs ?? 300_000;
|
||||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
|
||||
const command = options.clientPath ?? client;
|
||||
if (options.clientPath && !existsSyncImpl(options.clientPath)) {
|
||||
@@ -46,6 +61,8 @@ export async function executePrompt(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: -1,
|
||||
client,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
const args = argBuilder(prompt);
|
||||
@@ -55,11 +72,17 @@ export async function executePrompt(
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitSignal: NodeJS.Signals | null = null;
|
||||
const startMs = Date.now();
|
||||
|
||||
const child = spawnImpl(command, args, {
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Close stdin immediately so clients like codex don't hang waiting for input
|
||||
child.stdin?.end();
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
@@ -80,8 +103,35 @@ export async function executePrompt(
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (err) reject(err);
|
||||
else resolve(result!);
|
||||
const durationMs = Date.now() - startMs;
|
||||
const rawStderr = stderr;
|
||||
const cleanedStderr = filterStderrNoise(client, rawStderr, result?.exitCode ?? -1);
|
||||
if (options.debug || options.onDebug) {
|
||||
const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null);
|
||||
const debugInfo: DebugInfo = {
|
||||
command,
|
||||
args,
|
||||
pid: child.pid ?? undefined,
|
||||
exitCode: effectiveExitCode,
|
||||
exitSignal,
|
||||
durationMs,
|
||||
stderrLength: rawStderr.length,
|
||||
stdoutLength: stdout.length,
|
||||
noisySuccess: effectiveExitCode === 0 && rawStderr.length > 0,
|
||||
rawStderr: rawStderr !== cleanedStderr ? rawStderr : undefined,
|
||||
};
|
||||
options.onDebug?.(debugInfo);
|
||||
}
|
||||
if (err) {
|
||||
if (err instanceof ExecError) {
|
||||
err.result.stderr = cleanedStderr;
|
||||
err.result.client = client;
|
||||
err.result.durationMs = durationMs;
|
||||
}
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ ...result!, client, durationMs, stderr: cleanedStderr });
|
||||
}
|
||||
}
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
@@ -89,22 +139,25 @@ export async function executePrompt(
|
||||
settle(new ClientNotFoundError(client));
|
||||
} else {
|
||||
settle(
|
||||
new ExecError(err.message, { stdout, stderr, exitCode: -1 })
|
||||
new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
exitSignal = signal;
|
||||
if (timedOut) {
|
||||
settle(
|
||||
new ExecError(`Execution timed out after ${timeoutMs}ms`, {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: -1,
|
||||
client,
|
||||
durationMs: 0,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
settle(undefined, { stdout, stderr, exitCode: code ?? -1 });
|
||||
settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Job watcher — a tiny self-contained process that monitors a detached
|
||||
* child and writes the final job record to disk.
|
||||
*
|
||||
* Invoked as: node --import tsx src/job-watcher.ts <jobFile> <command> <arg1> <arg2> ...
|
||||
*
|
||||
* The watcher is itself spawned as detached+unref'd by the CLI, so the CLI
|
||||
* can return the job ID immediately while this process stays alive to capture
|
||||
* the child's output and finalize the job file.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import type { JobRecord, ExecResult, JobStatus, ClientName } from "./types.js";
|
||||
|
||||
// Must import CLIENT_ARGS to know the client command mapping
|
||||
// And the noise filter for consistent stderr handling
|
||||
|
||||
/**
|
||||
* Known stderr noise patterns per client (duplicated from execute.ts to keep
|
||||
* the watcher self-contained with no runtime dependency on execute.ts).
|
||||
*/
|
||||
const STDERR_NOISE_PATTERNS: Partial<Record<ClientName, RegExp[]>> = {
|
||||
codex: [
|
||||
/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/,
|
||||
],
|
||||
};
|
||||
|
||||
function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string {
|
||||
if (exitCode !== 0) return stderr;
|
||||
const patterns = STDERR_NOISE_PATTERNS[client];
|
||||
if (!patterns) return stderr;
|
||||
const lines = stderr.split("\n");
|
||||
const filtered = lines.filter((line) => !patterns.some((p) => p.test(line)));
|
||||
return filtered.join("\n").replace(/\n+$/, "");
|
||||
}
|
||||
|
||||
const jobFile = process.argv[2];
|
||||
const command = process.argv[3];
|
||||
const childArgs = process.argv.slice(4);
|
||||
|
||||
if (!jobFile || !command) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let record: JobRecord;
|
||||
try {
|
||||
record = JSON.parse(readFileSync(jobFile, "utf-8")) as JobRecord;
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const timeoutMs = 600_000; // 10 min default
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const startMs = Date.now();
|
||||
|
||||
const child = spawn(command, childArgs, {
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Close stdin so clients like codex don't hang
|
||||
child.stdin?.end();
|
||||
|
||||
// Update pid in job file
|
||||
record.pid = child.pid ?? undefined;
|
||||
writeFileSync(jobFile, JSON.stringify(record, null, 2));
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
||||
}, timeoutMs);
|
||||
|
||||
function finalize(status: JobStatus, result?: ExecResult, error?: string) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
const completedAt = new Date().toISOString();
|
||||
const durationMs = Date.now() - startMs;
|
||||
const finalRecord: JobRecord = {
|
||||
...record,
|
||||
status,
|
||||
stdout,
|
||||
stderr: result ? filterStderrNoise(record.client, stderr, result.exitCode) : stderr,
|
||||
result: result ? { ...result, durationMs } : undefined,
|
||||
error,
|
||||
completedAt,
|
||||
};
|
||||
try {
|
||||
// Check if the job was cancelled while we were running
|
||||
const current = JSON.parse(readFileSync(jobFile, "utf-8")) as JobRecord;
|
||||
if (current.status === "cancelled") {
|
||||
return; // Don't overwrite a cancelled job
|
||||
}
|
||||
writeFileSync(jobFile, JSON.stringify(finalRecord, null, 2));
|
||||
} catch { /* best effort */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
finalize("failed", undefined, err.message);
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timedOut || signal) {
|
||||
finalize("timed_out", {
|
||||
stdout, stderr, exitCode: -1, client: record.client, durationMs: 0,
|
||||
});
|
||||
} else if (code !== null && code !== 0) {
|
||||
finalize("failed", {
|
||||
stdout, stderr, exitCode: code, client: record.client, durationMs: 0,
|
||||
});
|
||||
} else {
|
||||
finalize("completed", {
|
||||
stdout, stderr, exitCode: code ?? 0, client: record.client, durationMs: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { spawn as defaultSpawn } from "node:child_process";
|
||||
import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, readdirSync as defaultReaddirSync, statSync as defaultStatSync, unlinkSync as defaultUnlinkSync, writeFileSync as defaultWriteFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CLIENT_ARGS } from "./execute.js";
|
||||
import type { ClientName, Job, JobRecord, JobStartOptions, ExecResult, JobStatus } from "./types.js";
|
||||
import { JobNotFoundError, JobResultUnavailableError } from "./types.js";
|
||||
|
||||
export interface JobOperationsOptions {
|
||||
jobDir?: string;
|
||||
fs?: {
|
||||
mkdirSync: typeof defaultMkdirSync;
|
||||
writeFileSync: typeof defaultWriteFileSync;
|
||||
readFileSync: typeof defaultReadFileSync;
|
||||
readdirSync: typeof defaultReaddirSync;
|
||||
existsSync: typeof defaultExistsSync;
|
||||
statSync: typeof defaultStatSync;
|
||||
unlinkSync: typeof defaultUnlinkSync;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartJobOptions extends JobStartOptions, JobOperationsOptions {
|
||||
/** Override the watcher spawn (for testing). When provided, startJob calls
|
||||
* this instead of spawning `node --import tsx job-watcher.ts ...` */
|
||||
spawnWatcher?: (jobFilePath: string, command: string, args: string[]) => { pid?: number; unref?: () => void };
|
||||
}
|
||||
|
||||
const DEFAULT_JOB_DIR = `${process.env.HOME || process.env.USERPROFILE}/.openclaw/ai-cli-dispatch/jobs`;
|
||||
|
||||
function getJobDir(options?: { jobDir?: string }): string {
|
||||
return options?.jobDir ?? DEFAULT_JOB_DIR;
|
||||
}
|
||||
|
||||
function writeJobFile(
|
||||
jobDir: string,
|
||||
record: JobRecord,
|
||||
fs: NonNullable<JobOperationsOptions["fs"]>
|
||||
): void {
|
||||
fs.mkdirSync(jobDir, { recursive: true });
|
||||
fs.writeFileSync(`${jobDir}/${record.id}.json`, JSON.stringify(record, null, 2));
|
||||
}
|
||||
|
||||
function readJobFile(
|
||||
jobId: string,
|
||||
jobDir: string,
|
||||
fs: NonNullable<JobOperationsOptions["fs"]>
|
||||
): JobRecord {
|
||||
const path = `${jobDir}/${jobId}.json`;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path, "utf-8")) as JobRecord;
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new JobNotFoundError(jobId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startJob(
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options: StartJobOptions = {}
|
||||
): Promise<Job> {
|
||||
const jobId = randomUUID();
|
||||
const jobDir = getJobDir(options);
|
||||
const fs = options.fs ?? {
|
||||
mkdirSync: defaultMkdirSync,
|
||||
writeFileSync: defaultWriteFileSync,
|
||||
readFileSync: defaultReadFileSync,
|
||||
readdirSync: defaultReaddirSync,
|
||||
existsSync: defaultExistsSync,
|
||||
statSync: defaultStatSync,
|
||||
unlinkSync: defaultUnlinkSync,
|
||||
};
|
||||
const spawnImpl = options.spawn ?? defaultSpawn;
|
||||
|
||||
const argBuilder = (CLIENT_ARGS as Record<string, (prompt: string) => string[]>)[client];
|
||||
if (!argBuilder) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const errRecord: JobRecord = {
|
||||
id: jobId,
|
||||
client,
|
||||
prompt,
|
||||
status: "failed",
|
||||
startedAt,
|
||||
completedAt: startedAt,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: `Unknown client: ${client}`,
|
||||
};
|
||||
writeJobFile(jobDir, errRecord, fs);
|
||||
return { id: jobId, client, prompt, status: "failed", startedAt, error: errRecord.error };
|
||||
}
|
||||
|
||||
const clientArgs = argBuilder(prompt);
|
||||
const command = options.clientPath ?? client;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const record: JobRecord = {
|
||||
id: jobId,
|
||||
client,
|
||||
prompt,
|
||||
status: "running",
|
||||
startedAt,
|
||||
pid: undefined,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
writeJobFile(jobDir, record, fs);
|
||||
|
||||
// Spawn a companion watcher process that outlives this CLI invocation.
|
||||
// The watcher monitors the actual client (codex/claude/opencode), captures
|
||||
// stdout/stderr, and writes the final job record to disk on exit.
|
||||
// This allows the CLI to return the job ID immediately while the watcher
|
||||
// stays alive to finalize the job.
|
||||
let watcher: { pid?: number; unref?: () => void };
|
||||
|
||||
if (options.spawnWatcher) {
|
||||
// Test path: use the injected watcher mock
|
||||
watcher = options.spawnWatcher(`${jobDir}/${jobId}.json`, command, clientArgs);
|
||||
} else {
|
||||
const watcherArgs = [
|
||||
"--import", "tsx",
|
||||
new URL("./job-watcher.ts", import.meta.url).pathname,
|
||||
`${jobDir}/${jobId}.json`,
|
||||
command,
|
||||
...clientArgs,
|
||||
];
|
||||
watcher = spawnImpl("node", watcherArgs, {
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: "ignore",
|
||||
});
|
||||
watcher.unref?.();
|
||||
}
|
||||
|
||||
// Give the watcher a tick to spawn and record the real child PID
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Re-read the job file to pick up the watcher's PID update
|
||||
let updatedRecord: JobRecord;
|
||||
try {
|
||||
updatedRecord = JSON.parse(fs.readFileSync(`${jobDir}/${jobId}.json`, "utf-8")) as JobRecord;
|
||||
} catch {
|
||||
updatedRecord = record;
|
||||
}
|
||||
|
||||
return {
|
||||
id: jobId,
|
||||
client,
|
||||
prompt,
|
||||
status: "running",
|
||||
startedAt,
|
||||
pid: updatedRecord.pid ?? watcher.pid ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJob(jobId: string, options: JobOperationsOptions = {}): Job {
|
||||
const jobDir = getJobDir(options);
|
||||
const fs = options.fs ?? {
|
||||
mkdirSync: defaultMkdirSync,
|
||||
writeFileSync: defaultWriteFileSync,
|
||||
readFileSync: defaultReadFileSync,
|
||||
readdirSync: defaultReaddirSync,
|
||||
existsSync: defaultExistsSync,
|
||||
statSync: defaultStatSync,
|
||||
unlinkSync: defaultUnlinkSync,
|
||||
};
|
||||
const record = readJobFile(jobId, jobDir, fs);
|
||||
const { stdout: _stdout, stderr: _stderr, ...job } = record;
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJobResult(jobId: string, options: JobOperationsOptions = {}): ExecResult {
|
||||
const job = getJob(jobId, options);
|
||||
if (job.status !== "completed") {
|
||||
throw new JobResultUnavailableError(jobId, job.status);
|
||||
}
|
||||
if (!job.result) {
|
||||
throw new JobResultUnavailableError(jobId, "completed");
|
||||
}
|
||||
return job.result;
|
||||
}
|
||||
|
||||
export function cancelJob(jobId: string, options: JobOperationsOptions = {}): void {
|
||||
const jobDir = getJobDir(options);
|
||||
const fs = options.fs ?? {
|
||||
mkdirSync: defaultMkdirSync,
|
||||
writeFileSync: defaultWriteFileSync,
|
||||
readFileSync: defaultReadFileSync,
|
||||
readdirSync: defaultReaddirSync,
|
||||
existsSync: defaultExistsSync,
|
||||
statSync: defaultStatSync,
|
||||
unlinkSync: defaultUnlinkSync,
|
||||
};
|
||||
|
||||
const record = readJobFile(jobId, jobDir, fs);
|
||||
if (record.status !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill the client child process (PID recorded by the watcher)
|
||||
if (record.pid) {
|
||||
try {
|
||||
process.kill(record.pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore — process may have already exited
|
||||
}
|
||||
}
|
||||
|
||||
// Update the job file to cancelled
|
||||
const cancelledRecord: JobRecord = {
|
||||
...record,
|
||||
status: "cancelled",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
writeJobFile(jobDir, cancelledRecord, fs);
|
||||
}
|
||||
|
||||
export function listJobs(options: JobOperationsOptions & { filter?: JobStatus } = {}): Job[] {
|
||||
const jobDir = getJobDir(options);
|
||||
const fs = options.fs ?? {
|
||||
mkdirSync: defaultMkdirSync,
|
||||
writeFileSync: defaultWriteFileSync,
|
||||
readFileSync: defaultReadFileSync,
|
||||
readdirSync: defaultReaddirSync,
|
||||
existsSync: defaultExistsSync,
|
||||
statSync: defaultStatSync,
|
||||
unlinkSync: defaultUnlinkSync,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(jobDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(jobDir).filter((f) => f.endsWith(".json"));
|
||||
const jobs: Job[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const jobId = entry.replace(/\.json$/, "");
|
||||
try {
|
||||
const record = readJobFile(jobId, jobDir, fs);
|
||||
const { stdout: _stdout, stderr: _stderr, ...job } = record;
|
||||
jobs.push(job);
|
||||
} catch {
|
||||
// ignore corrupt/missing files
|
||||
}
|
||||
}
|
||||
|
||||
jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||
|
||||
if (options.filter) {
|
||||
return jobs.filter((j) => j.status === options.filter);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export function cleanupJobs(options: JobOperationsOptions & { maxAgeMs?: number } = {}): void {
|
||||
const jobDir = getJobDir(options);
|
||||
const fs = options.fs ?? {
|
||||
mkdirSync: defaultMkdirSync,
|
||||
writeFileSync: defaultWriteFileSync,
|
||||
readFileSync: defaultReadFileSync,
|
||||
readdirSync: defaultReaddirSync,
|
||||
existsSync: defaultExistsSync,
|
||||
statSync: defaultStatSync,
|
||||
unlinkSync: defaultUnlinkSync,
|
||||
};
|
||||
const maxAgeMs = options.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!fs.existsSync(jobDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entries = fs.readdirSync(jobDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = `${jobDir}/${entry}`;
|
||||
try {
|
||||
const stat = fs.statSync(path);
|
||||
if (now - stat.mtimeMs > maxAgeMs) {
|
||||
fs.unlinkSync(path);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { PathLike } from "node:fs";
|
||||
|
||||
export type ClientName = "codex" | "claude" | "opencode";
|
||||
|
||||
export interface ClientInfo {
|
||||
@@ -11,11 +14,58 @@ export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
client: ClientName;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
clients: ClientName[];
|
||||
defaultClient?: ClientName;
|
||||
export interface DebugInfo {
|
||||
command: string;
|
||||
args: string[];
|
||||
pid?: number;
|
||||
exitCode: number | null;
|
||||
exitSignal: NodeJS.Signals | null;
|
||||
durationMs: number;
|
||||
stderrLength: number;
|
||||
stdoutLength: number;
|
||||
noisySuccess: boolean;
|
||||
/** Unfiltered stderr before noise removal (only present when --debug). */
|
||||
rawStderr?: string;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions {
|
||||
clientPath?: string;
|
||||
timeoutMs?: number;
|
||||
debug?: boolean;
|
||||
onDebug?: (info: DebugInfo) => void;
|
||||
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
|
||||
existsSync?: (path: PathLike) => boolean;
|
||||
}
|
||||
|
||||
export type JobStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
client: ClientName;
|
||||
prompt: string;
|
||||
status: JobStatus;
|
||||
result?: ExecResult;
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk storage contract for job files under
|
||||
* ~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json
|
||||
*/
|
||||
export interface JobRecord extends Job {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface JobStartOptions extends ExecuteOptions {
|
||||
jobDir?: string;
|
||||
}
|
||||
|
||||
export class ClientNotFoundError extends Error {
|
||||
@@ -34,3 +84,17 @@ export class ExecError extends Error {
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export class JobNotFoundError extends Error {
|
||||
constructor(jobId: string) {
|
||||
super(`Job "${jobId}" not found.`);
|
||||
this.name = "JobNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class JobResultUnavailableError extends Error {
|
||||
constructor(jobId: string, status: JobStatus) {
|
||||
super(`Job "${jobId}" result is not available (status: ${status}).`);
|
||||
this.name = "JobResultUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
reportError,
|
||||
reportCliError,
|
||||
handleSyncRun,
|
||||
handleAsyncRun,
|
||||
} from "../src/cli-helpers.js";
|
||||
import type { ExecResult, Job } from "../src/types.js";
|
||||
|
||||
function captureConsole() {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
|
||||
console.error = (...args: unknown[]) => errors.push(args.map(String).join(" "));
|
||||
return {
|
||||
logs,
|
||||
errors,
|
||||
restore() {
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reportError", () => {
|
||||
it("prints JSON error for Error instance when jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError(new Error("boom"), true);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors.length, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "boom");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints plain text error for Error instance when jsonMode=false", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError(new Error("boom"), false);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors.length, 1);
|
||||
assert.strictEqual(out.errors[0], "boom");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints JSON error for non-Error value when jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError("plain string", true);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "plain string");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportCliError", () => {
|
||||
it("prints JSON error with jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportCliError("missing arg", true);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "missing arg");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints prefixed text error with jsonMode=false", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportCliError("missing arg", false);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors[0], "Error: missing arg");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSyncRun", () => {
|
||||
it("returns 0 and prints JSON result in jsonMode", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const result: ExecResult = {
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 10,
|
||||
};
|
||||
const code = await handleSyncRun(
|
||||
async () => result,
|
||||
"codex",
|
||||
"hello",
|
||||
5000,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(out.logs.length, 1);
|
||||
const parsed = JSON.parse(out.logs[0]);
|
||||
assert.strictEqual(parsed.stdout, "out");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 0 and writes stdout/stderr in text mode", async () => {
|
||||
const out = captureConsole();
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
try {
|
||||
const result: ExecResult = {
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 10,
|
||||
};
|
||||
const code = await handleSyncRun(
|
||||
async () => result,
|
||||
"codex",
|
||||
"hello",
|
||||
5000,
|
||||
false,
|
||||
{
|
||||
jsonMode: false,
|
||||
stdoutWrite: (c) => stdout.push(c),
|
||||
stderrWrite: (c) => stderr.push(c),
|
||||
}
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stdout.join(""), "out");
|
||||
assert.strictEqual(stderr.join(""), "err");
|
||||
assert.strictEqual(out.logs.length, 0);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes timeoutMs and debug to executePrompt", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
let received: any;
|
||||
const code = await handleSyncRun(
|
||||
async (_c, _p, opts) => {
|
||||
received = opts;
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 1,
|
||||
};
|
||||
},
|
||||
"codex",
|
||||
"hello",
|
||||
12345,
|
||||
true,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(received?.timeoutMs, 12345);
|
||||
assert.strictEqual(received?.debug, true);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 1 and prints error JSON when executePrompt throws", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = await handleSyncRun(
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
"codex",
|
||||
"hello",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "fail");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAsyncRun", () => {
|
||||
it("returns 0 and prints JSON job in jsonMode", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const job: Job = {
|
||||
id: "j1",
|
||||
client: "codex",
|
||||
prompt: "hi",
|
||||
status: "running",
|
||||
startedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
const code = await handleAsyncRun(
|
||||
async () => job,
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
const parsed = JSON.parse(out.logs[0]);
|
||||
assert.strictEqual(parsed.jobId, "j1");
|
||||
assert.strictEqual(parsed.status, "running");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 0 and prints text job info", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const job: Job = {
|
||||
id: "j1",
|
||||
client: "codex",
|
||||
prompt: "hi",
|
||||
status: "running",
|
||||
startedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
const code = await handleAsyncRun(
|
||||
async () => job,
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: false, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.ok(out.logs[0].includes("j1"));
|
||||
assert.ok(out.logs[0].includes("running"));
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 1 and prints error JSON when startJob throws", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = await handleAsyncRun(
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "fail");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,4 +135,71 @@ describe("resolveConfig", () => {
|
||||
});
|
||||
assert.strictEqual(config.defaultClient, undefined);
|
||||
});
|
||||
|
||||
it("returns default timeout of 600000 when no sources are present", () => {
|
||||
const config = resolveConfig({
|
||||
existsSync: () => false,
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 600_000);
|
||||
});
|
||||
|
||||
it("loads timeout from file config", () => {
|
||||
const config = resolveConfig({
|
||||
existsSync: () => true,
|
||||
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 120_000);
|
||||
});
|
||||
|
||||
it("overrides file timeout with env var", () => {
|
||||
const config = resolveConfig({
|
||||
env: { AI_CLI_TIMEOUT: "240000" },
|
||||
existsSync: () => true,
|
||||
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 240_000);
|
||||
});
|
||||
|
||||
it("overrides env timeout with CLI flag", () => {
|
||||
const config = resolveConfig({
|
||||
flags: { timeout: "480000" },
|
||||
env: { AI_CLI_TIMEOUT: "240000" },
|
||||
existsSync: () => true,
|
||||
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 480_000);
|
||||
});
|
||||
|
||||
it("respects full priority ordering for timeout: flag > env > file > default", () => {
|
||||
const config = resolveConfig({
|
||||
flags: { timeout: "480000" },
|
||||
env: { AI_CLI_TIMEOUT: "240000" },
|
||||
existsSync: () => true,
|
||||
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 480_000);
|
||||
});
|
||||
|
||||
it("ignores invalid timeout from env var and falls back to default", () => {
|
||||
const config = resolveConfig({
|
||||
env: { AI_CLI_TIMEOUT: "not-a-number" },
|
||||
existsSync: () => false,
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 600_000);
|
||||
});
|
||||
|
||||
it("ignores invalid timeout from file and falls back to default", () => {
|
||||
const config = resolveConfig({
|
||||
existsSync: () => true,
|
||||
readFileSync: () => JSON.stringify({ timeout: "not-a-number" }),
|
||||
whichSync: () => undefined,
|
||||
});
|
||||
assert.strictEqual(config.timeout, 600_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface MockScenario {
|
||||
|
||||
function createMockChildProcess(scenario: MockScenario): any {
|
||||
const child = new EventEmitter() as any;
|
||||
child.pid = 12345;
|
||||
child.stdout = Readable.from(
|
||||
scenario.stdout !== undefined ? [scenario.stdout] : []
|
||||
);
|
||||
@@ -203,4 +204,241 @@ describe("executePrompt", () => {
|
||||
err instanceof ExecError && err.message.includes("Unknown client")
|
||||
);
|
||||
});
|
||||
|
||||
it("includes client and durationMs in result", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
|
||||
]);
|
||||
const result = await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.strictEqual(result.client, "codex");
|
||||
assert.strictEqual(typeof result.durationMs, "number");
|
||||
assert.ok(result.durationMs >= 0);
|
||||
});
|
||||
|
||||
it("rejects with ExecError containing custom timeout value", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo slow", { hang: true }],
|
||||
]);
|
||||
await assert.rejects(
|
||||
executePrompt("codex", "slow", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
(err: unknown) =>
|
||||
err instanceof ExecError &&
|
||||
err.message === "Execution timed out after 50ms" &&
|
||||
err.result.exitCode === -1 &&
|
||||
err.result.client === "codex" &&
|
||||
typeof err.result.durationMs === "number"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default timeout of 600000 when timeoutMs is not provided", async () => {
|
||||
const delays: number[] = [];
|
||||
const origSetTimeout = global.setTimeout;
|
||||
(global as any).setTimeout = function(callback: any, delay: number) {
|
||||
delays.push(delay);
|
||||
return origSetTimeout(callback, delay);
|
||||
};
|
||||
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
|
||||
]);
|
||||
try {
|
||||
await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.strictEqual(delays[0], 600_000);
|
||||
} finally {
|
||||
global.setTimeout = origSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it("emits debug info via onDebug when debug is true for successful execution", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
const result = await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(debugInfos.length, 1);
|
||||
const info = debugInfos[0];
|
||||
assert.strictEqual(info.command, "codex");
|
||||
assert.deepStrictEqual(info.args, ["exec", "--yolo", "hello"]);
|
||||
assert.strictEqual(info.pid, 12345);
|
||||
assert.strictEqual(info.exitCode, 0);
|
||||
assert.strictEqual(info.exitSignal, null);
|
||||
assert.strictEqual(info.stderrLength, 4);
|
||||
assert.strictEqual(info.stdoutLength, 2);
|
||||
assert.strictEqual(typeof info.durationMs, "number");
|
||||
assert.ok(info.durationMs >= 0);
|
||||
});
|
||||
|
||||
it("emits debug info via onDebug when debug is true for failed execution", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
const result = await executePrompt("codex", "fail", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 1);
|
||||
assert.strictEqual(debugInfos.length, 1);
|
||||
assert.strictEqual(debugInfos[0].exitCode, 1);
|
||||
assert.strictEqual(debugInfos[0].stderrLength, 5);
|
||||
assert.strictEqual(debugInfos[0].stdoutLength, 0);
|
||||
});
|
||||
|
||||
it("emits debug info via onDebug for spawn errors", async () => {
|
||||
const scenarios = new Map<string, MockScenario>();
|
||||
const debugInfos: any[] = [];
|
||||
await assert.rejects(
|
||||
executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
}),
|
||||
(err: unknown) => err instanceof ClientNotFoundError
|
||||
);
|
||||
assert.strictEqual(debugInfos.length, 1);
|
||||
assert.strictEqual(debugInfos[0].command, "codex");
|
||||
assert.deepStrictEqual(debugInfos[0].args, ["exec", "--yolo", "hello"]);
|
||||
assert.strictEqual(debugInfos[0].exitCode, null);
|
||||
assert.strictEqual(debugInfos[0].exitSignal, null);
|
||||
});
|
||||
|
||||
it("reports noisySuccess=true when stderr is non-empty and exitCode is 0", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(debugInfos[0].noisySuccess, true);
|
||||
});
|
||||
|
||||
it("reports noisySuccess=false when stderr is empty and exitCode is 0", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(debugInfos[0].noisySuccess, false);
|
||||
});
|
||||
|
||||
it("reports noisySuccess=false when exitCode is non-zero even if stderr is non-empty", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
await executePrompt("codex", "fail", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(debugInfos[0].noisySuccess, false);
|
||||
});
|
||||
|
||||
it("filters codex ReasoningSummary noise from stderr on exit code 0", async () => {
|
||||
const noisyStderr = [
|
||||
'2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryPartAdded without active item',
|
||||
'2026-05-20T18:33:03.281713Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
|
||||
'2026-05-20T18:33:03.348247Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
|
||||
].join('\n');
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "Hello world!", stderr: noisyStderr, exitCode: 0 }],
|
||||
]);
|
||||
const result = await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(result.stderr, "");
|
||||
assert.strictEqual(result.stdout, "Hello world!");
|
||||
});
|
||||
|
||||
it("preserves real error stderr from codex on non-zero exit code", async () => {
|
||||
const noisyStderr = [
|
||||
'2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
|
||||
'Error: something actually went wrong',
|
||||
].join('\n');
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo fail", { stdout: "", stderr: noisyStderr, exitCode: 1 }],
|
||||
]);
|
||||
const result = await executePrompt("codex", "fail", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 1);
|
||||
assert.ok(result.stderr.includes("ReasoningSummaryDelta"));
|
||||
assert.ok(result.stderr.includes("something actually went wrong"));
|
||||
});
|
||||
|
||||
it("provides rawStderr in debug info when noise is filtered", async () => {
|
||||
const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n';
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", stderr: noisyStderr, exitCode: 0 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
const result = await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(result.stderr, "");
|
||||
assert.strictEqual(debugInfos[0].rawStderr, noisyStderr);
|
||||
});
|
||||
|
||||
it("does not set rawStderr when no noise filtering occurred", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }],
|
||||
]);
|
||||
const debugInfos: any[] = [];
|
||||
await executePrompt("codex", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
debug: true,
|
||||
onDebug: (info) => debugInfos.push(info),
|
||||
});
|
||||
assert.strictEqual(debugInfos[0].rawStderr, undefined);
|
||||
});
|
||||
|
||||
it("does not filter stderr for non-codex clients", async () => {
|
||||
const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n';
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["claude -p hello --dangerously-skip-permissions", { stdout: "ok", stderr: noisyStderr, exitCode: 0 }],
|
||||
]);
|
||||
const result = await executePrompt("claude", "hello", {
|
||||
spawn: mockSpawn(scenarios),
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(result.stderr, noisyStderr);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
startJob,
|
||||
getJob,
|
||||
getJobResult,
|
||||
cancelJob,
|
||||
listJobs,
|
||||
cleanupJobs,
|
||||
} from "../src/jobs.js";
|
||||
import { JobNotFoundError, JobResultUnavailableError } from "../src/types.js";
|
||||
import type { ClientName, JobRecord, JobStatus } from "../src/types.js";
|
||||
|
||||
interface MockScenario {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
error?: NodeJS.ErrnoException;
|
||||
hang?: boolean;
|
||||
}
|
||||
|
||||
function createMockChildProcess(scenario: MockScenario): any {
|
||||
const child = new EventEmitter() as any;
|
||||
child.pid = 12345;
|
||||
child.stdout = Readable.from(
|
||||
scenario.stdout !== undefined ? [scenario.stdout] : []
|
||||
);
|
||||
child.stderr = Readable.from(
|
||||
scenario.stderr !== undefined ? [scenario.stderr] : []
|
||||
);
|
||||
child.killed = false;
|
||||
child.kill = (signal: string = "SIGTERM") => {
|
||||
child.killed = true;
|
||||
process.nextTick(() => {
|
||||
child.emit("exit", null, signal);
|
||||
child.emit("close", null, signal);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
child.unref = () => {};
|
||||
|
||||
let stdoutEnded = scenario.stdout === undefined;
|
||||
let stderrEnded = scenario.stderr === undefined;
|
||||
|
||||
child.stdout.on("end", () => {
|
||||
stdoutEnded = true;
|
||||
maybeClose();
|
||||
});
|
||||
child.stderr.on("end", () => {
|
||||
stderrEnded = true;
|
||||
maybeClose();
|
||||
});
|
||||
|
||||
function maybeClose() {
|
||||
if (stdoutEnded && stderrEnded && !scenario.hang && !scenario.error) {
|
||||
child.emit("exit", scenario.exitCode ?? 0, null);
|
||||
child.emit("close", scenario.exitCode ?? 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
process.nextTick(() => {
|
||||
if (scenario.error) {
|
||||
child.emit("error", scenario.error);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function createMockFs() {
|
||||
const files = new Map<string, string>();
|
||||
const dirs = new Set<string>();
|
||||
|
||||
const fs = {
|
||||
mkdirSync: (p: string, _opts?: any) => {
|
||||
dirs.add(p);
|
||||
},
|
||||
writeFileSync: (p: string, data: string) => {
|
||||
files.set(p, data);
|
||||
},
|
||||
readFileSync: (p: string, _opts?: any): string => {
|
||||
if (!files.has(p)) {
|
||||
const err = Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
|
||||
throw err;
|
||||
}
|
||||
return files.get(p)!;
|
||||
},
|
||||
readdirSync: (p: string): string[] => {
|
||||
const result: string[] = [];
|
||||
for (const f of files.keys()) {
|
||||
const dir = f.substring(0, f.lastIndexOf("/"));
|
||||
if (dir === p) {
|
||||
result.push(f.substring(f.lastIndexOf("/") + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
existsSync: (p: string): boolean => {
|
||||
return files.has(p) || dirs.has(p);
|
||||
},
|
||||
statSync: (p: string): { mtimeMs: number; isFile: () => boolean } => {
|
||||
if (!files.has(p)) {
|
||||
const err = Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
|
||||
throw err;
|
||||
}
|
||||
const data = files.get(p)!;
|
||||
// Use startedAt from JSON for deterministic age tests
|
||||
try {
|
||||
const record = JSON.parse(data) as JobRecord;
|
||||
return { mtimeMs: new Date(record.startedAt).getTime(), isFile: () => true };
|
||||
} catch {
|
||||
return { mtimeMs: Date.now(), isFile: () => true };
|
||||
}
|
||||
},
|
||||
unlinkSync: (p: string) => {
|
||||
files.delete(p);
|
||||
},
|
||||
__files: files,
|
||||
__dirs: dirs,
|
||||
};
|
||||
|
||||
return fs;
|
||||
}
|
||||
|
||||
function mockSpawn(scenarios: Map<string, MockScenario>): any {
|
||||
return (cmd: string, args: string[], _opts: any): any => {
|
||||
const key = [cmd, ...args].join(" ");
|
||||
const scenario = scenarios.get(key);
|
||||
if (!scenario) {
|
||||
return createMockChildProcess({
|
||||
error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }),
|
||||
});
|
||||
}
|
||||
return createMockChildProcess(scenario);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock spawnWatcher that simulates the job-watcher.ts behavior:
|
||||
* spawns the mock child, captures output, and writes the final job record.
|
||||
*/
|
||||
function createMockWatcher(
|
||||
scenarios: Map<string, MockScenario>,
|
||||
fs: ReturnType<typeof createMockFs>,
|
||||
opts?: { watcherTimeoutMs?: number }
|
||||
): (jobFilePath: string, command: string, args: string[]) => { pid: number; unref: () => void } {
|
||||
return (jobFilePath: string, command: string, clientArgs: string[]) => {
|
||||
const key = [command, ...clientArgs].join(" ");
|
||||
const scenario = scenarios.get(key) ?? { error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }) };
|
||||
const child = createMockChildProcess(scenario);
|
||||
const watcherPid = 99999;
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); });
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); });
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
// Read current record, update with results
|
||||
let record: JobRecord;
|
||||
try {
|
||||
record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const durationMs = Date.now() - new Date(record.startedAt).getTime();
|
||||
const status = code === 0 || code === null ? "completed" : "failed";
|
||||
record.status = status;
|
||||
record.stdout = stdout;
|
||||
record.stderr = stderr;
|
||||
record.completedAt = new Date().toISOString();
|
||||
record.result = { stdout, stderr, exitCode: code ?? 0, client: record.client, durationMs };
|
||||
if (scenario.error) {
|
||||
record.status = "failed";
|
||||
record.error = scenario.error.message;
|
||||
}
|
||||
fs.writeFileSync(jobFilePath, JSON.stringify(record));
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
let record: JobRecord;
|
||||
try {
|
||||
record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
record.status = "failed";
|
||||
record.error = err.message;
|
||||
record.completedAt = new Date().toISOString();
|
||||
fs.writeFileSync(jobFilePath, JSON.stringify(record));
|
||||
});
|
||||
|
||||
// For hang scenarios, simulate a timeout by writing timed_out after a delay
|
||||
if (scenario.hang) {
|
||||
const watcherTimeout = opts?.watcherTimeoutMs ?? 30;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
|
||||
// Don't overwrite if already cancelled/completed
|
||||
if (existing.status !== "running") return;
|
||||
existing.status = "timed_out";
|
||||
existing.completedAt = new Date().toISOString();
|
||||
existing.result = { stdout, stderr, exitCode: -1, client: existing.client, durationMs: watcherTimeout };
|
||||
fs.writeFileSync(jobFilePath, JSON.stringify(existing));
|
||||
} catch { /* ignore */ }
|
||||
}, watcherTimeout);
|
||||
}
|
||||
|
||||
// Simulate watcher updating the PID in the job file
|
||||
try {
|
||||
const record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
|
||||
record.pid = child.pid;
|
||||
fs.writeFileSync(jobFilePath, JSON.stringify(record));
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return { pid: watcherPid, unref: () => {} };
|
||||
};
|
||||
}
|
||||
|
||||
function createJobTestHelper(scenarios: Map<string, MockScenario>, jobDir: string) {
|
||||
const fs = createMockFs();
|
||||
const spawnWatcher = createMockWatcher(scenarios, fs);
|
||||
const spawn = mockSpawn(scenarios);
|
||||
return { fs, spawn, spawnWatcher, jobDir };
|
||||
}
|
||||
|
||||
function readJobRecord(fs: ReturnType<typeof createMockFs>, path: string): JobRecord {
|
||||
return JSON.parse(fs.readFileSync(path)) as JobRecord;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
describe("startJob", () => {
|
||||
it("spawns a detached child process and returns a running Job", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello world", { stdout: "ok\n", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello world", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
assert.strictEqual(job.client, "codex");
|
||||
assert.strictEqual(job.prompt, "hello world");
|
||||
assert.strictEqual(job.status, "running");
|
||||
assert.strictEqual(typeof job.id, "string");
|
||||
assert.ok(job.id.length > 0);
|
||||
assert.strictEqual(typeof job.pid, "number");
|
||||
assert.ok(typeof job.startedAt === "string");
|
||||
|
||||
// Wait for child to finish
|
||||
await delay(50);
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
|
||||
assert.strictEqual(record.status, "completed");
|
||||
assert.strictEqual(record.stdout, "ok\n");
|
||||
assert.strictEqual(record.result?.exitCode, 0);
|
||||
assert.ok(typeof record.completedAt === "string");
|
||||
});
|
||||
|
||||
it("generates unique ids for concurrent jobs", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo a", { stdout: "a", exitCode: 0 }],
|
||||
["codex exec --yolo b", { stdout: "b", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job1 = await startJob("codex", "a", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
const job2 = await startJob("codex", "b", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
assert.notStrictEqual(job1.id, job2.id);
|
||||
});
|
||||
|
||||
it("sets status to timed_out when timeout is exceeded", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo slow", { hang: true }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "slow", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
|
||||
// Wait for timeout + processing
|
||||
await delay(100);
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
|
||||
assert.strictEqual(record.status, "timed_out");
|
||||
});
|
||||
|
||||
it("sets status to failed on non-zero exit code", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo fail", { stdout: "", stderr: "err", exitCode: 1 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "fail", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
|
||||
assert.strictEqual(record.status, "failed");
|
||||
assert.strictEqual(record.result?.exitCode, 1);
|
||||
assert.strictEqual(record.stderr, "err");
|
||||
});
|
||||
|
||||
it("sets status to failed on spawn ENOENT", async () => {
|
||||
const scenarios = new Map<string, MockScenario>();
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
|
||||
assert.strictEqual(record.status, "failed");
|
||||
assert.ok(record.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJob", () => {
|
||||
it("returns the current Job state from disk", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { hang: true }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs, { watcherTimeoutMs: 5000 }),
|
||||
fs,
|
||||
});
|
||||
|
||||
const fetched = getJob(job.id, { jobDir, fs });
|
||||
assert.strictEqual(fetched.id, job.id);
|
||||
assert.strictEqual(fetched.status, "running");
|
||||
|
||||
await delay(50);
|
||||
|
||||
// Job should still be running since watcher timeout is 5s
|
||||
const after = getJob(job.id, { jobDir, fs });
|
||||
assert.strictEqual(after.status, "running");
|
||||
});
|
||||
|
||||
it("throws JobNotFoundError for nonexistent job", () => {
|
||||
const fs = createMockFs();
|
||||
assert.throws(
|
||||
() => getJob("missing", { jobDir: "/tmp/jobs", fs }),
|
||||
(err: unknown) => err instanceof JobNotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJobResult", () => {
|
||||
it("returns ExecResult when job is completed", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
|
||||
const result = getJobResult(job.id, { jobDir, fs });
|
||||
assert.strictEqual(result.stdout, "ok");
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
});
|
||||
|
||||
it("throws JobResultUnavailableError when job is still running", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { hang: true }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => getJobResult(job.id, { jobDir, fs }),
|
||||
(err: unknown) => err instanceof JobResultUnavailableError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws JobResultUnavailableError when job failed", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo fail", { stdout: "", stderr: "err", exitCode: 1 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "fail", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
|
||||
assert.throws(
|
||||
() => getJobResult(job.id, { jobDir, fs }),
|
||||
(err: unknown) => err instanceof JobResultUnavailableError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws JobNotFoundError for nonexistent job", () => {
|
||||
const fs = createMockFs();
|
||||
assert.throws(
|
||||
() => getJobResult("missing", { jobDir: "/tmp/jobs", fs }),
|
||||
(err: unknown) => err instanceof JobNotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelJob", () => {
|
||||
it("sends SIGTERM and updates status to cancelled", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo hello", { hang: true }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "hello", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs, { watcherTimeoutMs: 5000 }),
|
||||
fs,
|
||||
});
|
||||
|
||||
cancelJob(job.id, { jobDir, fs });
|
||||
|
||||
await delay(50);
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
|
||||
assert.strictEqual(record.status, "cancelled");
|
||||
});
|
||||
|
||||
it("throws JobNotFoundError for nonexistent job", () => {
|
||||
const fs = createMockFs();
|
||||
assert.throws(
|
||||
() => cancelJob("missing", { jobDir: "/tmp/jobs", fs }),
|
||||
(err: unknown) => err instanceof JobNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when job is not running", () => {
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
const now = new Date().toISOString();
|
||||
const completedRecord: JobRecord = {
|
||||
id: "job-completed",
|
||||
client: "codex",
|
||||
prompt: "done",
|
||||
status: "completed",
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
result: {
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 100,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(jobDir, { recursive: true });
|
||||
fs.writeFileSync(`${jobDir}/job-completed.json`, JSON.stringify(completedRecord));
|
||||
|
||||
cancelJob("job-completed", { jobDir, fs });
|
||||
|
||||
const record = readJobRecord(fs, `${jobDir}/job-completed.json`);
|
||||
assert.strictEqual(record.status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listJobs", () => {
|
||||
it("returns all jobs sorted by startedAt desc", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo a", { stdout: "a", exitCode: 0 }],
|
||||
["codex exec --yolo b", { stdout: "b", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job1 = await startJob("codex", "a", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
await delay(20);
|
||||
const job2 = await startJob("codex", "b", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
|
||||
const jobs = listJobs({ jobDir, fs });
|
||||
assert.strictEqual(jobs.length, 2);
|
||||
assert.strictEqual(jobs[0].id, job2.id);
|
||||
assert.strictEqual(jobs[1].id, job1.id);
|
||||
});
|
||||
|
||||
it("returns empty array when jobDir does not exist", () => {
|
||||
const fs = createMockFs();
|
||||
const jobs = listJobs({ jobDir: "/tmp/jobs", fs });
|
||||
assert.deepStrictEqual(jobs, []);
|
||||
});
|
||||
|
||||
it("ignores corrupt or unreadable job files", () => {
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
const now = new Date().toISOString();
|
||||
const validRecord: JobRecord = {
|
||||
id: "job-valid",
|
||||
client: "codex",
|
||||
prompt: "ok",
|
||||
status: "completed",
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
result: {
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 100,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(jobDir, { recursive: true });
|
||||
fs.writeFileSync(`${jobDir}/job-valid.json`, JSON.stringify(validRecord));
|
||||
fs.writeFileSync(`${jobDir}/job-corrupt.json`, "not-json");
|
||||
|
||||
const jobs = listJobs({ jobDir, fs });
|
||||
assert.strictEqual(jobs.length, 1);
|
||||
assert.strictEqual(jobs[0].id, "job-valid");
|
||||
});
|
||||
|
||||
it("filters jobs by status when filter is provided", () => {
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const runningRecord: JobRecord = {
|
||||
id: "job-running",
|
||||
client: "codex",
|
||||
prompt: "run",
|
||||
status: "running",
|
||||
startedAt: now,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
const completedRecord: JobRecord = {
|
||||
id: "job-completed",
|
||||
client: "claude",
|
||||
prompt: "done",
|
||||
status: "completed",
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
result: {
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
client: "claude",
|
||||
durationMs: 100,
|
||||
},
|
||||
};
|
||||
const failedRecord: JobRecord = {
|
||||
id: "job-failed",
|
||||
client: "opencode",
|
||||
prompt: "fail",
|
||||
status: "failed",
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
stdout: "",
|
||||
stderr: "err",
|
||||
result: {
|
||||
stdout: "",
|
||||
stderr: "err",
|
||||
exitCode: 1,
|
||||
client: "opencode",
|
||||
durationMs: 100,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(jobDir, { recursive: true });
|
||||
fs.writeFileSync(`${jobDir}/job-running.json`, JSON.stringify(runningRecord));
|
||||
fs.writeFileSync(`${jobDir}/job-completed.json`, JSON.stringify(completedRecord));
|
||||
fs.writeFileSync(`${jobDir}/job-failed.json`, JSON.stringify(failedRecord));
|
||||
|
||||
const all = listJobs({ jobDir, fs });
|
||||
assert.strictEqual(all.length, 3);
|
||||
|
||||
const running = listJobs({ jobDir, fs, filter: "running" });
|
||||
assert.strictEqual(running.length, 1);
|
||||
assert.strictEqual(running[0].id, "job-running");
|
||||
|
||||
const completed = listJobs({ jobDir, fs, filter: "completed" });
|
||||
assert.strictEqual(completed.length, 1);
|
||||
assert.strictEqual(completed[0].id, "job-completed");
|
||||
|
||||
const failed = listJobs({ jobDir, fs, filter: "failed" });
|
||||
assert.strictEqual(failed.length, 1);
|
||||
assert.strictEqual(failed[0].id, "job-failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupJobs", () => {
|
||||
it("deletes job files older than maxAgeMs", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo old", { stdout: "old", exitCode: 0 }],
|
||||
["codex exec --yolo new", { stdout: "new", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
// Create an old job by manipulating startedAt after creation
|
||||
const oldJob = await startJob("codex", "old", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
await delay(50);
|
||||
|
||||
const newJob = await startJob("codex", "new", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
await delay(50);
|
||||
|
||||
// Make old job appear 25h old by patching its record
|
||||
const oldPath = `${jobDir}/${oldJob.id}.json`;
|
||||
const oldRecord = readJobRecord(fs, oldPath);
|
||||
oldRecord.startedAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(oldPath, JSON.stringify(oldRecord));
|
||||
|
||||
cleanupJobs({ jobDir, fs, maxAgeMs: 24 * 60 * 60 * 1000 });
|
||||
|
||||
assert.strictEqual(fs.existsSync(oldPath), false);
|
||||
assert.strictEqual(fs.existsSync(`${jobDir}/${newJob.id}.json`), true);
|
||||
});
|
||||
|
||||
it("uses default maxAge of 24 hours", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
["codex exec --yolo old", { stdout: "old", exitCode: 0 }],
|
||||
]);
|
||||
const fs = createMockFs();
|
||||
const jobDir = "/tmp/jobs";
|
||||
|
||||
const job = await startJob("codex", "old", {
|
||||
jobDir,
|
||||
spawn: mockSpawn(scenarios),
|
||||
spawnWatcher: createMockWatcher(scenarios, fs),
|
||||
fs,
|
||||
});
|
||||
await delay(50);
|
||||
|
||||
const path = `${jobDir}/${job.id}.json`;
|
||||
const record = readJobRecord(fs, path);
|
||||
record.startedAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(path, JSON.stringify(record));
|
||||
|
||||
cleanupJobs({ jobDir, fs });
|
||||
|
||||
assert.strictEqual(fs.existsSync(path), false);
|
||||
});
|
||||
|
||||
it("is a no-op when jobDir does not exist", () => {
|
||||
const fs = createMockFs();
|
||||
// Should not throw
|
||||
cleanupJobs({ jobDir: "/tmp/jobs", fs });
|
||||
assert.strictEqual(fs.existsSync("/tmp/jobs"), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user