From 5fed5001dcc211683b3ab9b5ac34f2ece9535489 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 02:51:14 +0300 Subject: [PATCH 1/6] =?UTF-8?q?docs(roadmap):=20mark=202.E=20Done=20(PR=20?= =?UTF-8?q?#46)=20across=20the=20status=20surfaces=20=E2=80=94=20next=20pi?= =?UTF-8?q?ckup=202.G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #46 merged. The ink streaming TUI is shipped — the third RunRenderer over the one event bus (live per-node status + spinners, the active node's streaming tokens, a running cost footer, a persistent final summary), with cooperative Ctrl-C cancel (the native-AbortController + persistent-SIGINT + abort→cancelled chain). Behind ADR-0047 (ink ^6.8.0 + React 19, confined to apps/cli); no new ADR. - phase-2-cli.md: 2.E heading + status header + Remaining-build-order status line all marked ✅ Done (PR #46); the build-order table drops the 2.E row, renumbers (2.G now #1), and flips 2.E → ✓ in the 2.G + chat blocker cells; the gate-closing backbone shrinks to `2.G → 2.I → 2.L` (2.E joins 2.K + 2.H + 2.C as done). - current.md: 2.E added to the Landed list (behind ADR-0047); next pickup → 2.G; date bump (2026-06-24). - CLAUDE.md + README.md: status paragraphs updated (2.E landed; next pickup 2.G). Refs: phase-2-cli.md 2.E, ADR-0047 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 ++++--- README.md | 4 ++-- docs/roadmap/current.md | 13 +++++++++---- docs/roadmap/phases/phase-2-cli.md | 27 +++++++++++++-------------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f78d41d..5e62ba65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,9 +49,10 @@ entry point (multi-turn sessions, persistence, export-to-workflow) are shipped. resolution (2.B) landed (PR #40), `relavium run` is wired to the engine (2.D, the M3 keystone — PR #41), the `--json` CI machine-output contract landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43) -completes M3, and durable run history landed (2.H — PR #44, ADR-0050); and the provider/key -commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006). The next -pickup is 2.E (ink TUI). For live status, per-PR history, +completes M3, and durable run history landed (2.H — PR #44, ADR-0050); the provider/key +commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006); and the `ink` +streaming TUI landed (2.E — PR #46, behind ADR-0047). The next pickup is 2.G (interactive human-gate +prompt + `relavium gate` resume). For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview. diff --git a/README.md b/README.md index 708e57f8..04a4d3f3 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ One engine, three modes behind the one `LLMProvider` seam: local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint/resume, multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its -`--json` CI machine-output contract, the engine regression harness, durable local run history, and the -provider/key commands (API keys in the OS keychain) have landed (milestone **M3** reached). For live status and the full roadmap, see +`--json` CI machine-output contract, the engine regression harness, durable local run history, the +provider/key commands (API keys in the OS keychain), and the live `ink` streaming TUI have landed (milestone **M3** reached). For live status and the full roadmap, see [docs/roadmap/current.md](docs/roadmap/current.md) and the [roadmap](docs/roadmap/README.md). diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ab05651b..2ccbfdf9 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-06-23 +> Last updated: 2026-06-24 - **Related**: [README.md](README.md), [phases/phase-2-cli.md](phases/phase-2-cli.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -49,9 +49,14 @@ and **2.K** (the engine regression harness, now the engine's CI regression gate) and **2.C** (the `relavium provider` commands — a provider registry + API keys in the OS keychain via `@napi-rs/keyring`, resolved keychain → `RELAVIUM__API_KEY` env var → error), ✅ Done (PR #45, 2026-06-23) behind [ADR-0019](../decisions/0019-cli-node-keychain-library.md) + -[ADR-0006](../decisions/0006-os-keychain-for-api-keys.md) (no new ADR — `secrets.enc` deferred past v1.0). -**Next pickup:** **2.E** (ink TUI — go/no-go #1; the shared `ink` infra `2.G` + `2.M` reuse); the full -status-aware order is the +[ADR-0006](../decisions/0006-os-keychain-for-api-keys.md) (no new ADR — `secrets.enc` deferred past v1.0); +and **2.E** (the `ink` streaming TUI — the third `RunRenderer` over the one event bus: live per-node status ++ spinners, the active node's streaming tokens, a running cost footer, a persistent final summary; cooperative +Ctrl-C cancel), ✅ Done (PR #46, 2026-06-24) behind [ADR-0047](../decisions/0047-cli-framework-commander-ink-clack.md) +(ink + React 19, confined to `apps/cli`; no new ADR). +**Next pickup:** **2.G** (interactive human-gate prompt + `relavium gate` resume — go/no-go #3; reuses 2.E's +`ink` infra + the `finalize?()` seam, and closes 2.K's deferred gate-resume half); the full status-aware +order is the [Remaining build order](phases/phase-2-cli.md#remaining-build-order) queue. The CLI also lands the inbound MCP client (2.R, [ADR-0034](../decisions/0034-mcp-client-sdk-dependency.md)) off the M3 critical path. See the [Phase 2 workstreams](phases/phase-2-cli.md) and the diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 3bef722c..8fc56ee7 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -1,6 +1,6 @@ # Phase 2 — CLI -> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md). The status-aware order for everything still open (next pickup: **2.E**) is the [Remaining build order](#remaining-build-order) queue. +> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md); and **2.E** (the `ink` streaming TUI) is ✅ **Done (PR #46, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md). The status-aware order for everything still open (next pickup: **2.G**) is the [Remaining build order](#remaining-build-order) queue. - **Related**: [../README.md](../README.md), [phase-1-engine-and-llm.md](phase-1-engine-and-llm.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/contracts/config-spec.md](../../reference/contracts/config-spec.md), [../../reference/desktop/keychain-and-secrets.md](../../reference/desktop/keychain-and-secrets.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../reference/desktop/database-schema.md](../../reference/desktop/database-schema.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md) @@ -237,7 +237,7 @@ the workflow; Ctrl-C cancels with `run:cancelled` and a non-zero exit; the terminal-event→exit-code mapping holds for success (`0`), failure (`1`), and gate-paused (`3`). -### 2.E — `ink` streaming TUI (live node status + token stream + cost) +### 2.E — `ink` streaming TUI (live node status + token stream + cost) — ✅ **Done (PR #46)** Render the live run in the terminal with `ink`, consuming the same event bus as the JSON path so the two never diverge. @@ -655,23 +655,22 @@ This is the status × plan view; the dependency rationale for every row lives in [Ordered waves](#ordered-waves-each-wave-is-internally-parallel-waves-gate-left-to-right) — this table does not restate them, it only sequences what remains. -> **Status (2026-06-23):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C** done — **M3 reached** · next pickup: **2.E**. +> **Status (2026-06-24):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C · 2.E** done — **M3 reached** · next pickup: **2.G**. > (2.K's gate-resume + agent-replay halves remain deferred — they land with 2.G / later. 2.H shipped the > durable history substrate + read API; the consuming commands `list`/`logs`/`status` are 2.I.) | Next | Lane | Why now | Blockers (all met on arrival) | |---|---|---|---| -| **1. 2.E** ink TUI | feeder | go/no-go #1; the shared ink infra 2.G + 2.M reuse | 2.D ✓ | -| **2. 2.G** human gate + resume | feeder | go/no-go #3 **and closes 2.K's deferred gate-resume half** | 2.E · 2.H ✓ · 2.F ✓ | -| **3. 2.I** list / logs / status | feeder | go/no-go #2 (read side); blocks nothing | 2.H ✓ | -| **4. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole (via 2.G) | -| **5. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H ✓ | -| **6. 2.R** MCP client | additive | inbound MCP tools | 2.B ✓ · 2.C ✓ | -| **7. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C ✓ · 2.H ✓ · 2.E | -| **8. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | - -- **Gate-closing backbone — `2.E → 2.G → 2.I → 2.L`:** these four PRs flip the remaining - exit criteria (2.K + 2.H + 2.C are done). The remaining four (**2.S, 2.R, chat, 2.J**) +| **1. 2.G** human gate + resume | feeder | go/no-go #3 **and closes 2.K's deferred gate-resume half** | 2.E ✓ · 2.H ✓ · 2.F ✓ | +| **2. 2.I** list / logs / status | feeder | go/no-go #2 (read side); blocks nothing | 2.H ✓ | +| **3. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole (via 2.G) | +| **4. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H ✓ | +| **5. 2.R** MCP client | additive | inbound MCP tools | 2.B ✓ · 2.C ✓ | +| **6. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C ✓ · 2.H ✓ · 2.E ✓ | +| **7. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | + +- **Gate-closing backbone — `2.G → 2.I → 2.L`:** these three PRs flip the remaining + exit criteria (2.K + 2.H + 2.C + 2.E are done). The remaining four (**2.S, 2.R, chat, 2.J**) complete in-phase but do **not** block starting Phase 3. - **2.K fully closes at step 4 (2.G).** Its deferred gate-resume scenario can only be exercised once the gate pause/resume surface exists, so 2.L (step 6) must follow 2.G even From f4087e15200b693d481233975e7fe1270b33729f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 04:13:43 +0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(cli,db):=202.G=20=E2=80=94=20interacti?= =?UTF-8?q?ve=20human-gate=20prompt=20+=20`relavium=20gate`=20resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement both human-gate surfaces over the engine's single suspend/resume contract: an inline `@clack/prompts` prompt during `run` on a TTY, and the out-of-band `relavium gate ` cross-process resume (CI / anywhere). - db: loadRunSnapshot(db, runId) — status + frozen workflow snapshot + inputs, the substrate the fresh-process gate command rebuilds the run from (2.H read). - cli: createHistoryCheckpointer reconstructs the checkpoint from the durable event log; createCliHost gains a { checkpointer } option. The gate command reloads the snapshot, reconstructs the checkpoint, calls engine.resumeFromCheckpoint over the same store, then drives to the terminal. selectGate auto-fills one pending gate, requires --gate for >1, is idempotent (terminal / already-resolved → exit 0), and excludes budget gates. - cli: RunRenderer.suspend/resume seam (ink re-mounts from the retained store); a framework-free GatePrompter (@clack confined to one module); a shared driveRun core both run and gate use — preserving the 2.E Ctrl-C/SIGINT contract. engine.resume mid-loop is deadlock-free (the bus push never blocks). - harness: closes 2.K's deferred gate-resume scenario (run → exit 3 → gate → run:completed) over the offline fixtures. - deps: @clack/prompts ^1.6.0 (ADR-0047, §9a cooling window); Node floor 20.11→20.12 (its engines floor) across package.json / tech-stack.md / catalog. - docs: commands.md run + gate notes; regression-harness; deferred-tasks now tracks the unscheduled `relavium budget resume` follow-up. Refs: ADR-0047, ADR-0050, ADR-0006, ADR-0028 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/package.json | 1 + apps/cli/src/commands/drive.test.ts | 163 ++++++++++ apps/cli/src/commands/drive.ts | 168 ++++++++++ apps/cli/src/commands/gate.test.ts | 305 +++++++++++++++++++ apps/cli/src/commands/gate.ts | 270 ++++++++++++++++ apps/cli/src/commands/run.test.ts | 16 + apps/cli/src/commands/run.ts | 111 ++----- apps/cli/src/commands/specs.ts | 69 ++++- apps/cli/src/engine/checkpointer.test.ts | 88 ++++++ apps/cli/src/engine/checkpointer.ts | 17 ++ apps/cli/src/engine/host.test.ts | 13 + apps/cli/src/engine/host.ts | 26 +- apps/cli/src/gate/clack-prompter.test.ts | 116 +++++++ apps/cli/src/gate/clack-prompter.ts | 87 ++++++ apps/cli/src/gate/decision.test.ts | 89 ++++++ apps/cli/src/gate/decision.ts | 100 ++++++ apps/cli/src/gate/prompter.ts | 16 + apps/cli/src/gate/select-prompter.ts | 20 ++ apps/cli/src/harness/regression.e2e.test.ts | 71 ++++- apps/cli/src/render/renderer.ts | 9 + apps/cli/src/render/tui/ink-renderer.test.ts | 59 ++++ apps/cli/src/render/tui/ink-renderer.ts | 67 ++-- docs/reference/cli/commands.md | 15 +- docs/reference/cli/regression-harness.md | 10 +- docs/roadmap/deferred-tasks.md | 12 + docs/tech-stack.md | 2 +- package.json | 2 +- packages/db/src/index.ts | 2 + packages/db/src/run-history-store.test.ts | 52 ++++ packages/db/src/run-history-store.ts | 40 +++ pnpm-lock.yaml | 52 +++- pnpm-workspace.yaml | 15 +- 32 files changed, 1947 insertions(+), 136 deletions(-) create mode 100644 apps/cli/src/commands/drive.test.ts create mode 100644 apps/cli/src/commands/drive.ts create mode 100644 apps/cli/src/commands/gate.test.ts create mode 100644 apps/cli/src/commands/gate.ts create mode 100644 apps/cli/src/engine/checkpointer.test.ts create mode 100644 apps/cli/src/engine/checkpointer.ts create mode 100644 apps/cli/src/gate/clack-prompter.test.ts create mode 100644 apps/cli/src/gate/clack-prompter.ts create mode 100644 apps/cli/src/gate/decision.test.ts create mode 100644 apps/cli/src/gate/decision.ts create mode 100644 apps/cli/src/gate/prompter.ts create mode 100644 apps/cli/src/gate/select-prompter.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 358433c8..27efbb9c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,6 +17,7 @@ "test": "vitest run" }, "dependencies": { + "@clack/prompts": "catalog:", "@napi-rs/keyring": "catalog:", "@relavium/core": "workspace:*", "@relavium/db": "workspace:*", diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts new file mode 100644 index 00000000..4d976da0 --- /dev/null +++ b/apps/cli/src/commands/drive.test.ts @@ -0,0 +1,163 @@ +import { createInMemoryHost, parseWorkflow, type WorkflowEngine } from '@relavium/core'; +import type { GateDecision, RunEvent, RunPausedEvent } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildEngine } from '../engine/build-engine.js'; +import type { GatePrompter } from '../gate/prompter.js'; +import type { RunRenderer } from '../render/renderer.js'; +import { captureIo } from '../test-support.js'; +import { driveRun, shouldBreakOnPause } from './drive.js'; + +// gate → out: a single approval gate, then completes. The in-memory host pauses at the fail-closed gate. +const GATED = `schema_version: '1.0' +workflow: + id: drive-gated + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: out } +`; + +async function startGatedRun(): Promise<{ + engine: WorkflowEngine; + handle: ReturnType; +}> { + const engine = await buildEngine({ host: createInMemoryHost() }); + const def = parseWorkflow(GATED, { source: 'drive.test' }); + const handle = engine.start({ workflow: def, inputs: {} }); + return { engine, handle }; +} + +/** A renderer that records event types and spies on the suspend/resume/finalize lifecycle. */ +function recordingRenderer(): { renderer: RunRenderer; events: RunEvent[] } { + const events: RunEvent[] = []; + return { + events, + renderer: { + onEvent: (e) => { + events.push(e); + }, + suspend: vi.fn(), + resume: vi.fn(), + finalize: vi.fn(), + }, + }; +} + +const prompterReturning = (value: GateDecision | null): GatePrompter => ({ + prompt: vi.fn(() => Promise.resolve(value)), +}); + +describe('driveRun — interactive gate', () => { + it('resolves a gate inline (approve) — suspends the TUI, prompts, resumes the engine, drives to completion', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer, events } = recordingRenderer(); + const prompter = prompterReturning({ decision: 'approved', decidedBy: 'cli' }); + const { io } = captureIo(); + + const outcome = await driveRun({ + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + + expect(outcome).toBe('completed'); + expect(prompter.prompt).toHaveBeenCalledTimes(1); + expect(renderer.suspend).toHaveBeenCalledTimes(1); // released the terminal for the prompt... + expect(renderer.resume).toHaveBeenCalledTimes(1); // ...then re-mounted + const types = events.map((e) => e.type); + expect(types).toContain('human_gate:resumed'); // the engine applied the decision + expect(types.at(-1)).toBe('run:completed'); // and continued past the (ignored) run:paused to the terminal + }); + + it('passes the built decision through to the engine (the resumed event carries it)', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer, events } = recordingRenderer(); + const { io } = captureIo(); + + await driveRun({ + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompterReturning({ decision: 'rejected', decidedBy: 'cli', comment: 'no' }), + io, + }); + + const resumed = events.find((e) => e.type === 'human_gate:resumed'); + expect(resumed).toMatchObject({ decision: 'rejected', decidedBy: 'cli' }); + }); + + it('a null decision (the user aborted the prompt) cooperatively cancels the run', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer, events } = recordingRenderer(); + const { io } = captureIo(); + + const outcome = await driveRun({ + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompterReturning(null), + io, + }); + + expect(outcome).toBe('cancelled'); + expect(events.at(-1)?.type).toBe('run:cancelled'); + }); + + it('re-mounts the TUI even if the prompt throws (finally), then the error unwinds', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer } = recordingRenderer(); + const prompter: GatePrompter = { + prompt: () => Promise.reject(new Error('prompt boom')), + }; + const { io } = captureIo(); + + await expect( + driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }), + ).rejects.toThrow('prompt boom'); + expect(renderer.resume).toHaveBeenCalledTimes(1); // the suspend/prompt/resume finally restored the view + expect(renderer.finalize).toHaveBeenCalledTimes(1); // driveRun's finally still finalized + }); + + it('without a prompter, a gate pause stops the loop (→ paused, exit 3)', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer } = recordingRenderer(); + const { io } = captureIo(); + const outcome = await driveRun({ engine, handle, makeRenderer: () => renderer, io }); + expect(outcome).toBe('paused'); + }); +}); + +describe('shouldBreakOnPause', () => { + const pause = (over: Partial = {}): RunPausedEvent => ({ + type: 'run:paused', + runId: 'r', + timestamp: '2026-06-24T10:00:00.000Z', + sequenceNumber: 5, + pendingGateCount: 1, + gateIds: ['g1'], + ...over, + }); + + it('always breaks with no prompter (CI / --json / no-TTY → exit 3)', () => { + expect(shouldBreakOnPause(pause(), false, new Set())).toBe(true); + }); + + it('ignores a pause whose every gate was handled inline (no media park)', () => { + expect(shouldBreakOnPause(pause({ gateIds: ['g1'] }), true, new Set(['g1']))).toBe(false); + }); + + it('breaks on a gate it never handled (cannot resolve inline)', () => { + expect(shouldBreakOnPause(pause({ gateIds: ['g1', 'g2'] }), true, new Set(['g1']))).toBe(true); + }); + + it('breaks on a media-job park even when every gate was handled', () => { + const event = pause({ pendingGateCount: 0, gateIds: [], pendingMediaJobNodeIds: ['m'] }); + expect(shouldBreakOnPause(event, true, new Set())).toBe(true); + }); +}); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts new file mode 100644 index 00000000..c08a1dac --- /dev/null +++ b/apps/cli/src/commands/drive.ts @@ -0,0 +1,168 @@ +import type { RunHandle, WorkflowEngine } from '@relavium/core'; +import type { HumanGatePausedEvent, RunEvent, RunPausedEvent } from '@relavium/shared'; + +import type { GatePrompter } from '../gate/prompter.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { RunRenderer } from '../render/renderer.js'; + +/** A run's terminal disposition (`undefined` means the stream ended with no terminal/paused — an abnormal unwind). */ +export type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused'; + +export interface DriveRunDeps { + readonly engine: WorkflowEngine; + readonly handle: RunHandle; + /** + * Constructs the renderer — called INSIDE, after the SIGINT handler is registered, so a renderer-construction + * throw (e.g. `ink`'s `render()`) still unwinds through the same finally (cancel the live run, remove the + * listener) instead of leaving a running engine with no cooperative-cancel handler. + */ + readonly makeRenderer: () => RunRenderer; + /** Present only in the interactive TUI path; absent (or `undefined`) → a human-gate pause breaks to the + * gate-paused exit `3` (the callers pass `selectGatePrompter(...)`, which is `GatePrompter | undefined`). */ + readonly gatePrompter?: GatePrompter | undefined; + readonly io: CliIo; +} + +/** + * Drive a live run's event stream to a terminal/paused outcome — the SHARED core of `relavium run` (a fresh + * `engine.start`) and `relavium gate` (a resumed `engine.resumeFromCheckpoint`), so the two never fork the + * event loop, the SIGINT contract, or the renderer teardown (2.G). + * + * It: registers the cooperative-cancel SIGINT handler (`process.on`, never `once` — see below), feeds every + * event to the renderer, resolves an interactive human gate **inline** when a {@link GatePrompter} is present + * (suspend the TUI → prompt → `engine.resume` → re-mount, all on the same in-memory run), and on a + * non-interactive pause breaks to the gate-paused exit. The renderer is finalized even on a throw, and the + * SIGINT handler removed LAST — so `ink`'s `signal-exit` never becomes the sole SIGINT listener mid-teardown + * (which would re-raise → exit 130; the 2.E Ctrl-C contract). + */ +export async function driveRun(deps: DriveRunDeps): Promise { + const { engine, handle, makeRenderer, gatePrompter, io } = deps; + let outcome: RunOutcome | undefined; + let cancelRequested = false; + /** gateIds a prompter handled inline (resolved OR cancelled) — so a stale aggregate `run:paused` is ignored. */ + const handledGates = new Set(); + + // Register the cancel handler the instant the run is live — BEFORE constructing the renderer — so a failure + // building the renderer (ink's `render()` throwing) can never leave a running engine with no cooperative-cancel + // handler; a Ctrl-C in that window still routes to handle.cancel(). The finally removes it, so it can't leak. + const onSigint = (): void => { + if (cancelRequested) { + // A second Ctrl-C while the cooperative cancel is still draining — force a clean, deterministic exit + // rather than hang (e.g. a provider ignoring the abort), and never the bare-signal 130. + process.exit(EXIT_CODES.workflowFailed); + } + cancelRequested = true; + handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal) + }; + // `process.on`, NOT `process.once`: an interactive run mounts ink, which registers a `signal-exit` SIGINT + // listener that RE-RAISES SIGINT (→ 128+2 = exit 130) BUT ONLY when it is the sole remaining SIGINT listener. + // A `once` handler removes itself the instant it fires, so signal-exit then sees only itself and re-raises — + // killing the run at 130 before the cooperative cancel completes. Staying registered keeps signal-exit from + // re-raising, so cancel → run:cancelled → exit 1 wins. Removed in the finally. + process.on('SIGINT', onSigint); + let renderer: RunRenderer | undefined; + try { + renderer = makeRenderer(); + for await (const event of handle.events) { + renderer.onEvent(event); + outcome = nextOutcome(outcome, event); + + if (event.type === 'human_gate:paused' && gatePrompter !== undefined) { + handledGates.add(event.gateId); + await resolveGateInline(engine, handle, renderer, gatePrompter, event); + continue; // a resolve continues the run; a cancel drains it to run:cancelled — keep consuming either way + } + if (event.type === 'run:paused') { + if (shouldBreakOnPause(event, gatePrompter !== undefined, handledGates)) { + break; + } + continue; // every gate was handled inline (and no media park) — the resumed run continues to its terminal + } + } + } finally { + // No terminal/paused outcome means we're unwinding abnormally (renderer construction threw, or the event + // stream rejected) — cancel the still-live run so it doesn't keep executing unsupervised while the error + // propagates (cancel is idempotent + safe post-terminal). + if (outcome === undefined) { + handle.cancel(); + } + // Tear the renderer down even on a throw: the ink TUI must unmount to restore the terminal and write its + // persistent final summary. The `?.` is a no-op for the line/NDJSON renderers and when `renderer` is still + // undefined (construction threw). A teardown error must NOT mask the run's real outcome/error — surface it + // to stderr and move on. + try { + await renderer?.finalize?.(); + } catch (teardownErr) { + io.writeErr( + `renderer teardown failed: ${teardownErr instanceof Error ? teardownErr.message : String(teardownErr)}\n`, + ); + } + // Remove our SIGINT handler LAST — keep it registered across ink's unmount (renderer.finalize) so a Ctrl-C + // during unmount still hits us (forcing a clean exit 1) and ink's `signal-exit` never becomes the sole SIGINT + // listener (which would re-raise → 130). After finalize, ink has unsubscribed its own listener. + process.removeListener('SIGINT', onSigint); + } + return outcome; +} + +/** + * Resolve an interactive human gate without leaving the live run: hand the terminal to the `@clack/prompts` + * card (suspend the TUI), collect a decision, then re-mount. A `null` decision (the user aborted the prompt + * with Ctrl-C / ESC) cooperatively cancels the whole run; otherwise the decision is applied to the in-memory + * run via `engine.resume`, which emits `human_gate:resumed` and continues — both flow back through the loop. + */ +async function resolveGateInline( + engine: WorkflowEngine, + handle: RunHandle, + renderer: RunRenderer, + prompter: GatePrompter, + event: HumanGatePausedEvent, +): Promise { + let decision: Awaited>; + await renderer.suspend?.(); + try { + decision = await prompter.prompt(event); + } finally { + await renderer.resume?.(); + } + if (decision === null) { + handle.cancel(); + return; + } + await engine.resume(event.runId, event.gateId, decision); +} + +/** + * Whether an aggregate `run:paused` should stop the loop (→ gate-paused exit `3`). With no prompter (CI / + * `--json` / no-TTY) it always stops. With a prompter, every human gate was already resolved inline by the + * time its aggregate pause arrives (events are delivered in `sequenceNumber` order), so the pause is + * informational — UNLESS it carries a gate we never handled or a media-job park, neither resolvable inline. + */ +export function shouldBreakOnPause( + event: RunPausedEvent, + hasPrompter: boolean, + handledGates: ReadonlySet, +): boolean { + if (!hasPrompter) { + return true; + } + const mediaPark = (event.pendingMediaJobNodeIds?.length ?? 0) > 0; + const unhandledGate = event.gateIds.some((gateId) => !handledGates.has(gateId)); + return mediaPark || unhandledGate; +} + +function nextOutcome(current: RunOutcome | undefined, event: RunEvent): RunOutcome | undefined { + switch (event.type) { + case 'run:completed': + return 'completed'; + case 'run:failed': + return 'failed'; + case 'run:cancelled': + return 'cancelled'; + case 'run:paused': + return 'paused'; + default: + return current; + } +} diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts new file mode 100644 index 00000000..85b9c131 --- /dev/null +++ b/apps/cli/src/commands/gate.test.ts @@ -0,0 +1,305 @@ +import { randomUUID } from 'node:crypto'; + +import { parseWorkflow, type CheckpointState } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + loadRunSnapshot, + runMigrations, + type Db, + type DbClient, +} from '@relavium/db'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { buildEngine } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { isCliError } from '../process/errors.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo } from '../test-support.js'; +import { gateCommand, selectGate, type GateCommandDeps } from './gate.js'; + +// gate → double (reads inputs.n, restored on resume) → out. The in-memory host pauses at the fail-closed gate. +const GATED = `schema_version: '1.0' +workflow: + id: gate-resume + inputs: + - { name: n, type: number } + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: double, type: transform, transform: '({ d: inputs.n * 2 })' } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: double } + - { from: double, to: out } +`; + +// Two gates on parallel branches → a multi-gate pause that requires --gate to disambiguate. +const TWO_GATES = `schema_version: '1.0' +workflow: + id: gate-two + nodes: + - { id: start, type: input } + - { id: g1, type: human_gate, gate_type: approval } + - { id: g2, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g1 } + - { from: start, to: g2 } + - { from: g1, to: out } + - { from: g2, to: out } +`; + +function globalOptions(): GlobalOptions { + return { + json: false, + color: false, + cwd: process.cwd(), + configPath: undefined, + verbosity: 'normal', + }; +} + +describe('gateCommand', () => { + let client: DbClient; + let db: Db; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + db = client.db; + }); + afterEach(() => { + client.sqlite.close(); + }); + + /** The injected deps for a resume over the SHARED in-memory db (no-op close — the db spans the whole test). */ + function deps(io: ReturnType['io']): GateCommandDeps { + return { io, global: globalOptions(), openDb: () => ({ db, close: () => {} }) }; + } + + /** Drive a gated workflow to its pause against the db store (persisting run:started → run:paused). */ + async function setupPausedRun( + yaml = GATED, + inputs: Record = { n: 7 }, + ): Promise<{ runId: string; gateIds: string[] }> { + const def = parseWorkflow(yaml, { source: 'gate.test' }); + const store = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: def.workflow.id, + name: def.workflow.name ?? def.workflow.id, + definitionJson: JSON.stringify(def), + }, + }); + const engine = await buildEngine({ host: createCliHost(store) }); + const handle = engine.start({ workflow: def, inputs }); + let runId = ''; + const gateIds: string[] = []; + for await (const event of handle.events) { + if (event.type === 'run:started') runId = event.runId; + if (event.type === 'human_gate:paused') gateIds.push(event.gateId); + if (event.type === 'run:paused') break; + } + return { runId, gateIds }; + } + + it('resumes a paused run on --approve, drives it to completion (exit 0), and persists the decision', async () => { + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + + const code = await gateCommand({ runId, approve: true }, deps(io)); + + expect(code).toBe(EXIT_CODES.success); + expect(loadRunSnapshot(db, runId)?.status).toBe('completed'); // the cross-process resume reached the terminal + const store = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, + }); + const events = store.loadRunEvents(runId); + expect(events.find((e) => e.type === 'human_gate:resumed')).toMatchObject({ + decision: 'approved', + decidedBy: 'cli', + }); + // Inputs were RESTORED across the process boundary: the post-gate `double` node read inputs.n (=7) and + // produced d=14. A lost-inputs regression would compute `undefined * 2` = NaN instead. + const doubled = events.find((e) => e.type === 'node:completed' && e.nodeId === 'double'); + expect(doubled).toMatchObject({ output: { d: 14 } }); + }); + + it('surfaces a corrupt stored inputs blob as a clean exit-2 fault (no silent empty-inputs resume)', async () => { + const { runId } = await setupPausedRun(); + // Corrupt the persisted input_json to a non-JSON blob (simulating a damaged store row). + client.sqlite.prepare('UPDATE runs SET input_json = ? WHERE id = ?').run('{not json', runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + + it('resumes on --reject, persisting the rejection + comment', async () => { + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + const code = await gateCommand({ runId, reject: true, comment: 'not now' }, deps(io)); + expect(code).toBe(EXIT_CODES.success); + const store = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, + }); + expect(store.loadRunEvents(runId).find((e) => e.type === 'human_gate:resumed')).toMatchObject({ + decision: 'rejected', + }); + }); + + it('is idempotent: a doubled decision on an already-completed run is a clean exit-0 no-op (no double-advance)', async () => { + const { runId } = await setupPausedRun(); + const { io, out } = captureIo(); + const reader = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, + }); + await gateCommand({ runId, approve: true }, deps(io)); // first resume → completes + const eventsAfterFirst = reader.loadRunEvents(runId).length; + + const code = await gateCommand({ runId, approve: true }, deps(io)); // second decision + expect(code).toBe(EXIT_CODES.success); + expect(out()).toContain('nothing to resume'); // idempotent message, not a re-run + expect(reader.loadRunEvents(runId).length).toBe(eventsAfterFirst); // NO new events — not advanced twice + }); + + it('rejects an unknown runId (exit 2)', async () => { + const { io } = captureIo(); + let caught: unknown; + try { + await gateCommand({ runId: 'no-such-run', approve: true }, deps(io)); + } catch (err) { + caught = err; + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) expect(caught.code).toBe('invalid_invocation'); + }); + + it('rejects an invalid flag combination before touching the db (exit 2)', async () => { + const { io } = captureIo(); + await expect(gateCommand({ runId: 'any' }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + await expect( + gateCommand({ runId: 'any', approve: true, reject: true }, deps(io)), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + }); + + it('requires --gate when more than one gate is pending, then resolves the named one', async () => { + const { runId, gateIds } = await setupPausedRun(TWO_GATES, {}); + expect(gateIds.length).toBeGreaterThan(1); + const firstGate = gateIds[0]; + if (firstGate === undefined) throw new Error('expected a pending gate'); + const { io } = captureIo(); + + // No --gate → ambiguous → exit 2 listing the pending ids. + let caught: unknown; + try { + await gateCommand({ runId, approve: true }, deps(io)); + } catch (err) { + caught = err; + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain(firstGate); + } + + // Naming the first gate resolves it; the run pauses again at the second → exit 3 (resume that one next). + const code = await gateCommand({ runId, approve: true, gate: firstGate }, deps(io)); + expect(code).toBe(EXIT_CODES.gatePaused); + }); + + it('treats a re-approval of an already-resolved gate as an idempotent no-op (exit 0)', async () => { + const { runId, gateIds } = await setupPausedRun(TWO_GATES, {}); + const firstGate = gateIds[0]; + if (firstGate === undefined) throw new Error('expected a pending gate'); + const { io, out } = captureIo(); + await gateCommand({ runId, approve: true, gate: firstGate }, deps(io)); // resolve g1 (run pauses at g2) + const code = await gateCommand({ runId, approve: true, gate: firstGate }, deps(io)); // re-approve g1 + expect(code).toBe(EXIT_CODES.success); + expect(out()).toContain('already resolved'); + }); +}); + +describe('selectGate', () => { + const checkpoint = (over: Partial = {}): CheckpointState => ({ + schemaVersion: 1, + runStatus: 'paused', + workflowId: 'wf', + startedAtMs: 0, + nodeStates: new Map(), + completedNodeIds: [], + pendingGates: [], + pendingMediaJobs: [], + resolvedGateIds: [], + lastSequenceNumber: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + cumulativeCostMicrocents: 0, + ...over, + }); + const gate = (gateId: string, isBudgetGate = false) => ({ gateId, nodeId: gateId, isBudgetGate }); + + it('auto-fills the single pending human gate', () => { + expect(selectGate(checkpoint({ pendingGates: [gate('g1')] }), 'paused', undefined)).toEqual({ + kind: 'resume', + gateId: 'g1', + }); + }); + + it('excludes a budget gate — that is the `relavium budget resume` surface, not a human gate', () => { + // A pending budget gate alone reads as "no human gate", not as something `relavium gate` resolves. + expect( + selectGate(checkpoint({ pendingGates: [gate('b1', true)] }), 'paused', undefined), + ).toEqual({ + kind: 'invalid', + message: 'the run is not paused at a human gate', + }); + }); + + it('treats a terminal run (completed / failed / cancelled) as an idempotent no-op', () => { + expect(selectGate(checkpoint(), 'completed', undefined).kind).toBe('idempotent'); + expect(selectGate(checkpoint(), 'failed', undefined).kind).toBe('idempotent'); + expect(selectGate(checkpoint(), 'cancelled', undefined).kind).toBe('idempotent'); + }); + + it('requires --gate for more than one pending gate, listing the ids', () => { + const sel = selectGate( + checkpoint({ pendingGates: [gate('g1'), gate('g2')] }), + 'paused', + undefined, + ); + expect(sel.kind).toBe('invalid'); + if (sel.kind === 'invalid') { + expect(sel.message).toContain('g1'); + expect(sel.message).toContain('g2'); + } + }); + + it('--gate naming an already-resolved id is idempotent; an unknown id is invalid', () => { + expect(selectGate(checkpoint({ resolvedGateIds: ['g1'] }), 'paused', 'g1').kind).toBe( + 'idempotent', + ); + expect(selectGate(checkpoint({ pendingGates: [gate('g1')] }), 'paused', 'gX').kind).toBe( + 'invalid', + ); + }); + + it('reports "not paused at a human gate" when nothing is pending and nothing was resolved', () => { + expect(selectGate(checkpoint(), 'running', undefined)).toEqual({ + kind: 'invalid', + message: 'the run is not paused at a human gate', + }); + }); +}); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts new file mode 100644 index 00000000..72bda826 --- /dev/null +++ b/apps/cli/src/commands/gate.ts @@ -0,0 +1,270 @@ +import { randomUUID } from 'node:crypto'; + +import { + EngineStateError, + type CheckpointState, + type WorkflowDefinition, + type WorkflowEngine, +} from '@relavium/core'; +import { createRunHistoryStore, loadRunSnapshot, type Db } from '@relavium/db'; +import { WorkflowSchema, type RunStatus } from '@relavium/shared'; + +import { loadResolvedConfig } from '../config/load.js'; +import { openLocalDb } from '../db/open.js'; +import { + buildEngine as defaultBuildEngine, + type BuildEngineOptions, +} from '../engine/build-engine.js'; +import { createHistoryCheckpointer } from '../engine/checkpointer.js'; +import { createCliHost } from '../engine/host.js'; +import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; +import { decisionFromFlags, type GateFlags } from '../gate/decision.js'; +import type { GatePrompter } from '../gate/prompter.js'; +import { selectGatePrompter } from '../gate/select-prompter.js'; +import { CliError } from '../process/errors.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; +import type { RunRenderer } from '../render/renderer.js'; +import { selectRenderer } from '../render/select.js'; +import { driveRun } from './drive.js'; + +export interface GateCommandArgs extends GateFlags { + readonly runId: string; + /** `--gate `: which pending gate to resolve (required only when more than one is pending). */ + readonly gate?: string; +} + +export interface GateCommandDeps { + readonly io: CliIo; + readonly global: GlobalOptions; + /** Injectable so tests drive a stub provider + the in-memory host engine. */ + readonly buildEngine?: (options?: BuildEngineOptions) => Promise; + /** Injectable provider seam (the engine's resolver for a post-gate agent node). Defaults to the env resolver. */ + readonly providers?: ProviderResolver; + /** Injectable history-db opener — tests pass an in-memory db; production opens `~/.relavium/history.db`. */ + readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; + readonly selectGatePrompter?: (io: CliIo, global: GlobalOptions) => GatePrompter | undefined; +} + +const TERMINAL_STATUSES: ReadonlySet = new Set(['completed', 'failed', 'cancelled']); + +/** + * The `relavium gate` core (**2.G**) — resolve a pending human gate from the terminal, the surface-agnostic + * resume path for a `human_gate:paused` run (an interactive run that paused, or a CI run that exited `3`). It + * runs in a **fresh process** from the original `relavium run`, so it rebuilds the run's `WorkflowDefinition` + * + inputs from the durable snapshot (2.H), reconstructs the checkpoint from the persisted event log + * (`createHistoryCheckpointer`), and calls `engine.resumeFromCheckpoint` over the same store — then drives the + * resumed run to its terminal through the shared {@link driveRun} core. + * + * Resume is **idempotent**: a doubled decision (the run already finished, or this gate was already resolved) is + * a clean exit-`0` no-op, never a double-advance — leaning on the engine's checkpoint/gate-state idempotency. + * Flag/lookup faults are typed {@link CliError}s (exit `2`); run-time outcomes map to `0`/`1`/`3`. + */ +export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): Promise { + // Validate the resolution flags FIRST (cheap, before touching the db): exactly one of --approve/--reject/ + // --input, mutually exclusive, --comment not with --input. + const flags = decisionFromFlags(args); + if (!flags.ok) { + throw new CliError('invalid_invocation', flags.error); + } + const decision = flags.decision; + + const { homeDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + let opened: { db: Db; close: () => void }; + try { + opened = (deps.openDb ?? openLocalDb)(homeDir); + } catch (err) { + throw new CliError( + 'invalid_invocation', + `could not open the run history database: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + + try { + const snapshot = loadRunSnapshot(opened.db, args.runId); + if (snapshot === undefined) { + throw new CliError('invalid_invocation', `no run found with id ${args.runId}`); + } + + const workflow = parseSnapshot(snapshot.workflowDefinitionSnapshot, args.runId); + const inputs = parseInputs(snapshot.inputJson, args.runId); + + // The workflow-scoped store records the NEW resume events (persist-before-deliver) and resolves the + // workflow id for the engine's identity guard; the checkpointer reconstructs the paused state from the log. + const store = createRunHistoryStore(opened.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.name ?? workflow.workflow.id, + definitionJson: snapshot.workflowDefinitionSnapshot, + }, + }); + const checkpointer = createHistoryCheckpointer(store); + const checkpoint = await checkpointer.load(args.runId); + if (checkpoint === undefined) { + // A run row with a snapshot but no reconstructable checkpoint (no run:started in the log) — corrupt/partial. + throw new CliError('invalid_invocation', `run ${args.runId} has no resumable state`); + } + + // The reconstructed `checkpoint.runStatus` is the authoritative run state (folded fresh from the event log + // right here), so it drives the terminal/idempotency decision — not the `runs.status` column, which a + // racing process could have advanced between the snapshot read and now (and which the engine would then + // surface as a closed-handle resume → a misleading exit 1). + const selection = selectGate(checkpoint, checkpoint.runStatus, args.gate); + if (selection.kind === 'invalid') { + throw new CliError('invalid_invocation', selection.message); + } + if (selection.kind === 'idempotent') { + // A doubled decision (run finished / gate already resolved) — a clean no-op, NOT a double-advance. + deps.io.writeOut(`${selection.message}\n`); + return EXIT_CODES.success; + } + + const providers = deps.providers ?? createProviderResolver(deps.io.env); + const engine = await (deps.buildEngine ?? defaultBuildEngine)({ + providers, + host: createCliHost(store, { checkpointer }), + }); + let handle; + try { + handle = await engine.resumeFromCheckpoint({ + runId: args.runId, + workflow, + inputs, + gateId: selection.gateId, + decision, + }); + } catch (err) { + // A typed engine refusal (e.g. workflow_mismatch on a corrupt store) is an invalid invocation, not an + // unhandled crash — surface it cleanly as exit 2 with the engine's reason. + if (err instanceof EngineStateError) { + throw new CliError( + 'invalid_invocation', + `cannot resume run ${args.runId}: ${err.message}`, + { + cause: err, + }, + ); + } + throw err; + } + + const outcome = await driveRun({ + engine, + handle, + makeRenderer: () => (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global), + gatePrompter: (deps.selectGatePrompter ?? selectGatePrompter)(deps.io, deps.global), + io: deps.io, + }); + + switch (outcome) { + case 'completed': + return EXIT_CODES.success; + case 'paused': + // The resumed run paused again at a LATER gate — resume that one next (exit 3, like a fresh CI pause). + return EXIT_CODES.gatePaused; + default: + return EXIT_CODES.workflowFailed; + } + } finally { + opened.close(); + } +} + +/** The chosen gate, or a typed disposition: an idempotent no-op (exit 0) or an invalid invocation (exit 2). */ +type GateSelection = + | { readonly kind: 'resume'; readonly gateId: string } + | { readonly kind: 'idempotent'; readonly message: string } + | { readonly kind: 'invalid'; readonly message: string }; + +/** + * Choose which gate to resume — or report an idempotent no-op / invalid invocation. Budget gates + * (`isBudgetGate`) are excluded: they are the `relavium budget resume` surface (ADR-0028), not a human gate. + * Auto-fills when exactly one human gate is pending; requires `--gate` to disambiguate more than one. + */ +export function selectGate( + checkpoint: CheckpointState, + status: RunStatus, + requested: string | undefined, +): GateSelection { + if (TERMINAL_STATUSES.has(status)) { + return { kind: 'idempotent', message: `run ${status}; nothing to resume` }; + } + const pending = checkpoint.pendingGates.filter((gate) => !gate.isBudgetGate); + const resolved = new Set(checkpoint.resolvedGateIds); + + if (requested !== undefined) { + if (pending.some((gate) => gate.gateId === requested)) { + return { kind: 'resume', gateId: requested }; + } + if (resolved.has(requested)) { + return { kind: 'idempotent', message: `gate ${requested} already resolved` }; + } + return { + kind: 'invalid', + message: `no pending gate ${requested} on run${pendingList(pending)}`, + }; + } + if (pending.length === 0) { + return resolved.size > 0 + ? { kind: 'idempotent', message: 'the run has no pending human gate (already resolved)' } + : { kind: 'invalid', message: 'the run is not paused at a human gate' }; + } + const [only, ...rest] = pending; + if (rest.length === 0 && only !== undefined) { + return { kind: 'resume', gateId: only.gateId }; // exactly one human gate pending → auto-fill + } + return { + kind: 'invalid', + message: `more than one gate is pending — pass --gate :${pendingList(pending)}`, + }; +} + +function pendingList(pending: readonly { readonly gateId: string }[]): string { + return pending.length === 0 ? '' : ` (pending: ${pending.map((gate) => gate.gateId).join(', ')})`; +} + +/** Re-validate the frozen snapshot JSON against the shared schema — a corrupt snapshot is an exit-2 fault. */ +function parseSnapshot(snapshotJson: string, runId: string): WorkflowDefinition { + try { + return WorkflowSchema.parse(JSON.parse(snapshotJson)); + } catch (err) { + throw new CliError( + 'invalid_invocation', + `the stored workflow snapshot for run ${runId} could not be parsed`, + { cause: err }, + ); + } +} + +/** + * Restore the run's inputs from the frozen `input_json`. A corrupt / non-object value is an exit-2 fault + * (matching {@link parseSnapshot}) — never a silent `{}`, which would resume with every `{{ inputs.x }}` + * evaluating to `undefined` and fail confusingly at a downstream node instead of cleanly up front. + */ +function parseInputs(inputJson: string, runId: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(inputJson); + } catch (err) { + throw new CliError( + 'invalid_invocation', + `the stored inputs for run ${runId} could not be parsed`, + { cause: err }, + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new CliError( + 'invalid_invocation', + `the stored inputs for run ${runId} are not a JSON object`, + ); + } + return parsed as Record; +} diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 2bccfa82..d27c89c2 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildEngine } from '../engine/build-engine.js'; import { createProviderResolver } from '../engine/providers.js'; +import type { GatePrompter } from '../gate/prompter.js'; import { isCliError } from '../process/errors.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; @@ -337,6 +338,21 @@ describe('runCommand', () => { expect(out()).toContain('(approval)'); }); + it('threads an interactive gate prompter through to driveRun: an approved inline gate completes (exit 0)', async () => { + // When a prompter is present (the interactive TTY path), `run` resolves the gate INLINE rather than + // exiting 3 — proving run.ts wires selectGatePrompter into the shared driveRun core (2.G). + const path = writeWorkflow('gated.relavium.yaml', GATED); + const { io } = captureIo(); + const prompter: GatePrompter = { + prompt: () => Promise.resolve({ decision: 'approved', decidedBy: 'cli' }), + }; + const code = await runCommand( + { workflow: path, input: [] }, + deps(io, globalOptions(), { selectGatePrompter: () => prompter }), + ); + expect(code).toBe(EXIT_CODES.success); // the inline prompt resolved the gate; the run continued to completion + }); + it('forwards SIGINT as a cooperative cancel → run:cancelled → exit 1, leaking no SIGINT listener', async () => { const path = writeWorkflow('stall.relavium.yaml', STALL); const { io, out } = captureIo(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 559a7be0..e2b69f3e 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -6,7 +6,6 @@ import { type WorkflowDefinition, type WorkflowEngine, } from '@relavium/core'; -import type { RunEvent } from '@relavium/shared'; import { loadResolvedConfig } from '../config/load.js'; import { @@ -19,6 +18,8 @@ import { neededProviderIds, type ProviderResolver, } from '../engine/providers.js'; +import type { GatePrompter } from '../gate/prompter.js'; +import { selectGatePrompter } from '../gate/select-prompter.js'; import type { OpenedHistory } from '../history/open.js'; import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; @@ -27,6 +28,7 @@ import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; +import { driveRun } from './drive.js'; import { parseInputArgs, resolveInputs } from './inputs.js'; export interface RunCommandArgs { @@ -51,17 +53,22 @@ export interface RunCommandDeps { * inject a fake renderer (onEvent + finalize spies) to assert the finalize wiring without a TTY. */ readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; + /** + * Injectable interactive gate-prompter selector (2.G). Defaults to the real {@link selectGatePrompter} + * (present only on an interactive TTY); tests inject a fake prompter to drive the inline gate-resolve path + * without a TTY, or omit it so a gate pause exits 3 like the non-interactive path. + */ + readonly selectGatePrompter?: (io: CliIo, global: GlobalOptions) => GatePrompter | undefined; } -type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused'; - /** * The `relavium run` core (2.D) — the M3 keystone and first real consumer of `@relavium/core`: - * resolve + parse the workflow, coerce/validate `--input`, build the engine, drive its event stream - * through a renderer, forward SIGINT as a cooperative cancel, and map the terminal outcome to a - * deterministic exit code ([commands.md](../../../docs/reference/cli/commands.md#exit-codes)). - * Framework-free — no commander/ink import. Pre-run faults (config / not-found / bad input / parse) - * throw a typed {@link CliError} (exit 2); run-time outcomes arrive as events and map to 0/1/3. + * resolve + parse the workflow, coerce/validate `--input`, build the engine, then hand the live run to the + * shared {@link driveRun} core (event stream → renderer, SIGINT → cooperative cancel, interactive human gate → + * inline prompt, 2.G) and map the terminal outcome to a deterministic exit code + * ([commands.md](../../../docs/reference/cli/commands.md#exit-codes)). Framework-free — no commander/ink import. + * Pre-run faults (config / not-found / bad input / parse) throw a typed {@link CliError} (exit 2); run-time + * outcomes arrive as events and map to 0/1/3. */ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Promise { const build = deps.buildEngine ?? defaultBuildEngine; @@ -122,67 +129,18 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr ); const handle = engine.start({ workflow: def, inputs }); - let outcome: RunOutcome | undefined; - let cancelRequested = false; - // Register the cancel handler the instant the engine is live — BEFORE constructing the renderer — so a - // failure building the renderer (e.g. ink's `render()` throwing) can never leave a running engine with no - // cooperative-cancel handler; a Ctrl-C in that window still routes to handle.cancel(). The `finally` - // removes it, so the listener can't leak on a throw. - const onSigint = (): void => { - if (cancelRequested) { - // A second Ctrl-C while the cooperative cancel is still draining — force a clean, deterministic exit - // rather than hang (e.g. if a provider ignores the abort), and never the bare-signal 130. - process.exit(EXIT_CODES.workflowFailed); - } - cancelRequested = true; - handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal) - }; - // `process.on`, NOT `process.once`: an interactive run mounts ink, which registers a `signal-exit` SIGINT - // listener that RE-RAISES SIGINT (→ 128+2 = exit 130) BUT ONLY when it is the sole remaining SIGINT - // listener. A `once` handler removes itself the instant it fires, so signal-exit then sees only itself and - // re-raises — killing the run at 130 before the cooperative cancel completes. Staying registered keeps - // signal-exit from re-raising, so cancel → run:cancelled → exit 1 wins. Removed in the `finally`. - process.on('SIGINT', onSigint); - let renderer: RunRenderer | undefined; - try { - // Output mode (commands.md "Output modes"): the ink TUI on an interactive TTY, NDJSON under --json, - // the plain line renderer otherwise — all the same `onEvent` seam over one bus (2.F / 2.K). - renderer = (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global); - for await (const event of handle.events) { - renderer.onEvent(event); - outcome = nextOutcome(outcome, event); - if (event.type === 'run:paused') { - // A human gate — the only `run:paused` source in 2.D (no media-job host is wired; build-engine.ts). - // The interactive prompt + `relavium gate` resume are 2.G (the persisted gate state is now durable, - // 2.H); for now the run exits with the gate-paused code. When media host-wiring lands (2.S) a - // media-only park is also a `run:paused`, so revisit whether exit 3 should distinguish it then. - break; - } - } - } finally { - // No terminal/paused outcome means we're unwinding abnormally (renderer construction threw, or the - // event stream rejected) — cancel the still-live engine run so it doesn't keep executing unsupervised - // in the background while the error propagates (cancel is idempotent + safe post-terminal). - if (outcome === undefined) { - handle.cancel(); - } - // Tear the renderer down even on a throw: the ink TUI must unmount to restore the terminal and write - // its persistent final summary. The `?.` is a no-op for the line/NDJSON renderers and when `renderer` - // is still undefined (construction threw). A teardown error must NOT mask the run's real - // outcome/error — surface it to stderr and move on. - try { - await renderer?.finalize?.(); - } catch (teardownErr) { - deps.io.writeErr( - `renderer teardown failed: ${teardownErr instanceof Error ? teardownErr.message : String(teardownErr)}\n`, - ); - } - // Remove our SIGINT handler LAST — keep it registered across ink's unmount (renderer.finalize), so a - // Ctrl-C during unmount still hits us (forcing a clean exit 1) and ink's `signal-exit` never becomes the - // sole SIGINT listener (which would re-raise → 130). After finalize, ink has unsubscribed its own - // listener, so removing ours here leaves the SIGINT set clean. - process.removeListener('SIGINT', onSigint); - } + // Hand the live run to the shared driver (2.G): it owns the event loop, the SIGINT cooperative-cancel + // contract, the renderer lifecycle (constructed inside, after SIGINT registration — output mode per + // commands.md "Output modes": ink TUI on a TTY, NDJSON under --json, plain otherwise), and the inline + // human-gate prompt when an interactive prompter is present (CI / --json / no-TTY → no prompter → a gate + // pause exits 3, resumable by `relavium gate`). + const outcome = await driveRun({ + engine, + handle, + makeRenderer: () => (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global), + gatePrompter: (deps.selectGatePrompter ?? selectGatePrompter)(deps.io, deps.global), + io: deps.io, + }); switch (outcome) { case 'completed': @@ -197,18 +155,3 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr opened?.close(); } } - -function nextOutcome(current: RunOutcome | undefined, event: RunEvent): RunOutcome | undefined { - switch (event.type) { - case 'run:completed': - return 'completed'; - case 'run:failed': - return 'failed'; - case 'run:cancelled': - return 'cancelled'; - case 'run:paused': - return 'paused'; - default: - return current; - } -} diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index 5aa083fb..f4ffb2b8 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -13,6 +13,7 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; +import { gateCommand } from './gate.js'; import { runProviderCommand, type ProviderCommandArgs, @@ -22,11 +23,11 @@ import { runCommand } from './run.js'; /** * The documented command surface (canonical home: - * [commands.md](../../../../docs/reference/cli/commands.md)). `run` is the real command (2.D); the - * remaining confirmed pre-chat commands are registered as clean "not-yet-available" stubs until their - * own workstreams (the read commands at 2.I, the authoring commands at 2.J, the gate at 2.G, keys at - * 2.C). The chat family (`chat`/`chat-resume`/`chat-list`/`chat-export`/`agent run`) and the - * `gate list` / `budget resume` subcommands land with their workstreams (2.M–2.Q, 2.G), not here. + * [commands.md](../../../../docs/reference/cli/commands.md)). `run` (2.D), `gate` (2.G), and `provider` (2.C) + * are real commands; the remaining confirmed pre-chat commands are registered as clean "not-yet-available" + * stubs until their own workstreams (the read commands at 2.I, the authoring commands at 2.J). The chat family + * (`chat`/`chat-resume`/`chat-list`/`chat-export`/`agent run`) and the `gate list` / `budget resume` + * subcommands land with their workstreams (2.M–2.Q, 2.I), not here. */ /** The runtime context the real commands need; the boundary reads `result.exitCode` after parse. */ @@ -58,11 +59,6 @@ const STUB_COMMANDS: readonly StubSpec[] = [ summary: 'Show active/paused runs and their per-node status.', landsIn: 'workstream 2.I', }, - { - name: 'gate ', - summary: 'Resolve a pending human gate (approve / reject / input).', - landsIn: 'workstream 2.G', - }, { name: 'create', summary: 'Scaffold a new workflow or agent via an interactive wizard.', @@ -88,6 +84,7 @@ const STUB_COMMANDS: readonly StubSpec[] = [ export function registerCommands(program: Command, ctx?: CommandContext): void { registerRun(program, ctx); + registerGate(program, ctx); registerProvider(program, ctx); for (const spec of STUB_COMMANDS) { program @@ -131,6 +128,58 @@ function registerRun(program: Command, ctx?: CommandContext): void { }); } +/** Register `relavium gate ` (2.G) — resolve a pending human gate over the durable resume substrate. */ +function registerGate(program: Command, ctx?: CommandContext): void { + const gate = program + .command('gate ') + .description('Resolve a pending human gate (approve / reject / input).') + .option('--approve', 'approve the gate') + .option('--reject', 'reject the gate') + .option('--comment ', 'a decision comment (with --approve / --reject)') + .option('--input ', 'provide input for a gate_type=input gate (JSON, else a raw string)') + .option( + '--gate ', + 'which pending gate to resolve (required when more than one is pending)', + ); + + if (ctx === undefined) { + gate.action(() => { + throw new CliError('not_implemented', '`relavium gate` requires the CLI runtime context.'); + }); + return; + } + + gate.action( + async ( + runId: string, + opts: { + approve?: boolean; + reject?: boolean; + comment?: string; + input?: string; + gate?: string; + }, + ) => { + ctx.result.exitCode = await gateCommand( + { + runId, + ...(opts.approve === undefined ? {} : { approve: opts.approve }), + ...(opts.reject === undefined ? {} : { reject: opts.reject }), + ...(opts.comment === undefined ? {} : { comment: opts.comment }), + ...(opts.input === undefined ? {} : { input: opts.input }), + ...(opts.gate === undefined ? {} : { gate: opts.gate }), + }, + { + io: ctx.io, + global: ctx.global, + // Production resolves a post-gate agent's key via the OS keychain → env var (2.C), like `run`. + providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + }, + ); + }, + ); +} + /** Register `relavium provider` and its subcommands (2.C). Each opens the local db + keychain per invocation. */ function registerProvider(program: Command, ctx?: CommandContext): void { const provider = program diff --git a/apps/cli/src/engine/checkpointer.test.ts b/apps/cli/src/engine/checkpointer.test.ts new file mode 100644 index 00000000..35b00363 --- /dev/null +++ b/apps/cli/src/engine/checkpointer.test.ts @@ -0,0 +1,88 @@ +import { + createClient, + createRunHistoryStore, + runMigrations, + type DbClient, + type RunHistoryStore, + type RunHistoryWorkflow, +} from '@relavium/db'; +import type { RunEvent } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createHistoryCheckpointer } from './checkpointer.js'; + +const TS = '2026-06-24T10:00:00.000Z'; +const WORKFLOW: RunHistoryWorkflow = { + slug: 'demo', + name: 'Demo', + definitionJson: JSON.stringify({ workflow: { id: 'demo', name: 'Demo', nodes: [], edges: [] } }), +}; + +describe('createHistoryCheckpointer', () => { + let client: DbClient; + let store: RunHistoryStore; + let n: number; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + n = 0; + store = createRunHistoryStore(client.db, { + uuid: () => `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`, + now: () => new Date(TS).getTime(), + workflow: WORKFLOW, + }); + }); + + afterEach(() => { + client.sqlite.close(); + }); + + it('reconstructs a paused run`s checkpoint from the durable event log (the 2.G read path)', async () => { + const wf = await store.resolveWorkflowId('demo'); + // A realistic pause emits the per-gate `human_gate:paused` AND the aggregate `run:paused` (the latter is + // what folds the checkpoint's run-level status to 'paused'; the former adds the pending gate + node state). + const events: RunEvent[] = [ + { + type: 'run:started', + runId: 'run-1', + timestamp: TS, + sequenceNumber: 0, + workflowId: wf, + inputs: {}, + executionMode: 'local', + }, + { + type: 'human_gate:paused', + runId: 'run-1', + timestamp: TS, + sequenceNumber: 1, + nodeId: 'gate', + gateId: 'g1', + gateType: 'approval', + message: 'ship it?', + }, + { + type: 'run:paused', + runId: 'run-1', + timestamp: TS, + sequenceNumber: 2, + pendingGateCount: 1, + gateIds: ['g1'], + }, + ]; + for (const event of events) { + await store.persistEvent(event); + } + + const checkpoint = await createHistoryCheckpointer(store).load('run-1'); + expect(checkpoint?.runStatus).toBe('paused'); + expect(checkpoint?.workflowId).toBe(wf); + expect(checkpoint?.pendingGates.map((g) => g.gateId)).toEqual(['g1']); + expect(checkpoint?.resolvedGateIds).toEqual([]); + }); + + it('returns undefined for a run with no persisted run:started (unknown / never-persisted)', async () => { + expect(await createHistoryCheckpointer(store).load('nope')).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/engine/checkpointer.ts b/apps/cli/src/engine/checkpointer.ts new file mode 100644 index 00000000..44534abb --- /dev/null +++ b/apps/cli/src/engine/checkpointer.ts @@ -0,0 +1,17 @@ +import { reconstructCheckpointState, type Checkpointer } from '@relavium/core'; +import type { RunHistoryStore } from '@relavium/db'; + +/** + * A {@link Checkpointer} that rebuilds a run's `CheckpointState` from the durable event log (2.H) — the read + * side of cross-process gate resume (**2.G**). `engine.resumeFromCheckpoint(runId, …)` calls + * `host.checkpointer.load(runId)` to seed the rehydrated run; for the SQLite {@link RunHistoryStore} that is + * `loadRunEvents(runId)` folded through {@link reconstructCheckpointState} — the **same** 1.R reconstruction + * the engine's in-memory checkpointer uses, but over persisted rows instead of an in-process buffer (so a + * `relavium gate` in a fresh process sees the state a prior `relavium run` left behind). Returns `undefined` + * for a run with no persisted `run:started` (unknown / never-persisted), exactly as the port specifies. + */ +export function createHistoryCheckpointer(store: RunHistoryStore): Checkpointer { + return { + load: (runId) => Promise.resolve(reconstructCheckpointState(store.loadRunEvents(runId))), + }; +} diff --git a/apps/cli/src/engine/host.test.ts b/apps/cli/src/engine/host.test.ts index 8899e45f..2e9f18c7 100644 --- a/apps/cli/src/engine/host.test.ts +++ b/apps/cli/src/engine/host.test.ts @@ -1,8 +1,21 @@ +import type { Checkpointer } from '@relavium/core'; import { describe, expect, it } from 'vitest'; import { createCliHost } from './host.js'; describe('createCliHost', () => { + it('uses an injected checkpointer (the 2.G cross-process gate-resume seam) over the default', async () => { + const injected: Checkpointer = { load: () => Promise.resolve(undefined) }; + const host = createCliHost(undefined, { checkpointer: injected }); + expect(host.checkpointer).toBe(injected); // resumeFromCheckpoint loads from the durable reconstruction + expect(await host.checkpointer.load('any')).toBeUndefined(); + }); + + it('defaults to the in-memory checkpointer when none is injected (the run path never resumes)', () => { + const host = createCliHost(); + expect(host.checkpointer).toBeDefined(); + }); + it('provides a NATIVE AbortController whose signal a provider SDK threads into fetch', () => { // Regression: the CLI host once injected the engine's in-house test `createAbortController`, whose signal // is NOT `instanceof AbortSignal` — so the LLM adapters (isAbortSignal gate) DROPPED it and a run cancel diff --git a/apps/cli/src/engine/host.ts b/apps/cli/src/engine/host.ts index d60aed68..9bf9898f 100644 --- a/apps/cli/src/engine/host.ts +++ b/apps/cli/src/engine/host.ts @@ -3,25 +3,41 @@ import { randomUUID } from 'node:crypto'; import { InMemoryRunStore, createInMemoryCheckpointer, + type Checkpointer, type ExecutionHost, type RunStore, } from '@relavium/core'; +/** Options for {@link createCliHost}. */ +export interface CliHostOptions { + /** + * The {@link Checkpointer} the engine's `resumeFromCheckpoint` loads from — supplied only on the + * cross-process gate-resume path (**2.G**), where it must reconstruct from the durable event log (the + * `createHistoryCheckpointer` over the SQLite store). Omitted on the `run` path, which never resumes from a + * checkpoint, so it defaults to the in-memory reconstruction (a no-op `undefined` over the durable store). + */ + readonly checkpointer?: Checkpointer; +} + /** * A real, node-backed {@link ExecutionHost} for the CLI — wall-clock ISO timestamps, UUID ids - * (ADR-0022), `setTimeout` one-shot timers, and the global AbortController. The run store is the - * in-memory reference for now; durable SQLite history via `@relavium/db` lands in **2.H**. No - * `mediaStore` — media host-wiring is **2.S** (a media-bearing run fails loud, never leaks bytes). + * (ADR-0022), `setTimeout` one-shot timers, and the global AbortController. `run` injects the durable + * SQLite `RunStore` (2.H); `gate` additionally injects the durable {@link Checkpointer} (2.G) so a fresh + * process can rehydrate a paused run from its persisted events. No `mediaStore` — media host-wiring is **2.S** + * (a media-bearing run fails loud, never leaks bytes). * * The clock/ids/abort/timer are generic Node primitives (no CLI specifics), so this is positioned * for later extraction to a shared node-host helper the VS Code host can reuse. */ -export function createCliHost(store: RunStore = new InMemoryRunStore()): ExecutionHost { +export function createCliHost( + store: RunStore = new InMemoryRunStore(), + options?: CliHostOptions, +): ExecutionHost { return { clock: { now: () => new Date().toISOString() }, ids: { newId: () => randomUUID() }, store, - checkpointer: createInMemoryCheckpointer(store), + checkpointer: options?.checkpointer ?? createInMemoryCheckpointer(store), // A NATIVE AbortController — its `signal` is a real `AbortSignal` that the provider SDKs thread into // `fetch`, so a run cancel actually aborts an in-flight LLM stream (→ prompt `run:cancelled`). The // engine's in-house `createAbortController` is for TESTS ONLY (its signal is not `instanceof diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts new file mode 100644 index 00000000..8086da12 --- /dev/null +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -0,0 +1,116 @@ +import type { HumanGatePausedEvent } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { createClackGatePrompter, type ClackPromptDeps } from './clack-prompter.js'; + +const CANCEL = Symbol('clack:cancel'); + +function deps(over: Partial = {}): ClackPromptDeps { + return { + note: vi.fn(), + confirm: vi.fn(() => Promise.resolve(true)), + text: vi.fn(() => Promise.resolve('')), + isCancel: (v): v is symbol => v === CANCEL, + ...over, + }; +} + +function gate(over: Partial = {}): HumanGatePausedEvent { + return { + type: 'human_gate:paused', + runId: 'run-1', + timestamp: '2026-06-24T10:00:00.000Z', + sequenceNumber: 1, + nodeId: 'review', + gateId: 'g1', + gateType: 'approval', + message: 'Ship it?', + ...over, + }; +} + +describe('createClackGatePrompter', () => { + it('renders a card from the event (title carries the gate type + node id)', async () => { + const note = vi.fn<(message: string, title: string) => void>(); + await createClackGatePrompter(deps({ note })).prompt(gate()); + expect(note).toHaveBeenCalledTimes(1); + const [body, title] = note.mock.calls[0] ?? []; + expect(title).toContain('Approval gate'); + expect(title).toContain('review'); + expect(body).toContain('Ship it?'); + }); + + it('approval gate → approve yields an approved decision', async () => { + const d = await createClackGatePrompter(deps({ confirm: () => Promise.resolve(true) })).prompt( + gate(), + ); + expect(d).toEqual({ decision: 'approved', decidedBy: 'cli' }); + }); + + it('approval gate → reject collects an optional comment', async () => { + const d = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(false), text: () => Promise.resolve('too risky') }), + ).prompt(gate()); + expect(d).toEqual({ decision: 'rejected', decidedBy: 'cli', comment: 'too risky' }); + }); + + it('a blank rejection comment is dropped', async () => { + const d = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(false), text: () => Promise.resolve(' ') }), + ).prompt(gate()); + expect(d).toEqual({ decision: 'rejected', decidedBy: 'cli' }); + }); + + it('cancelling the confirm (Ctrl-C / ESC) returns null', async () => { + const d = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(CANCEL) }), + ).prompt(gate()); + expect(d).toBeNull(); + }); + + it('cancelling the rejection-comment prompt returns null (no half-built decision)', async () => { + const d = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(false), text: () => Promise.resolve(CANCEL) }), + ).prompt(gate()); + expect(d).toBeNull(); + }); + + it('input gate → text value yields an input_provided decision (raw string)', async () => { + const confirm = vi.fn(() => Promise.resolve(true)); + const d = await createClackGatePrompter( + deps({ confirm, text: () => Promise.resolve('us-east-1') }), + ).prompt(gate({ gateType: 'input', message: 'Region?' })); + expect(d).toEqual({ decision: 'input_provided', decidedBy: 'cli', payload: 'us-east-1' }); + expect(confirm).not.toHaveBeenCalled(); // input gate never asks approve/reject + }); + + it('input gate → cancel returns null', async () => { + const d = await createClackGatePrompter(deps({ text: () => Promise.resolve(CANCEL) })).prompt( + gate({ gateType: 'input' }), + ); + expect(d).toBeNull(); + }); + + it('review gate routes through approve/reject (not a text input)', async () => { + const text = vi.fn(() => Promise.resolve('')); + const d = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(true), text }), + ).prompt(gate({ gateType: 'review' })); + expect(d).toEqual({ decision: 'approved', decidedBy: 'cli' }); + expect(text).not.toHaveBeenCalled(); // approved → no comment prompt + }); + + it('shows the deadline in the card when the gate has one', async () => { + const note = vi.fn<(message: string, title: string) => void>(); + await createClackGatePrompter(deps({ note })).prompt( + gate({ + expiresAt: '2026-06-24T11:00:00.000Z', + timeoutMs: 3_600_000, + timeoutAction: 'reject', + }), + ); + const [body] = note.mock.calls[0] ?? []; + expect(body).toContain('Expires at 2026-06-24T11:00:00.000Z'); + expect(body).toContain('auto-reject'); + }); +}); diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts new file mode 100644 index 00000000..79a87f80 --- /dev/null +++ b/apps/cli/src/gate/clack-prompter.ts @@ -0,0 +1,87 @@ +import { confirm, isCancel, note, text } from '@clack/prompts'; +import type { HumanGatePausedEvent } from '@relavium/shared'; + +import { approvalDecision, inputDecision, rejectionDecision } from './decision.js'; +import type { GatePrompter } from './prompter.js'; + +/** + * The `@clack/prompts`-backed {@link GatePrompter} (2.G, [ADR-0047](../../../../docs/decisions/0047-cli-framework-commander-ink-clack.md)) + * — the ONLY place `@clack/prompts` is imported on the run path, mirroring how `ink` is confined to the TUI + * renderer. It renders a card from the `human_gate:paused` event and collects the decision: approve / reject + * (+ optional comment) for an `approval` / `review` gate, or a free-text value for an `input` gate. The + * prompt fns are injectable so the routing + cancel logic is unit-tested without a TTY. + */ + +/** The narrow slice of `@clack/prompts` the prompter uses — injectable so the branching is testable. */ +export interface ClackPromptDeps { + readonly note: (message: string, title: string) => void; + readonly confirm: (opts: { + message: string; + active: string; + inactive: string; + }) => Promise; + readonly text: (opts: { message: string; placeholder?: string }) => Promise; + /** Clack's cancel sentinel guard (Ctrl-C / ESC) — a real type guard so a non-cancel value narrows. */ + readonly isCancel: (value: unknown) => value is symbol; +} + +const defaultDeps: ClackPromptDeps = { + note: (message, title) => { + note(message, title); + }, + confirm: (opts) => confirm(opts), + text: (opts) => text(opts), + isCancel, +}; + +const GATE_TITLE: Record = { + approval: 'Approval gate', + review: 'Review gate', + input: 'Input gate', +}; + +/** The boxed card body shown above the prompt — the gate message, plus the deadline when the gate has one. */ +function cardBody(event: HumanGatePausedEvent): string { + const lines = [event.message.trim() === '' ? '(no message)' : event.message]; + if (event.expiresAt !== undefined) { + lines.push( + '', + `Expires at ${event.expiresAt} — auto-${event.timeoutAction ?? 'reject'} on timeout`, + ); + } + return lines.join('\n'); +} + +export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): GatePrompter { + return { + prompt: async (event) => { + deps.note(cardBody(event), `⏸ ${GATE_TITLE[event.gateType]} · ${event.nodeId}`); + + if (event.gateType === 'input') { + // A human types a value: kept as the raw string (predictable). The structured/JSON path is the + // scripted `relavium gate --input ` flag (see decision.ts `parseGateInput`). + const value = await deps.text({ + message: event.message.trim() === '' ? 'Provide input' : event.message, + }); + return deps.isCancel(value) ? null : inputDecision(value); + } + + const approved = await deps.confirm({ + message: 'Approve?', + active: 'Approve', + inactive: 'Reject', + }); + if (deps.isCancel(approved)) { + return null; + } + if (approved) { + return approvalDecision(); + } + const comment = await deps.text({ + message: 'Reason for rejection (optional)', + placeholder: '', + }); + return deps.isCancel(comment) ? null : rejectionDecision(comment); + }, + }; +} diff --git a/apps/cli/src/gate/decision.test.ts b/apps/cli/src/gate/decision.test.ts new file mode 100644 index 00000000..a239c30c --- /dev/null +++ b/apps/cli/src/gate/decision.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + approvalDecision, + decisionFromFlags, + inputDecision, + parseGateInput, + rejectionDecision, +} from './decision.js'; + +describe('decision builders', () => { + it('approvalDecision is approved/decidedBy cli, with no comment key by default', () => { + expect(approvalDecision()).toEqual({ decision: 'approved', decidedBy: 'cli' }); + expect('comment' in approvalDecision()).toBe(false); + expect(approvalDecision('looks good')).toEqual({ + decision: 'approved', + decidedBy: 'cli', + comment: 'looks good', + }); + }); + + it('rejectionDecision trims a comment and omits a blank one', () => { + expect(rejectionDecision(' too risky ')).toEqual({ + decision: 'rejected', + decidedBy: 'cli', + comment: 'too risky', + }); + expect('comment' in rejectionDecision(' ')).toBe(false); + expect('comment' in rejectionDecision()).toBe(false); + }); + + it('inputDecision always carries the payload (even null)', () => { + expect(inputDecision({ api_key: 'x' })).toEqual({ + decision: 'input_provided', + decidedBy: 'cli', + payload: { api_key: 'x' }, + }); + expect(inputDecision(null)).toMatchObject({ decision: 'input_provided', payload: null }); + }); +}); + +describe('parseGateInput', () => { + it('parses JSON when it parses, else keeps the raw string', () => { + expect(parseGateInput('{"k":1}')).toEqual({ k: 1 }); + expect(parseGateInput('42')).toBe(42); + expect(parseGateInput('true')).toBe(true); + expect(parseGateInput('null')).toBeNull(); + expect(parseGateInput('some-token')).toBe('some-token'); + expect(parseGateInput('')).toBe(''); // JSON.parse('') throws → raw string + }); +}); + +describe('decisionFromFlags', () => { + it('requires exactly one of --approve / --reject / --input', () => { + expect(decisionFromFlags({})).toMatchObject({ ok: false }); + expect(decisionFromFlags({ approve: true, reject: true })).toMatchObject({ ok: false }); + expect(decisionFromFlags({ approve: true, input: 'x' })).toMatchObject({ ok: false }); + expect(decisionFromFlags({ reject: true, input: 'x' })).toMatchObject({ ok: false }); + }); + + it('builds an approval (with optional comment)', () => { + expect(decisionFromFlags({ approve: true })).toEqual({ + ok: true, + decision: { decision: 'approved', decidedBy: 'cli' }, + }); + expect(decisionFromFlags({ approve: true, comment: 'lgtm' })).toEqual({ + ok: true, + decision: { decision: 'approved', decidedBy: 'cli', comment: 'lgtm' }, + }); + }); + + it('builds a rejection with the comment', () => { + expect(decisionFromFlags({ reject: true, comment: 'Too risky' })).toEqual({ + ok: true, + decision: { decision: 'rejected', decidedBy: 'cli', comment: 'Too risky' }, + }); + }); + + it('builds an input_provided decision, parsing the payload', () => { + expect(decisionFromFlags({ input: '{"api_key":"redacted"}' })).toEqual({ + ok: true, + decision: { decision: 'input_provided', decidedBy: 'cli', payload: { api_key: 'redacted' } }, + }); + }); + + it('rejects --comment combined with --input', () => { + expect(decisionFromFlags({ input: 'x', comment: 'y' })).toMatchObject({ ok: false }); + }); +}); diff --git a/apps/cli/src/gate/decision.ts b/apps/cli/src/gate/decision.ts new file mode 100644 index 00000000..3ae68895 --- /dev/null +++ b/apps/cli/src/gate/decision.ts @@ -0,0 +1,100 @@ +import type { GateDecision } from '@relavium/shared'; + +/** + * Pure {@link GateDecision} builders + the `relavium gate` flag→decision mapping (2.G) — framework-free + * (no `commander`, no `@clack/prompts`), so both the interactive prompt and the out-of-band command build the + * same shape and the logic is unit-testable without a TTY ([ADR-0047](../../../../docs/decisions/0047-cli-framework-commander-ink-clack.md)). + */ + +/** + * The `decidedBy` recorded for a decision made through the local CLI. A deterministic, **non-PII** marker — + * the desktop/portal surfaces supply a real user id later, but the local CLI has no authenticated user, and + * `human_gate:resumed.decidedBy` is persisted to the durable event log, so it must never carry PII (an OS + * username, an email). The reserved `'timeout'` value (an auto-resolved gate) is the engine's, never ours. + */ +export const DECIDED_BY_CLI = 'cli'; + +/** Attach a non-empty, trimmed comment to a decision; omit the key entirely otherwise (exactOptionalPropertyTypes). */ +function withComment(comment: string | undefined): { comment?: string } { + const trimmed = comment?.trim(); + return trimmed !== undefined && trimmed !== '' ? { comment: trimmed } : {}; +} + +export function approvalDecision(comment?: string): GateDecision { + return { decision: 'approved', decidedBy: DECIDED_BY_CLI, ...withComment(comment) }; +} + +export function rejectionDecision(comment?: string): GateDecision { + return { decision: 'rejected', decidedBy: DECIDED_BY_CLI, ...withComment(comment) }; +} + +export function inputDecision(payload: unknown, comment?: string): GateDecision { + return { + decision: 'input_provided', + decidedBy: DECIDED_BY_CLI, + payload, + ...withComment(comment), + }; +} + +/** + * Parse a `relavium gate --input `: JSON when it parses (so `'{"k":1}'` / `'42'` / `'true'` / `'null'` + * become a structured payload the gate node's downstream consumers can read), otherwise the raw string (so + * `--input some-token` is the literal `"some-token"`). The result becomes the gate node's output verbatim. + * + * Note: the payload is the operator's *intended* gate input and is persisted to the durable event log + * (`human_gate:resumed.payload`) and emitted on the `--json` stream — by design (the documented use is e.g. + * `--input '{"api_key": "…"}'`). It is masked at the durable boundary only if the workflow types it `secret` + * (ADR-0006/0036); a plain `--input` value is recorded as-is, the same as any other gate input across surfaces. + */ +export function parseGateInput(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +/** The mutually-exclusive resolution flags `relavium gate` accepts. */ +export interface GateFlags { + readonly approve?: boolean; + readonly reject?: boolean; + readonly comment?: string; + readonly input?: string; +} + +/** A built decision, or a typed invalid-invocation message the command maps to exit 2. */ +export type GateFlagResult = + | { readonly ok: true; readonly decision: GateDecision } + | { readonly ok: false; readonly error: string }; + +/** + * Map the `gate` resolution flags to a {@link GateDecision}. Exactly one of `--approve` / `--reject` / + * `--input` is required and they are mutually exclusive; `--comment` annotates an approve/reject rationale and + * is invalid with `--input` (which carries a structured payload, not free text). A bad combination is an + * invalid invocation (exit 2), surfaced as the `error` message — never a thrown string. + */ +export function decisionFromFlags(flags: GateFlags): GateFlagResult { + const modes = [flags.approve === true, flags.reject === true, flags.input !== undefined].filter( + Boolean, + ).length; + if (modes === 0) { + return { ok: false, error: 'specify one of --approve, --reject, or --input' }; + } + if (modes > 1) { + return { ok: false, error: '--approve, --reject, and --input are mutually exclusive' }; + } + if (flags.input !== undefined) { + if (flags.comment !== undefined) { + return { + ok: false, + error: '--comment is not valid with --input (the input payload carries the data)', + }; + } + return { ok: true, decision: inputDecision(parseGateInput(flags.input)) }; + } + if (flags.approve === true) { + return { ok: true, decision: approvalDecision(flags.comment) }; + } + return { ok: true, decision: rejectionDecision(flags.comment) }; +} diff --git a/apps/cli/src/gate/prompter.ts b/apps/cli/src/gate/prompter.ts new file mode 100644 index 00000000..3958c317 --- /dev/null +++ b/apps/cli/src/gate/prompter.ts @@ -0,0 +1,16 @@ +import type { GateDecision, HumanGatePausedEvent } from '@relavium/shared'; + +/** + * Resolves an interactive human gate during a live run (**2.G**). The interactive TUI path supplies a + * `@clack/prompts`-backed implementation ({@link createClackGatePrompter}); the non-interactive paths + * (CI / `--json` / no-TTY) supply **none** — the run instead exits with the gate-paused code `3` to be resumed + * out-of-band by `relavium gate`. Framework-free (no `@clack/prompts` import here) so the run-driving core can + * depend on the seam without pulling the prompt library — the clack impl lives in its own module, exactly as + * the `ink` renderer is split from the `RunRenderer` seam. + * + * `prompt` returns the built {@link GateDecision}, or `null` when the user cancels the prompt itself + * (Ctrl-C / ESC) — the run core then cooperatively cancels the whole run. + */ +export interface GatePrompter { + prompt: (event: HumanGatePausedEvent) => Promise; +} diff --git a/apps/cli/src/gate/select-prompter.ts b/apps/cli/src/gate/select-prompter.ts new file mode 100644 index 00000000..b48bfdcf --- /dev/null +++ b/apps/cli/src/gate/select-prompter.ts @@ -0,0 +1,20 @@ +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; +import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; +import { createClackGatePrompter } from './clack-prompter.js'; +import type { GatePrompter } from './prompter.js'; + +/** + * The interactive {@link GatePrompter} for a run, or `undefined` when the environment can't prompt — CI / + * `--json` / no-TTY, where the run instead exits with the gate-paused code `3` to be resumed out-of-band by + * `relavium gate` (2.G). Mirrors `selectRenderer`: the SAME `detectOutputMode` decides, so the prompter is + * present in exactly the mode the `ink` TUI renders (a real interactive TTY) and absent everywhere else. + */ +export function selectGatePrompter(io: CliIo, global: GlobalOptions): GatePrompter | undefined { + const mode = detectOutputMode({ + stdoutIsTty: io.stdoutIsTty, + json: global.json, + ci: isCiEnv(io.env), + }); + return mode === 'tui' ? createClackGatePrompter() : undefined; +} diff --git a/apps/cli/src/harness/regression.e2e.test.ts b/apps/cli/src/harness/regression.e2e.test.ts index 377a8289..6f598eff 100644 --- a/apps/cli/src/harness/regression.e2e.test.ts +++ b/apps/cli/src/harness/regression.e2e.test.ts @@ -1,10 +1,15 @@ +import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { WorkflowDefinition } from '@relavium/core'; +import { createClient, createRunHistoryStore, runMigrations } from '@relavium/db'; import { RunEventSchema, type RunEvent } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; +import { gateCommand } from '../commands/gate.js'; import { runCommand } from '../commands/run.js'; +import type { OpenedHistory } from '../history/open.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { run } from '../run.js'; @@ -31,9 +36,11 @@ import { captureIo } from '../test-support.js'; * and asserts the IDENTICAL stream + exit code, proving the argv-parsing glue wires a real workflow run * faithfully — coverage `run.test.ts` leaves out (it drives the shell only for meta-ops + faults). * - * Deferred (scope-split in phase-2-cli.md §2.K): the gate-RESUME scenario (`relavium gate --approve` → - * completion) needs 2.G + 2.H; agent fixtures via recorded-LLM replay and the nightly live-provider lane - * need the replay-provider wiring. This first cut covers the non-agent scenarios + run-to-gate. + * The gate-RESUME scenario (`relavium gate --approve` → completion) — deferred in the §2.K first cut because + * it needs 2.G + 2.H — lands HERE now (the final `it` below): the `human-gate` fixture runs to exit `3` + * against a durable (in-memory) history db, then a fresh-process `gate` reloads + resumes it to `run:completed`. + * Still deferred: agent fixtures via recorded-LLM replay and the nightly live-provider lane (need the + * replay-provider wiring). */ const FIXTURES_DIR = fileURLToPath(new URL('./fixtures/', import.meta.url)); @@ -295,4 +302,62 @@ describe('engine regression harness (2.K) — offline fixtures over `relavium ru // dropped flag would route to `lo` and mismatch here — that is what makes this an `--input` test. expect(events.map(signature)).toEqual([...inputDependent.events]); }); + + // The gate-RESUME scenario (deferred in the §2.K first cut; lands with 2.G): run the human-gate fixture to + // the gate-paused exit `3` against a DURABLE history db, then resume it from a FRESH process via the `gate` + // command — the cross-process path (reload snapshot + reconstruct checkpoint + resumeFromCheckpoint) — and + // assert it drives to `run:completed`. This closes 2.K's deferred half over the same offline fixtures. + it('resumes a gate-paused run via `relavium gate --approve` to completion (closes 2.K’s deferred half)', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + try { + // Persist the run so a fresh-process `gate` can reload it (the durable substrate 2.H gives 2.G). + const openRunStore = (workflow: WorkflowDefinition): OpenedHistory => ({ + store: createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.name ?? workflow.workflow.id, + definitionJson: JSON.stringify(workflow), + }, + }), + close: () => {}, + }); + + // 1. Run to the gate → exit 3, persisted to the db. + const runIo = captureIo(); + const runCode = await runCommand( + { workflow: join(FIXTURES_DIR, 'human-gate.relavium.yaml'), input: [] }, + { io: runIo.io, global: globalOptions(), openRunStore }, + ); + expect(runCode).toBe(EXIT_CODES.gatePaused); + const runId = parseEvents(runIo.out()).find((e) => e.type === 'run:started')?.runId; + if (runId === undefined) { + throw new Error('expected a run:started event carrying the runId'); + } + + // 2. Resume from a FRESH process (the `gate` command) over the same db → exit 0, driven to completion. + const gateIo = captureIo(); + const gateCode = await gateCommand( + { runId, approve: true }, + { + io: gateIo.io, + global: globalOptions(), + openDb: () => ({ db: client.db, close: () => {} }), + }, + ); + expect(gateCode).toBe(EXIT_CODES.success); + expect(gateIo.err()).toBe(''); // the resume stream is stdout-pure NDJSON too (ADR-0049) + // The resume emits exactly the continuation: the gate resolves, then the downstream output, then done. + expect(parseEvents(gateIo.out()).map(signature)).toEqual([ + 'human_gate:resumed:gate', + 'node:started:out', + 'node:completed:out', + 'run:completed', + ]); + } finally { + client.sqlite.close(); + } + }); }); diff --git a/apps/cli/src/render/renderer.ts b/apps/cli/src/render/renderer.ts index 8853f9b7..b9c054fe 100644 --- a/apps/cli/src/render/renderer.ts +++ b/apps/cli/src/render/renderer.ts @@ -9,6 +9,15 @@ import type { CliIo } from '../process/io.js'; */ export interface RunRenderer { onEvent: (event: RunEvent) => void; + /** + * Optional pause of the live view so an interactive `@clack/prompts` gate card (2.G) can own the terminal + * mid-run: the `ink` TUI unmounts (releasing the terminal) WITHOUT writing its final summary; `resume` + * re-mounts from the same retained store so the live view continues seamlessly. The line / NDJSON renderers + * have no live view and omit both — the run core calls them only in the interactive TUI path. + */ + suspend?: () => Promise | void; + /** Re-mount the live view after a {@link suspend} (no-op once {@link finalize} has run). See `suspend`. */ + resume?: () => Promise | void; /** * Optional teardown, awaited by the run core after the event loop ends (even on a throw). The `ink` TUI * (2.E) uses it to unmount the live view — restoring the terminal — and write its persistent final diff --git a/apps/cli/src/render/tui/ink-renderer.test.ts b/apps/cli/src/render/tui/ink-renderer.test.ts index 808a3a42..755738a0 100644 --- a/apps/cli/src/render/tui/ink-renderer.test.ts +++ b/apps/cli/src/render/tui/ink-renderer.test.ts @@ -28,6 +28,25 @@ function fakeMount(): { return { mount: () => ({ unmount, waitUntilExit }), unmount, waitUntilExit }; } +/** Like {@link fakeMount} but counts mount() calls — so suspend/resume re-mount cycles (2.G) are observable. */ +function countingMount(): { + mount: () => InkMountInstance; + unmount: ReturnType; + mountCount: () => number; +} { + let mounts = 0; + const unmount = vi.fn(); + const waitUntilExit = vi.fn(() => Promise.resolve()); + return { + mount: () => { + mounts += 1; + return { unmount, waitUntilExit }; + }, + unmount, + mountCount: () => mounts, + }; +} + describe('createInkRenderer', () => { it('finalize unmounts, awaits exit, then writes the persistent summary — and is idempotent', async () => { const { mount, unmount, waitUntilExit } = fakeMount(); @@ -78,6 +97,46 @@ describe('createInkRenderer', () => { expect(writeSummary.mock.calls[0]?.[0]).toContain('run cancelled'); }); + it('suspend unmounts the live view WITHOUT a summary; resume re-mounts from the same store (2.G gate prompt)', async () => { + const { mount, unmount, mountCount } = countingMount(); + const summaries: string[] = []; + const renderer = createInkRenderer({ + color: false, + mount, + writeSummary: (text) => summaries.push(text), + }); + expect(mountCount()).toBe(1); // initial mount at construction + + // Suspend to hand the terminal to the gate prompt: unmount, but the run is NOT over → no summary. + await renderer.suspend?.(); + expect(unmount).toHaveBeenCalledTimes(1); + expect(summaries).toHaveLength(0); + + // Resume re-mounts a fresh ink instance over the retained store. + await renderer.resume?.(); + expect(mountCount()).toBe(2); + + // Finalize then tears down the second instance and writes the persistent summary exactly once. + await renderer.finalize?.(); + expect(unmount).toHaveBeenCalledTimes(2); + expect(summaries).toHaveLength(1); + }); + + it('resume after finalize is a no-op — a late resume never re-opens a torn-down TUI', async () => { + const { mount, mountCount } = countingMount(); + const renderer = createInkRenderer({ color: false, mount, writeSummary: () => {} }); + await renderer.finalize?.(); + await renderer.resume?.(); + expect(mountCount()).toBe(1); // still just the initial mount — finalize is terminal + }); + + it('resume while already mounted is idempotent (no double-mount without a preceding suspend)', async () => { + const { mount, mountCount } = countingMount(); + const renderer = createInkRenderer({ color: false, mount, writeSummary: () => {} }); + await renderer.resume?.(); + expect(mountCount()).toBe(1); + }); + it('clears the frame loop and re-throws if mount() throws during construction', () => { const clearSpy = vi.spyOn(globalThis, 'clearInterval'); expect(() => diff --git a/apps/cli/src/render/tui/ink-renderer.ts b/apps/cli/src/render/tui/ink-renderer.ts index dff48405..85a4259b 100644 --- a/apps/cli/src/render/tui/ink-renderer.ts +++ b/apps/cli/src/render/tui/ink-renderer.ts @@ -46,13 +46,6 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { const stdout = options.stdout ?? process.stdout; const store = createRunStore(options.color); - // The frame loop drives the throttle (coalesced repaints) + the spinner animation. unref() so a pending - // tick never holds the process open past the run's end. - const frame = setInterval(() => { - store.tick(); - }, FRAME_MS); - frame.unref(); - const mount = options.mount ?? ((s: RunStore): InkMountInstance => @@ -67,13 +60,42 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { maxFps: Math.max(1, Math.round(1000 / FRAME_MS)), })); - let instance: InkMountInstance; - try { - instance = mount(store); - } catch (err) { - clearInterval(frame); // mount threw — don't leak the frame loop (finalize won't run) - throw err; - } + // The live view is a (frame loop + ink mount) pair that can be torn down and re-created: `start` after + // construction and on `resume`, `stop` on `suspend` and `finalize`. The store outlives every cycle, so a + // re-mount re-projects the exact same state (2.E: ink is a thin projection of an ink-free store). + let frame: ReturnType | undefined; + let instance: InkMountInstance | undefined; + + const start = (): void => { + // The frame loop drives the throttle (coalesced repaints) + the spinner animation. unref() so a pending + // tick never holds the process open past the run's end. + const f = setInterval(() => { + store.tick(); + }, FRAME_MS); + f.unref(); + try { + instance = mount(store); + } catch (err) { + clearInterval(f); // mount threw — don't leak the frame loop (finalize won't run) + throw err; + } + frame = f; // only after a successful mount, so a mount throw leaves no dangling timer + }; + + const stop = async (): Promise => { + if (frame !== undefined) { + clearInterval(frame); + frame = undefined; + } + if (instance !== undefined) { + const live = instance; + instance = undefined; + live.unmount(); + await live.waitUntilExit(); + } + }; + + start(); // initial mount (a construction throw propagates, leaving no timer — see `start`) let finalized = false; @@ -81,15 +103,26 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { onEvent: (event) => { store.apply(event); }, + suspend: async () => { + // Release the terminal so a `@clack/prompts` gate card (2.G) can render. Paint the latest live frame + // first, then unmount WITHOUT writing the summary (that is finalize's job — the run is not over). + store.flush(); + await stop(); + }, + resume: () => { + // Re-mount the live view from the SAME store after the gate prompt. A no-op once finalized (a late + // resume after teardown must never re-open the TUI) or while already mounted (idempotent). + if (!finalized && instance === undefined) { + start(); + } + }, finalize: async () => { if (finalized) { return; } finalized = true; - clearInterval(frame); store.flush(); // paint the final live frame - instance.unmount(); - await instance.waitUntilExit(); + await stop(); // The live frames are ephemeral; write the persistent plain-text summary into the scrollback. const write = options.writeSummary ?? diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 64435b93..0d22801d 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-06-23 +> Last updated: 2026-06-24 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) @@ -93,7 +93,7 @@ before parsing the subcommand). ## Commands -The command set below is the confirmed surface. Subcommands marked _(planned)_ are intended but not yet locked. +The command set below is the confirmed surface. Commands ship **per workstream**: `run` (2.D), `gate`, and `provider` are **live**; the read commands (`list` / `logs` / `status` / `gate list`) land at **2.I**, the authoring commands (`create` / `import` / `export`) at **2.J**, the chat family at **2.M–2.Q**, and `budget resume` is a [tracked follow-up](../../roadmap/deferred-tasks.md). Invoking a not-yet-shipped command exits with a clean "not available yet (lands in …)" message. Subcommands marked _(planned)_ are intended but not yet locked. | Command | Purpose | |---------|---------| @@ -132,9 +132,9 @@ relavium run ./workflows/code-review.relavium.yaml --input file=./src/index.ts - - `--json` switches to NDJSON [RunEvent](../contracts/sse-event-schema.md) output. - `Ctrl-C` (SIGINT) requests a cooperative cancel; the run drains to `run:cancelled` and exits non-zero (`1`). - A missing API key for an inline agent's **primary** provider is caught **pre-flight** as an invalid invocation (exit `2`) naming the `RELAVIUM__API_KEY` to set, before the run starts. The pre-flight is a strict subset of the keys a run may touch, so it never blocks a valid run: a `fallback_chain` provider's key (read only if the chain fails over to it) and a `$ref`-resolved external agent's key (until `$ref` resolution lands, 2.M–2.Q) are conditional and instead surface mid-run as a run failure (exit `1`). -- On a `human_gate` node the run **pauses**: in interactive mode it prompts inline; in CI mode it exits with the gate-paused code (`3`, see [Exit codes](#exit-codes)) and can be resumed with `relavium gate`. The emitted `human_gate:paused` event carries the `gateId` needed for the resume (`relavium gate --gate `); with `--json` it is on the NDJSON event line, otherwise read it from `relavium status`/`relavium logs`. +- On a `human_gate` node the run **pauses**: in interactive mode it prompts inline; in CI mode it exits with the gate-paused code (`3`, see [Exit codes](#exit-codes)) and can be resumed with `relavium gate`. The emitted `human_gate:paused` event carries the `runId` + `gateId` needed for the resume (`relavium gate --gate `); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate ()`, also echoed in the final summary). (Once `relavium status` / `relavium logs` land at 2.I they will surface pending `gateId`s too.) -> **Implementation status (as of workstream 2.E).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. The interactive **`ink` TUI** (2.E) renders the live run on a TTY — per-node status + spinners, the active node's streaming tokens, a running cost/duration footer, and a persistent final summary. Under `--no-color` it keeps the TUI but suppresses ANSI color; it falls back to the plain line renderer when no TTY is attached or `CI=true`, and to NDJSON under `--json` (the three renderers are one `onEvent` seam over one bus). Provider keys resolve from the **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; manage them with `relavium provider`), and runs persist to durable history (2.H). One piece still lands in a later workstream: the interactive inline gate prompt + `relavium gate` resume (2.G — until then a `human_gate` node exits `3`). Built-in tools that need a host capability (filesystem, process, egress) are **fail-closed** (unavailable) pending a security-reviewed capability workstream. +> **Implementation status (as of workstream 2.G).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. The interactive **`ink` TUI** (2.E) renders the live run on a TTY — per-node status + spinners, the active node's streaming tokens, a running cost/duration footer, and a persistent final summary. Under `--no-color` it keeps the TUI but suppresses ANSI color; it falls back to the plain line renderer when no TTY is attached or `CI=true`, and to NDJSON under `--json` (the three renderers are one `onEvent` seam over one bus). Provider keys resolve from the **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; manage them with `relavium provider`), and runs persist to durable history (2.H). The **interactive human-gate prompt** + out-of-band [`relavium gate`](#relavium-gate) resume are live (2.G): on a TTY a `human_gate` node renders a `@clack/prompts` card inline (approve / reject + comment / input) and the run continues; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate `. Built-in tools that need a host capability (filesystem, process, egress) are **fail-closed** (unavailable) pending a security-reviewed capability workstream. ### `relavium list` @@ -167,8 +167,13 @@ relavium gate --input '{"api_key": "..."}' # for gate_type=inpu relavium gate --gate --approve # disambiguate when >1 gate is pending ``` +- Exactly **one** of `--approve` / `--reject` / `--input` is required and they are mutually exclusive; `--comment ` annotates an approve/reject rationale and is invalid with `--input` (which carries the payload). A bad combination is an invalid invocation (exit `2`). +- `--input ` is parsed as **JSON when it parses** (`'{"k":1}'` / `'42'` / `'true'` → a structured payload), else kept as the **raw string** (`--input some-token` → `"some-token"`); the result becomes the gate node's output. (The interactive prompt for a `gate_type=input` gate takes the typed value as a raw string.) - `--gate ` selects **which** pending gate to resolve. The resume contract is `engine.resume(runId, gateId, decision)` — `gateId` is mandatory on the resume path (it is carried on the `human_gate:paused` event; see [sse-event-schema.md](../contracts/sse-event-schema.md) and `resume_run` in [ipc-contract.md](../contracts/ipc-contract.md)). `--gate` is **optional on the CLI**: when exactly one gate is pending the CLI fills it in automatically; when **more than one** gate is pending it is **required**, and omitting it is an invalid invocation (exit `2`) listing the pending `gateId`s. -- Get the pending `gateId`(s) from `relavium status` or `relavium logs ` (both print them — see below). +- In **2.G**, read the pending `runId` + `gateId` from the run's own output: the `human_gate:paused` event line under `--json`, or the `paused at gate ()` line the plain/TUI renderer prints. (`relavium status` / `relavium logs ` will also print them once they land at **2.I** — see below.) +- **Idempotent.** A doubled decision — the run already finished, or the named gate was already resolved — is a clean exit-`0` no-op, never a double-advance (it leans on the engine's checkpoint/gate-state idempotency). An unknown `runId` is exit `2`. + +> **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate `relavium budget resume` surface ([deferred-tasks](../../roadmap/deferred-tasks.md)). The `relavium gate list` multi-gate listing lands with `relavium status`/`logs` (2.I). ### `relavium provider` diff --git a/docs/reference/cli/regression-harness.md b/docs/reference/cli/regression-harness.md index d01e9222..9214ea4d 100644 --- a/docs/reference/cli/regression-harness.md +++ b/docs/reference/cli/regression-harness.md @@ -80,7 +80,7 @@ run exit codes (`0` / `1` / `3`). | `sequential.relavium.yaml` | input → transform → output (the simplest chain) | `0` | | `fan-out.relavium.yaml` | `parallel` split + `merge` (`object_merge`) — fan_out/fan_in | `0` | | `conditional.relavium.yaml` | `condition` branch + `node:skipped` skip-propagation (run **both** arms: `n=3`→`lo`, `n=15`→`hi`) | `0` | -| `human-gate.relavium.yaml` | `human_gate` pause → `run:paused` → gate-paused exit | `3` | +| `human-gate.relavium.yaml` | `human_gate` pause → `run:paused` → gate-paused exit; **and** (2.G) the cross-process **resume**: run to exit `3` against a durable db, then `relavium gate --approve` → `run:completed` | `3` → `0` | | `failure.relavium.yaml` | a transform throws → `node:failed` → `run:failed` (downstream skipped) | `1` | ## Adding a fixture @@ -93,10 +93,10 @@ run exit codes (`0` / `1` / `3`). ## Deferred (scope-split in [phase-2-cli.md §2.K](../../roadmap/phases/phase-2-cli.md)) -- **Gate-*resume* scenario** (`relavium gate --approve` → completion) — needs the `relavium gate` - command (**2.G**) and durable run history (**2.H**); neither exists yet, so the CLI cannot reload and - resume a paused run across processes. The run-to-gate → exit `3` half ships now; the approve → - complete half lands with 2.G/2.H. (The engine's resume is already proven in `m2-e2e-harness`.) +- ~~**Gate-*resume* scenario** (`relavium gate --approve` → completion)~~ — **landed with 2.G**: the + harness runs the `human-gate` fixture to exit `3` against a durable (in-memory) history db, then resumes + it from a fresh-process `relavium gate --approve` (reload snapshot + reconstruct checkpoint + + `resumeFromCheckpoint`) and asserts `run:completed`. This closed 2.K's deferred half. - **Agent fixtures via recorded-LLM replay** — the `@relavium/llm` `conformance/replay.ts` substrate (a recorded-`fetch` override that refuses secret bodies) can drive an agent workflow offline through the injectable `ProviderResolver`; deferred so the first cut stays non-agent. Agent dispatch is diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 40720d45..4ebde890 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -481,6 +481,18 @@ Severity is the review's verified rating. Check an item off in the PR that resol media-egress work, ~2.S), a media-only park would be reported as "gate-paused" with no gate; at that point decide whether exit 3 (and the rendered message) should distinguish a gate park from a media park. *(low · apps/cli/src/commands/run.ts; media host-wiring / 2.S)* +- [ ] **`relavium budget resume [--approve | --abort]` is documented but has no numbered + workstream.** [commands.md](../reference/cli/commands.md) lists `budget resume` as the non-interactive + operator path for a run suspended at a budget cap (`budget:paused`, `on_exceed: pause_for_approval` — + [ADR-0028](../decisions/0028-workflow-resource-governance.md)), but no Phase-2 workstream implements it. + It reuses **2.G's** cross-process resume substrate end-to-end: a `budget:paused` reconstructs as a + pending gate with `isBudgetGate: true` (`reconstructCheckpointState` / `CheckpointPendingGate`), so the + command loads the snapshot + checkpoint exactly like `relavium gate` and calls `engine.resumeFromCheckpoint` + with a `GateDecision` — `--approve` continues the one-shot deferred call (a pre-egress bypass), `--abort` + fails the run. The delta from `gate` is only the command surface + the two budget-specific flags; it is + **deliberately out of 2.G** (not in its acceptance criteria, and a distinct ADR-0028 surface). Schedule it + as a small follow-up once 2.G lands the substrate — candidate home: alongside 2.I (the read/operator + commands) or its own short workstream. *(low · apps/cli/src/commands/; ADR-0028; reuses 2.G resume path)* ## Schema / validation hardening diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 40cbbfce..3be7ebde 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -68,7 +68,7 @@ adversarially reviewed by a 10-agent workflow before being locked. ## Supporting Tooling -- **Node.js runtime**: **Node 22** for dev + CI (pinned in [`.nvmrc`](../.nvmrc)); the supported floor is **20.11.0** per the root `package.json` `engines.node` (`>=20.11.0`). The TypeScript engine and every Node surface (CLI, the `@relavium/db`/`@relavium/llm` test runs, Phase-2 Bun parity) target this range. +- **Node.js runtime**: **Node 22** for dev + CI (pinned in [`.nvmrc`](../.nvmrc)); the supported floor is **20.12.0** per the root `package.json` `engines.node` (`>=20.12.0`). The TypeScript engine and every Node surface (CLI, the `@relavium/db`/`@relavium/llm` test runs, Phase-2 Bun parity) target this range. _(The floor moved 20.11→20.12 at workstream 2.G: the current `@clack/prompts` — the CLI wizard/prompt layer, [ADR-0047](decisions/0047-cli-framework-commander-ink-clack.md) — declares `engines.node >= 20.12.0`. A patch-level forward bump within the same Node 20 LTS line; dev/CI already run Node 22.)_ - **Monorepo**: Turborepo + pnpm workspaces (see [project-structure.md](project-structure.md)) - **UI**: shadcn/ui + Radix on Tailwind, shared via `packages/ui` - **Canvas**: ReactFlow (custom node types in `packages/ui`) diff --git a/package.json b/package.json index 9a257788..fe0f5141 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "SEE LICENSE IN LICENSE", "packageManager": "pnpm@9.12.3", "engines": { - "node": ">=20.11.0", + "node": ">=20.12.0", "pnpm": ">=9" }, "scripts": { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b4cf504b..7532666f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -73,11 +73,13 @@ export { // platform-free engine never imports this — a host wires it over history.db (unencrypted on the CLI, ADR-0050). export { createRunHistoryStore, + loadRunSnapshot, type RunHistoryStore, type RunHistoryStoreDeps, type RunHistoryWorkflow, type InterruptedRunInfo, type RunRecord, + type RunResumeSnapshot, } from './run-history-store.js'; // Provider registry (2.C) — CRUD over the non-secret `llm_providers` catalog the CLI's `relavium provider` diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index eac826d2..511e6b11 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -6,6 +6,7 @@ import { createClient, runMigrations, type DbClient } from './client.js'; import { runCosts, runEvents, runs, stepExecutions } from './schema.js'; import { createRunHistoryStore, + loadRunSnapshot, type RunHistoryStore, type RunHistoryWorkflow, } from './run-history-store.js'; @@ -382,4 +383,55 @@ describe('createRunHistoryStore', () => { expect(value ?? '').not.toContain(RAW); } }); + + describe('loadRunSnapshot', () => { + it('returns the frozen snapshot + status for a paused run (the 2.G resume substrate)', async () => { + const wf = await store.resolveWorkflowId('demo'); + await store.persistEvent({ + ...ev('run:started', 0, { workflowId: wf, inputs: { n: 3 }, executionMode: 'local' }), + runId: 'run-snap', + }); + await store.persistEvent({ + ...ev('human_gate:paused', 1, { + nodeId: 'gate', + gateId: 'g1', + gateType: 'approval', + message: 'ok?', + }), + runId: 'run-snap', + }); + + const snap = loadRunSnapshot(client.db, 'run-snap'); + expect(snap?.status).toBe('paused'); + // The exact `JSON.stringify(WorkflowDefinition)` written at run:started — round-trips to the parsed graph. + expect(snap?.workflowDefinitionSnapshot).toBe(WORKFLOW.definitionJson); + expect(JSON.parse(snap?.workflowDefinitionSnapshot ?? '{}')).toMatchObject({ + workflow: { id: 'demo' }, + }); + // The run's inputs are restored too (a post-gate node may read `{{ inputs.x }}` on resume). + expect(JSON.parse(snap?.inputJson ?? '{}')).toEqual({ n: 3 }); + }); + + it('reflects a terminal run status (so the gate command can no-op an already-finished run)', async () => { + const wf = await store.resolveWorkflowId('demo'); + await store.persistEvent({ + ...ev('run:started', 0, { workflowId: wf, inputs: {}, executionMode: 'local' }), + runId: 'run-done', + }); + await store.persistEvent({ + ...ev('run:completed', 1, { + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }), + runId: 'run-done', + }); + expect(loadRunSnapshot(client.db, 'run-done')?.status).toBe('completed'); + }); + + it('returns undefined for an unknown runId', () => { + expect(loadRunSnapshot(client.db, 'nope')).toBeUndefined(); + }); + }); }); diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 2982af38..2fde2fe0 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -420,3 +420,43 @@ function stepMatch(runId: string, nodeId: string, attemptNumber: number | undefi eq(stepExecutions.attemptNumber, attemptNumber ?? 1), ); } + +/** A paused run's frozen workflow snapshot + inputs + current status, read by id. */ +export interface RunResumeSnapshot { + readonly status: RunStatus; + /** `JSON.stringify(WorkflowDefinition)` — the frozen `runs.workflow_definition_snapshot` (the events don't + * carry the graph). The caller re-validates it with the shared `WorkflowSchema` before resuming. */ + readonly workflowDefinitionSnapshot: string; + /** `JSON.stringify(inputs)` from `run:started` (`runs.input_json`) — restored on resume so a post-gate node + * that reads `{{ inputs.x }}` sees the value the run started with (the events don't replay the inputs). */ + readonly inputJson: string; +} + +/** + * Read one run's `status` + frozen `workflow_definition_snapshot` + `input_json` by id — the substrate + * `relavium gate` (2.G) needs to rebuild the `WorkflowDefinition` + inputs for a cross-process + * `resumeFromCheckpoint` **before** it knows which workflow the paused run used. Standalone (a plain `runs`-row + * read) rather than a {@link RunHistoryStore} method, because the store is workflow-scoped at construction + * (`deps.workflow`) and the gate command only learns the workflow *from* this snapshot — a chicken-and-egg the + * standalone read resolves. Returns `undefined` for an unknown `runId`. The snapshot/inputs are unsafe-column + * data (no raw secrets — the engine masks at the write boundary, ADR-0050); this read is pass-through and + * never logs them. + */ +export function loadRunSnapshot(db: Db, runId: string): RunResumeSnapshot | undefined { + const row = db + .select({ + status: runs.status, + snapshot: runs.workflowDefinitionSnapshot, + inputJson: runs.inputJson, + }) + .from(runs) + .where(eq(runs.id, runId)) + .get(); + return row === undefined + ? undefined + : { + status: row.status, + workflowDefinitionSnapshot: row.snapshot, + inputJson: row.inputJson, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75ee4691..78931ebe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@anthropic-ai/sdk': specifier: ^0.101.0 version: 0.101.0 + '@clack/prompts': + specifier: ^1.6.0 + version: 1.6.0 '@eslint/js': specifier: ^9.17.0 version: 9.39.4 @@ -28,7 +31,7 @@ catalogs: specifier: ^7.0.15 version: 7.0.15 '@types/node': - specifier: ^20.11.0 + specifier: ^20.12.0 version: 20.19.41 '@types/react': specifier: ^19.2.17 @@ -128,6 +131,9 @@ importers: apps/cli: dependencies: + '@clack/prompts': + specifier: 'catalog:' + version: 1.6.0 '@napi-rs/keyring': specifier: 'catalog:' version: 1.3.0 @@ -337,6 +343,14 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@clack/core@1.4.2': + resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.6.0': + resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} + engines: {node: '>= 20.12.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1881,6 +1895,15 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2422,6 +2445,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@8.0.0: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} @@ -2805,6 +2831,18 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@1.4.2': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.6.0': + dependencies: + '@clack/core': 1.4.2 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -4000,6 +4038,16 @@ snapshots: fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -4559,6 +4607,8 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sisteransi@1.0.5: {} + slice-ansi@8.0.0: dependencies: ansi-styles: 6.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f29127e8..1cbd4673 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,14 +16,15 @@ catalog: typescript-eslint: ^8.18.1 vitest: ^3.0.0 # Type packages (dev): @types/node is pinned to the `engines` runtime FLOOR - # (node >=20.11.0), NOT .nvmrc's dev/CI version (22), so a Node-22-only API (e.g. + # (node >=20.12.0), NOT .nvmrc's dev/CI version (22), so a Node-22-only API (e.g. # `node:sqlite`, which ADR-0021 rejects for needing >=22.5) is a type error rather than - # a runtime break on the supported floor. + # a runtime break on the supported floor. (Floor moved 20.11→20.12 at 2.G when the current + # `@clack/prompts` (ADR-0047) raised its own engines floor to >=20.12.0 — see tech-stack.md.) '@types/better-sqlite3': ^7.6.13 # Dev/type-only: the `JSONSchema7` type for the `@relavium/llm` seam's `ToolDef.parameters` # (a type-only import, erased at build — no runtime dependency, so no ADR needed). '@types/json-schema': ^7.0.15 - '@types/node': ^20.11.0 + '@types/node': ^20.12.0 drizzle-kit: ^0.31.10 # Runtime zod: ^3.23.0 @@ -64,8 +65,14 @@ catalog: '@napi-rs/keyring': ^1.3.0 # CLI streaming TUI (2.E, ADR-0047) — `ink` (React for terminals) + its required `react` peer; the # `@types/react` peer is optional (dev-only, for typecheck). ink 6.x is the major that honors BOTH the - # tech-stack React 19 pin AND the Node ≥20.11 supported floor (ink 7 requires Node ≥22; ink 5 wants + # tech-stack React 19 pin AND the Node ≥20.12 supported floor (ink 7 requires Node ≥22; ink 5 wants # React 18) — see tech-stack.md. Confined to apps/cli's renderer layer, inlined by tsup. §9a cooling window. ink: ^6.8.0 react: ^19.2.7 '@types/react': ^19.2.17 + # CLI setup-wizard / interactive-prompt layer (2.G + 2.J, ADR-0047) — `@clack/prompts` renders the + # human-gate card (`relavium gate`/inline prompt) and the `create` wizard. The current 1.x line requires + # Node ≥20.12.0, which set the repo's supported floor at 2.G (20.11→20.12; root engines + tech-stack.md). + # Confined to apps/cli's wizard/prompt layer (never the ink live-run renderer, never an engine package), + # inlined by tsup. §9a cooling window. + '@clack/prompts': ^1.6.0 From 1630483f474afd8c68245e242a21e9d18611e8f8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 10:14:04 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(cli,db):=202.G=20external-review=20roun?= =?UTF-8?q?d=20=E2=80=94=20gate-prompt=20cancel=20race=20+=20drop=20dead?= =?UTF-8?q?=20snapshot=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the confirmed findings from three independent 2.G reviews (all "ship-ready", 0 blockers); two flagged the same race. - drive.ts: a Ctrl-C during the inline gate prompt can settle the run before the decision is applied, so engine.resume throws EngineStateError('run_already_terminal'). Swallow exactly that code and let the loop drain run:cancelled (outcome 'cancelled') instead of surfacing a generic "unexpected internal error". Deterministic test added. - db: drop the now-dead `status` field from loadRunSnapshot / RunResumeSnapshot — the gate command uses the authoritative checkpoint.runStatus (folded fresh from the event log), and loadRun(runId).status covers any status-by-id need. Leaner read, no dead projection. - docs(commands.md): note gate idempotency is per-gate — a blind retry without --gate on a sequential multi-gate workflow resolves the NEXT gate, so CI retry loops should pin --gate . Refs: ADR-0047, ADR-0050 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/drive.test.ts | 38 ++++++++++++++++++++++- apps/cli/src/commands/drive.ts | 16 ++++++++-- apps/cli/src/commands/gate.test.ts | 3 +- docs/reference/cli/commands.md | 2 +- packages/db/src/run-history-store.test.ts | 21 +------------ packages/db/src/run-history-store.ts | 22 ++++++------- 6 files changed, 64 insertions(+), 38 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index 4d976da0..60b35702 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -1,4 +1,9 @@ -import { createInMemoryHost, parseWorkflow, type WorkflowEngine } from '@relavium/core'; +import { + EngineStateError, + createInMemoryHost, + parseWorkflow, + type WorkflowEngine, +} from '@relavium/core'; import type { GateDecision, RunEvent, RunPausedEvent } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; @@ -131,6 +136,37 @@ describe('driveRun — interactive gate', () => { const outcome = await driveRun({ engine, handle, makeRenderer: () => renderer, io }); expect(outcome).toBe('paused'); }); + + it('swallows a run_already_terminal from a cancel-during-prompt race, draining to cancelled (no opaque error)', async () => { + const { engine, handle } = await startGatedRun(); + const { renderer } = recordingRenderer(); + const { io } = captureIo(); + // The SIGINT-during-prompt race: the prompt cooperatively cancels the run (→ run:cancelled), then returns + // a decision the engine now refuses with run_already_terminal. driveRun must swallow that and drain to the + // cancelled terminal — never surface it as a generic "unexpected internal error". + const resumeSpy = vi + .spyOn(engine, 'resume') + .mockRejectedValue( + new EngineStateError('run_already_terminal', 'the run has already terminated'), + ); + const prompter: GatePrompter = { + prompt: () => { + handle.cancel(); // cooperative cancel → the run drains to run:cancelled + return Promise.resolve({ decision: 'approved', decidedBy: 'cli' }); + }, + }; + + const outcome = await driveRun({ + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + + expect(resumeSpy).toHaveBeenCalledTimes(1); // the race path WAS taken... + expect(outcome).toBe('cancelled'); // ...and resolved cleanly to the cancelled terminal, no throw + }); }); describe('shouldBreakOnPause', () => { diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index c08a1dac..33012b0e 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -1,4 +1,4 @@ -import type { RunHandle, WorkflowEngine } from '@relavium/core'; +import { EngineStateError, type RunHandle, type WorkflowEngine } from '@relavium/core'; import type { HumanGatePausedEvent, RunEvent, RunPausedEvent } from '@relavium/shared'; import type { GatePrompter } from '../gate/prompter.js'; @@ -130,7 +130,19 @@ async function resolveGateInline( handle.cancel(); return; } - await engine.resume(event.runId, event.gateId, decision); + try { + await engine.resume(event.runId, event.gateId, decision); + } catch (err) { + // A Ctrl-C during the prompt cooperatively cancels the run (the SIGINT handler calls handle.cancel()); + // if that settles the run in the window between the prompt returning and this await, the engine refuses + // the now-moot decision with `run_already_terminal`. That is a clean cancellation, not a fault — return + // and let the loop drain the buffered run:cancelled (→ outcome 'cancelled'), never a generic "internal + // error". Any other engine refusal is a real bug and re-throws. + if (err instanceof EngineStateError && err.code === 'run_already_terminal') { + return; + } + throw err; + } } /** diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 85b9c131..b752073a 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -4,7 +4,6 @@ import { parseWorkflow, type CheckpointState } from '@relavium/core'; import { createClient, createRunHistoryStore, - loadRunSnapshot, runMigrations, type Db, type DbClient, @@ -114,12 +113,12 @@ describe('gateCommand', () => { const code = await gateCommand({ runId, approve: true }, deps(io)); expect(code).toBe(EXIT_CODES.success); - expect(loadRunSnapshot(db, runId)?.status).toBe('completed'); // the cross-process resume reached the terminal const store = createRunHistoryStore(db, { uuid: () => randomUUID(), now: () => Date.now(), workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, }); + expect(store.loadRun(runId)?.status).toBe('completed'); // the cross-process resume reached the terminal const events = store.loadRunEvents(runId); expect(events.find((e) => e.type === 'human_gate:resumed')).toMatchObject({ decision: 'approved', diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 0d22801d..4b4e7f64 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -171,7 +171,7 @@ relavium gate --gate --approve # disambiguate when > - `--input ` is parsed as **JSON when it parses** (`'{"k":1}'` / `'42'` / `'true'` → a structured payload), else kept as the **raw string** (`--input some-token` → `"some-token"`); the result becomes the gate node's output. (The interactive prompt for a `gate_type=input` gate takes the typed value as a raw string.) - `--gate ` selects **which** pending gate to resolve. The resume contract is `engine.resume(runId, gateId, decision)` — `gateId` is mandatory on the resume path (it is carried on the `human_gate:paused` event; see [sse-event-schema.md](../contracts/sse-event-schema.md) and `resume_run` in [ipc-contract.md](../contracts/ipc-contract.md)). `--gate` is **optional on the CLI**: when exactly one gate is pending the CLI fills it in automatically; when **more than one** gate is pending it is **required**, and omitting it is an invalid invocation (exit `2`) listing the pending `gateId`s. - In **2.G**, read the pending `runId` + `gateId` from the run's own output: the `human_gate:paused` event line under `--json`, or the `paused at gate ()` line the plain/TUI renderer prints. (`relavium status` / `relavium logs ` will also print them once they land at **2.I** — see below.) -- **Idempotent.** A doubled decision — the run already finished, or the named gate was already resolved — is a clean exit-`0` no-op, never a double-advance (it leans on the engine's checkpoint/gate-state idempotency). An unknown `runId` is exit `2`. +- **Idempotent.** A doubled decision — the run already finished, or the named gate was already resolved — is a clean exit-`0` no-op, never a double-advance (it leans on the engine's checkpoint/gate-state idempotency). An unknown `runId` is exit `2`. Idempotency is **per gate**, though: on a *sequential* multi-gate workflow a blind repeat *without* `--gate` (after the first decision advanced the run and it re-paused at the **next** gate) auto-fills and resolves *that* gate — so an automated retry-until-exit-`0` loop should **pin `--gate `** to avoid resolving later gates unattended. > **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate `relavium budget resume` surface ([deferred-tasks](../../roadmap/deferred-tasks.md)). The `relavium gate list` multi-gate listing lands with `relavium status`/`logs` (2.I). diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 511e6b11..6a6c7696 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -385,7 +385,7 @@ describe('createRunHistoryStore', () => { }); describe('loadRunSnapshot', () => { - it('returns the frozen snapshot + status for a paused run (the 2.G resume substrate)', async () => { + it('returns the frozen workflow snapshot + inputs for a paused run (the 2.G resume substrate)', async () => { const wf = await store.resolveWorkflowId('demo'); await store.persistEvent({ ...ev('run:started', 0, { workflowId: wf, inputs: { n: 3 }, executionMode: 'local' }), @@ -402,7 +402,6 @@ describe('createRunHistoryStore', () => { }); const snap = loadRunSnapshot(client.db, 'run-snap'); - expect(snap?.status).toBe('paused'); // The exact `JSON.stringify(WorkflowDefinition)` written at run:started — round-trips to the parsed graph. expect(snap?.workflowDefinitionSnapshot).toBe(WORKFLOW.definitionJson); expect(JSON.parse(snap?.workflowDefinitionSnapshot ?? '{}')).toMatchObject({ @@ -412,24 +411,6 @@ describe('createRunHistoryStore', () => { expect(JSON.parse(snap?.inputJson ?? '{}')).toEqual({ n: 3 }); }); - it('reflects a terminal run status (so the gate command can no-op an already-finished run)', async () => { - const wf = await store.resolveWorkflowId('demo'); - await store.persistEvent({ - ...ev('run:started', 0, { workflowId: wf, inputs: {}, executionMode: 'local' }), - runId: 'run-done', - }); - await store.persistEvent({ - ...ev('run:completed', 1, { - outputs: {}, - totalTokensUsed: { input: 0, output: 0 }, - totalCostMicrocents: 0, - durationMs: 1, - }), - runId: 'run-done', - }); - expect(loadRunSnapshot(client.db, 'run-done')?.status).toBe('completed'); - }); - it('returns undefined for an unknown runId', () => { expect(loadRunSnapshot(client.db, 'nope')).toBeUndefined(); }); diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 2fde2fe0..07c78103 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -421,9 +421,8 @@ function stepMatch(runId: string, nodeId: string, attemptNumber: number | undefi ); } -/** A paused run's frozen workflow snapshot + inputs + current status, read by id. */ +/** A paused run's frozen workflow snapshot + inputs, read by id (just the bits a cross-process resume rebuilds from). */ export interface RunResumeSnapshot { - readonly status: RunStatus; /** `JSON.stringify(WorkflowDefinition)` — the frozen `runs.workflow_definition_snapshot` (the events don't * carry the graph). The caller re-validates it with the shared `WorkflowSchema` before resuming. */ readonly workflowDefinitionSnapshot: string; @@ -433,19 +432,19 @@ export interface RunResumeSnapshot { } /** - * Read one run's `status` + frozen `workflow_definition_snapshot` + `input_json` by id — the substrate - * `relavium gate` (2.G) needs to rebuild the `WorkflowDefinition` + inputs for a cross-process - * `resumeFromCheckpoint` **before** it knows which workflow the paused run used. Standalone (a plain `runs`-row - * read) rather than a {@link RunHistoryStore} method, because the store is workflow-scoped at construction - * (`deps.workflow`) and the gate command only learns the workflow *from* this snapshot — a chicken-and-egg the - * standalone read resolves. Returns `undefined` for an unknown `runId`. The snapshot/inputs are unsafe-column - * data (no raw secrets — the engine masks at the write boundary, ADR-0050); this read is pass-through and - * never logs them. + * Read one run's frozen `workflow_definition_snapshot` + `input_json` by id — the substrate `relavium gate` + * (2.G) needs to rebuild the `WorkflowDefinition` + inputs for a cross-process `resumeFromCheckpoint` **before** + * it knows which workflow the paused run used. Standalone (a plain `runs`-row read) rather than a + * {@link RunHistoryStore} method, because the store is workflow-scoped at construction (`deps.workflow`) and + * the gate command only learns the workflow *from* this snapshot — a chicken-and-egg the standalone read + * resolves. (Run *status* is not returned — the gate command uses the authoritative `checkpoint.runStatus` + * folded fresh from the event log, and `loadRun(runId).status` covers any status-by-id need.) Returns + * `undefined` for an unknown `runId`. The snapshot/inputs are unsafe-column data (no raw secrets — the engine + * masks at the write boundary, ADR-0050); this read is pass-through and never logs them. */ export function loadRunSnapshot(db: Db, runId: string): RunResumeSnapshot | undefined { const row = db .select({ - status: runs.status, snapshot: runs.workflowDefinitionSnapshot, inputJson: runs.inputJson, }) @@ -455,7 +454,6 @@ export function loadRunSnapshot(db: Db, runId: string): RunResumeSnapshot | unde return row === undefined ? undefined : { - status: row.status, workflowDefinitionSnapshot: row.snapshot, inputJson: row.inputJson, }; From d4268d3366238e984e959d71330aadaef52b18d0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 12:00:01 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(cli):=202.G=20PR=20#47=20review=20round?= =?UTF-8?q?=20=E2=80=94=20corrupt-log=20exit-2=20+=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the still-valid PR #47 review findings (verified against the code; the rest skipped with reasons). - gate.ts: wrap checkpointer.load so a malformed run_events row (bad JSON / failed schema parse during the fold) surfaces as a clean exit-2 CliError, matching the snapshot/inputs handling, never a raw escaping error. Test added. - gate.ts: parseInputs uses an isPlainObject type guard instead of an `as` cast (strictly narrowed, no assertion). - drive.ts: drop the redundant `continue` — fold the run:paused break into one condition (Sonar). - specs.ts: the gate action passes `{ runId, ...opts }` (the conditional spread was unnecessary — the spread preserves optionality under exactOptionalPropertyTypes). - gate.test.ts: toHaveLength over `.length).toBe` (Sonar). - deferred-tasks.md: trim the budget-resume entry to a summary + link to the canonical commands.md rather than restating the engine internals. Skipped: current.md "Next pickup: 2.G" — not a contradiction; the roadmap status surfaces flip to Done only after PR #47 merges (the project's Done-after-merge rule, as done for 2.E). Refs: ADR-0047, ADR-0050 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/drive.ts | 12 +++++++----- apps/cli/src/commands/gate.test.ts | 14 +++++++++++++- apps/cli/src/commands/gate.ts | 22 +++++++++++++++++++--- apps/cli/src/commands/specs.ts | 9 +-------- docs/roadmap/deferred-tasks.md | 16 ++++++---------- 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 33012b0e..1f314096 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -73,11 +73,13 @@ export async function driveRun(deps: DriveRunDeps): Promise { }); }); + it('surfaces a corrupt persisted event log as a clean exit-2 fault (no raw escaping error)', async () => { + const { runId } = await setupPausedRun(); + // Corrupt a run_events payload so the checkpoint reconstruction (loadRunEvents → JSON.parse) throws. + client.sqlite + .prepare('UPDATE run_events SET payload_json = ? WHERE run_id = ? AND seq = 0') + .run('{not json', runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + it('resumes on --reject, persisting the rejection + comment', async () => { const { runId } = await setupPausedRun(); const { io } = captureIo(); @@ -169,7 +181,7 @@ describe('gateCommand', () => { const code = await gateCommand({ runId, approve: true }, deps(io)); // second decision expect(code).toBe(EXIT_CODES.success); expect(out()).toContain('nothing to resume'); // idempotent message, not a re-run - expect(reader.loadRunEvents(runId).length).toBe(eventsAfterFirst); // NO new events — not advanced twice + expect(reader.loadRunEvents(runId)).toHaveLength(eventsAfterFirst); // NO new events — not advanced twice }); it('rejects an unknown runId (exit 2)', async () => { diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 72bda826..1660993e 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -107,7 +107,18 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): }, }); const checkpointer = createHistoryCheckpointer(store); - const checkpoint = await checkpointer.load(args.runId); + let checkpoint: CheckpointState | undefined; + try { + checkpoint = await checkpointer.load(args.runId); + } catch (err) { + // A malformed run_events row (bad JSON / failed schema parse during the fold) — surface a corrupt log + // as a clean exit-2 fault, matching the snapshot/inputs handling, never a raw escaping error. + throw new CliError( + 'invalid_invocation', + `the persisted event log for run ${args.runId} could not be read`, + { cause: err }, + ); + } if (checkpoint === undefined) { // A run row with a snapshot but no reconstructable checkpoint (no run:started in the log) — corrupt/partial. throw new CliError('invalid_invocation', `run ${args.runId} has no resumable state`); @@ -249,6 +260,11 @@ function parseSnapshot(snapshotJson: string, runId: string): WorkflowDefinition * (matching {@link parseSnapshot}) — never a silent `{}`, which would resume with every `{{ inputs.x }}` * evaluating to `undefined` and fail confusingly at a downstream node instead of cleanly up front. */ +/** A non-null, non-array object — the shape a run's restored inputs must have (a guard, so no `as` cast). */ +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function parseInputs(inputJson: string, runId: string): Record { let parsed: unknown; try { @@ -260,11 +276,11 @@ function parseInputs(inputJson: string, runId: string): Record { cause: err }, ); } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + if (!isPlainObject(parsed)) { throw new CliError( 'invalid_invocation', `the stored inputs for run ${runId} are not a JSON object`, ); } - return parsed as Record; + return parsed; // narrowed by isPlainObject — no cast } diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index f4ffb2b8..4ccf0c9e 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -161,14 +161,7 @@ function registerGate(program: Command, ctx?: CommandContext): void { }, ) => { ctx.result.exitCode = await gateCommand( - { - runId, - ...(opts.approve === undefined ? {} : { approve: opts.approve }), - ...(opts.reject === undefined ? {} : { reject: opts.reject }), - ...(opts.comment === undefined ? {} : { comment: opts.comment }), - ...(opts.input === undefined ? {} : { input: opts.input }), - ...(opts.gate === undefined ? {} : { gate: opts.gate }), - }, + { runId, ...opts }, { io: ctx.io, global: ctx.global, diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 4ebde890..5079e963 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -482,17 +482,13 @@ Severity is the review's verified rating. Check an item off in the PR that resol decide whether exit 3 (and the rendered message) should distinguish a gate park from a media park. *(low · apps/cli/src/commands/run.ts; media host-wiring / 2.S)* - [ ] **`relavium budget resume [--approve | --abort]` is documented but has no numbered - workstream.** [commands.md](../reference/cli/commands.md) lists `budget resume` as the non-interactive + workstream.** [commands.md](../reference/cli/commands.md) (canonical) specifies it as the non-interactive operator path for a run suspended at a budget cap (`budget:paused`, `on_exceed: pause_for_approval` — - [ADR-0028](../decisions/0028-workflow-resource-governance.md)), but no Phase-2 workstream implements it. - It reuses **2.G's** cross-process resume substrate end-to-end: a `budget:paused` reconstructs as a - pending gate with `isBudgetGate: true` (`reconstructCheckpointState` / `CheckpointPendingGate`), so the - command loads the snapshot + checkpoint exactly like `relavium gate` and calls `engine.resumeFromCheckpoint` - with a `GateDecision` — `--approve` continues the one-shot deferred call (a pre-egress bypass), `--abort` - fails the run. The delta from `gate` is only the command surface + the two budget-specific flags; it is - **deliberately out of 2.G** (not in its acceptance criteria, and a distinct ADR-0028 surface). Schedule it - as a small follow-up once 2.G lands the substrate — candidate home: alongside 2.I (the read/operator - commands) or its own short workstream. *(low · apps/cli/src/commands/; ADR-0028; reuses 2.G resume path)* + [ADR-0028](../decisions/0028-workflow-resource-governance.md)), but no Phase-2 workstream implements it. It + reuses **2.G's** cross-process resume substrate (a budget pause resolves through the same checkpoint reload + + resume path as a human gate, behind a budget-specific command + flags), so it is a small follow-up once + 2.G lands — candidate home: alongside 2.I, or its own short workstream. **Deliberately out of 2.G** (a + distinct ADR-0028 surface, not in 2.G's acceptance). *(low · apps/cli/src/commands/; ADR-0028)* ## Schema / validation hardening From 9e3b6e875dce4342c84e54661958e974595e76e0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 12:50:22 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(cli,db):=202.G=20PR=20#47=20comprehensi?= =?UTF-8?q?ve=20review=20=E2=80=94=20masked-secret=20resume=20guard=20+=20?= =?UTF-8?q?cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all confirmed findings from a 10-dimension adversarial review (workflow, 0 blockers, 1 major) plus two external review batches. Verified each against the code; fixed the still-valid ones, skipped 4 with reasons. MAJOR — masked-secret cross-process resume (the completeness critic's catch): runs.input_json persists the MASKED run:started.inputs (a `secret`-typed input is stored as { secret: true, ref }, never plaintext — ADR-0006/0036), so a fresh-process resume restored secrets as the masked placeholder, silently diverging from the in-process run. gate.ts now fails closed (exit 2) when a restored input is a MaskedSecret, with an actionable message; re-provide-on-resume is a tracked follow-up. + fixture/test + doc. Code: - gate.ts: a closed-handle resume (concurrent-settle race) now maps to idempotent exit 0, not exit 1. - drive.ts: resolveGateInline's finally catches+logs a renderer.resume() throw so it can't mask the prompt decision/error; outcomeToExitCode is now a shared helper (run + gate no longer duplicate the switch). - ink-renderer.ts: finalize writes the summary in a finally, so a stop() (waitUntilExit) rejection can't lose it. - clack-prompter.ts: an input-gate prompt uses a generic label (the card already shows the message — no double-echo). parseInputs JSDoc reorder; `let handle: RunHandle` typed. Tests (+15): secret→exit2, closed-handle→exit0, EngineStateError→exit2, empty-log→exit2, parseInputs[]/corrupt-snapshot x2, --input e2e, sequential multi-gate + prompter wiring, cancel-on-prompt-throw, resume-throw-doesn't-mask, finalize-summary-survives-stop-reject, mount-throw-on-resume, all-three-flags, clack review/timeout. The harness gate-resume scenario now uses a real FILE db with a SEPARATE connection for the resume (true reopen) and asserts gap-free sequenceNumber continuation across the boundary. Docs: commands.md --input secrets prohibition + non-secret example + masked-secret-resume limitation; deferred-tasks re-provide-secret-on-resume follow-up; phase-2-cli.md step numbers corrected + `gate list` reconciled to 2.I (canonical commands.md). Skipped: createCliHost checkpointer/store enforcement (no bug — sole caller derives both from the same store; a runtime same-backend check is impossible, type coupling breaches the db layering); SIGINT-leak gateCommand test (redundant — contract lives in driveRun, covered by run.test); redundant `only !== undefined` (TS array-destructure requires it); driveRun-cancel-on-closed-handle (inert, folded into the closed-handle fix). Refs: ADR-0047, ADR-0050, ADR-0006 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/drive.test.ts | 25 ++- apps/cli/src/commands/drive.ts | 35 +++- apps/cli/src/commands/gate.test.ts | 205 ++++++++++++++++++- apps/cli/src/commands/gate.ts | 56 +++-- apps/cli/src/commands/run.ts | 14 +- apps/cli/src/gate/clack-prompter.test.ts | 20 ++ apps/cli/src/gate/clack-prompter.ts | 8 +- apps/cli/src/gate/decision.test.ts | 3 + apps/cli/src/harness/regression.e2e.test.ts | 71 ++++--- apps/cli/src/render/tui/ink-renderer.test.ts | 35 ++++ apps/cli/src/render/tui/ink-renderer.ts | 22 +- docs/reference/cli/commands.md | 5 +- docs/roadmap/deferred-tasks.md | 8 + docs/roadmap/phases/phase-2-cli.md | 14 +- 14 files changed, 445 insertions(+), 76 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index 60b35702..a9b36b57 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -114,19 +114,42 @@ describe('driveRun — interactive gate', () => { expect(events.at(-1)?.type).toBe('run:cancelled'); }); - it('re-mounts the TUI even if the prompt throws (finally), then the error unwinds', async () => { + it('re-mounts the TUI even if the prompt throws (finally), cancels the live run, then the error unwinds', async () => { const { engine, handle } = await startGatedRun(); const { renderer } = recordingRenderer(); const prompter: GatePrompter = { prompt: () => Promise.reject(new Error('prompt boom')), }; const { io } = captureIo(); + const cancelSpy = vi.spyOn(handle, 'cancel'); await expect( driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }), ).rejects.toThrow('prompt boom'); expect(renderer.resume).toHaveBeenCalledTimes(1); // the suspend/prompt/resume finally restored the view expect(renderer.finalize).toHaveBeenCalledTimes(1); // driveRun's finally still finalized + expect(cancelSpy).toHaveBeenCalledTimes(1); // ...and cancelled the still-live run (abnormal-unwind guard) + }); + + it('a renderer.resume() throw does NOT mask the prompt error (logs the resume failure to stderr)', async () => { + const { engine, handle } = await startGatedRun(); + const renderer: RunRenderer = { + onEvent: () => {}, + suspend: vi.fn(), + resume: vi.fn(() => { + throw new Error('resume boom'); + }), + finalize: vi.fn(), + }; + const prompter: GatePrompter = { + prompt: () => Promise.reject(new Error('prompt boom')), + }; + const { io, err } = captureIo(); + + await expect( + driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }), + ).rejects.toThrow('prompt boom'); // the PROMPT error survives — the resume throw did not replace it + expect(err()).toContain('failed to restore the live view'); // the resume failure went to stderr instead }); it('without a prompter, a gate pause stops the loop (→ paused, exit 3)', async () => { diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 1f314096..ff2b21eb 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -2,7 +2,7 @@ import { EngineStateError, type RunHandle, type WorkflowEngine } from '@relavium import type { HumanGatePausedEvent, RunEvent, RunPausedEvent } from '@relavium/shared'; import type { GatePrompter } from '../gate/prompter.js'; -import { EXIT_CODES } from '../process/exit-codes.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { RunRenderer } from '../render/renderer.js'; @@ -70,7 +70,7 @@ export async function driveRun(deps: DriveRunDeps): Promise { let decision: Awaited>; await renderer.suspend?.(); try { decision = await prompter.prompt(event); } finally { - await renderer.resume?.(); + // Re-mount best-effort: a re-mount failure (ink's render() throwing) must NOT mask the prompt's decision + // or its error — a throwing `finally` would replace the try's outcome. Surface it to stderr and move on, + // exactly like driveRun's teardown (finalize) guard above. + try { + await renderer.resume?.(); + } catch (resumeErr) { + io.writeErr( + `failed to restore the live view after the gate prompt: ${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}\n`, + ); + } } if (decision === null) { handle.cancel(); @@ -166,6 +176,25 @@ export function shouldBreakOnPause( return mediaPark || unhandledGate; } +/** + * Map a {@link RunOutcome} (or `undefined` — the stream ended with no terminal/paused, an abnormal unwind) to + * its CLI exit code. The single owner of the outcome→exit contract, shared by `run` and `gate` so the two can + * never drift and a new `RunOutcome` variant has exactly one place to update. (`gate` handles its own + * `undefined` case — an idempotent closed-handle resume → exit 0 — BEFORE calling this; here `undefined` is the + * generic abnormal-unwind → failure, which is what `run` wants.) + */ +export function outcomeToExitCode(outcome: RunOutcome | undefined): ExitCode { + switch (outcome) { + case 'completed': + return EXIT_CODES.success; + case 'paused': + return EXIT_CODES.gatePaused; + default: + // failed / cancelled / (an abnormal no-terminal `undefined`) → non-zero workflow failure. + return EXIT_CODES.workflowFailed; + } +} + function nextOutcome(current: RunOutcome | undefined, event: RunEvent): RunOutcome | undefined { switch (event.type) { case 'run:completed': diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 36d22730..d1150643 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,23 +1,50 @@ import { randomUUID } from 'node:crypto'; -import { parseWorkflow, type CheckpointState } from '@relavium/core'; +import { + EngineStateError, + parseWorkflow, + type CheckpointState, + type RunHandle, + type WorkflowEngine, +} from '@relavium/core'; import { createClient, createRunHistoryStore, runMigrations, type Db, type DbClient, + type RunHistoryStore, } from '@relavium/db'; +import type { RunEvent } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildEngine } from '../engine/build-engine.js'; import { createCliHost } from '../engine/host.js'; +import type { GatePrompter } from '../gate/prompter.js'; import { isCliError } from '../process/errors.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo } from '../test-support.js'; import { gateCommand, selectGate, type GateCommandDeps } from './gate.js'; +/** A WorkflowEngine stub exposing only resumeFromCheckpoint — for the closed-handle / EngineStateError paths + * that the real engine can't be driven into deterministically (they need a concurrent-settle race). */ +function stubEngine(resumeFromCheckpoint: WorkflowEngine['resumeFromCheckpoint']): WorkflowEngine { + return { resumeFromCheckpoint } as unknown as WorkflowEngine; +} + +/** A closed RunHandle: its event stream completes immediately with zero events (what createClosedRunHandle yields). */ +function emptyHandle(runId: string): RunHandle { + return { + runId, + // eslint-disable-next-line @typescript-eslint/require-await + events: (async function* (): AsyncGenerator {})(), + subscribe: () => () => {}, + cancel: () => {}, + whenConsumersReady: () => Promise.resolve(), + }; +} + // gate → double (reads inputs.n, restored on resume) → out. The in-memory host pauses at the fail-closed gate. const GATED = `schema_version: '1.0' workflow: @@ -51,6 +78,49 @@ workflow: - { from: g2, to: out } `; +// SEQUENTIAL gates (g1 → g2): only g1 pends initially; resolving it re-pauses the run at g2. +const SEQ_GATES = `schema_version: '1.0' +workflow: + id: gate-seq + nodes: + - { id: start, type: input } + - { id: g1, type: human_gate, gate_type: approval } + - { id: g2, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g1 } + - { from: g1, to: g2 } + - { from: g2, to: out } +`; + +// A gate_type=input gate, for the --input resume path. +const INPUT_GATED = `schema_version: '1.0' +workflow: + id: gate-input + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: input } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: out } +`; + +// A run with a SECRET-typed input — masked at persist time, so a cross-process resume cannot restore it. +const SECRET_GATED = `schema_version: '1.0' +workflow: + id: gate-secret + inputs: + - { name: token, type: secret } + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: out } +`; + function globalOptions(): GlobalOptions { return { json: false, @@ -79,6 +149,15 @@ describe('gateCommand', () => { return { io, global: globalOptions(), openDb: () => ({ db, close: () => {} }) }; } + /** A read-only history store over the shared db (for asserting persisted events post-resume). */ + function reader(): RunHistoryStore { + return createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'x', name: 'x', definitionJson: '{}' }, + }); + } + /** Drive a gated workflow to its pause against the db store (persisting run:started → run:paused). */ async function setupPausedRun( yaml = GATED, @@ -152,6 +231,130 @@ describe('gateCommand', () => { }); }); + it('surfaces a valid-JSON-but-non-object inputs blob (array) as exit-2', async () => { + const { runId } = await setupPausedRun(); + client.sqlite.prepare('UPDATE runs SET input_json = ? WHERE id = ?').run('[]', runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + + it('surfaces a corrupt workflow snapshot (bad JSON) as exit-2', async () => { + const { runId } = await setupPausedRun(); + client.sqlite + .prepare('UPDATE runs SET workflow_definition_snapshot = ? WHERE id = ?') + .run('{not json', runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + + it('surfaces a schema-invalid workflow snapshot as exit-2', async () => { + const { runId } = await setupPausedRun(); + client.sqlite + .prepare('UPDATE runs SET workflow_definition_snapshot = ? WHERE id = ?') + .run('{"workflow":{}}', runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + + it('surfaces a run with a snapshot but no event log as exit-2 (no resumable state)', async () => { + const { runId } = await setupPausedRun(); + client.sqlite.prepare('DELETE FROM run_events WHERE run_id = ?').run(runId); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + }); + + it('fails closed (exit-2) on a run with a SECRET input — a masked value can never be restored on resume', async () => { + const { runId } = await setupPausedRun(SECRET_GATED, { token: ['s', 'k', '-secret'].join('') }); + const { io } = captureIo(); + await expect(gateCommand({ runId, approve: true }, deps(io))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + // And the masked placeholder is what was persisted — never the plaintext (defence-in-depth check). + const started = reader() + .loadRunEvents(runId) + .find((e) => e.type === 'run:started'); + expect(JSON.stringify(started)).not.toContain('sk-secret'); + }); + + it('resolves an input gate via --input end-to-end, persisting the input_provided payload', async () => { + const { runId } = await setupPausedRun(INPUT_GATED, {}); + const { io } = captureIo(); + const code = await gateCommand({ runId, input: 'us-east-1' }, deps(io)); + expect(code).toBe(EXIT_CODES.success); + expect( + reader() + .loadRunEvents(runId) + .find((e) => e.type === 'human_gate:resumed'), + ).toMatchObject({ + decision: 'input_provided', + payload: 'us-east-1', + }); + }); + + it('maps a closed-handle resume (a concurrent-settle race) to an idempotent exit 0', async () => { + const { runId } = await setupPausedRun(); + const { io, out } = captureIo(); + const code = await gateCommand( + { runId, approve: true }, + { + ...deps(io), + buildEngine: () => Promise.resolve(stubEngine(() => Promise.resolve(emptyHandle(runId)))), + }, + ); + expect(code).toBe(EXIT_CODES.success); // a closed handle (already terminal) is an idempotent no-op, not a failure + expect(out()).toContain('already settled'); + }); + + it('maps a workflow_mismatch EngineStateError from resumeFromCheckpoint to exit-2', async () => { + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + await expect( + gateCommand( + { runId, approve: true }, + { + ...deps(io), + buildEngine: () => + Promise.resolve( + stubEngine(() => + Promise.reject(new EngineStateError('workflow_mismatch', 'mismatch')), + ), + ), + }, + ), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + }); + + it('a sequential multi-gate: blind --approve resolves g1 (re-pause → exit 3), a second resolves g2 (exit 0)', async () => { + // The documented per-gate idempotency footgun: without --gate, a repeat auto-fills the NOW-pending next gate. + const { runId, gateIds } = await setupPausedRun(SEQ_GATES, {}); + expect(gateIds).toHaveLength(1); // only g1 pends initially (sequential, not parallel) + const { io } = captureIo(); + expect(await gateCommand({ runId, approve: true }, deps(io))).toBe(EXIT_CODES.gatePaused); // g1 → re-pause at g2 + expect(await gateCommand({ runId, approve: true }, deps(io))).toBe(EXIT_CODES.success); // blind repeat resolves g2 + }); + + it('wires selectGatePrompter through to driveRun: a re-pause at a later gate is resolved inline (exit 0)', async () => { + const { runId } = await setupPausedRun(SEQ_GATES, {}); + const { io } = captureIo(); + const prompter: GatePrompter = { + prompt: () => Promise.resolve({ decision: 'approved', decidedBy: 'cli' }), + }; + // Resume g1 via the flag; the run re-pauses at g2, which the INJECTED prompter resolves inline → completes. + const code = await gateCommand( + { runId, approve: true }, + { ...deps(io), selectGatePrompter: () => prompter }, + ); + expect(code).toBe(EXIT_CODES.success); + }); + it('resumes on --reject, persisting the rejection + comment', async () => { const { runId } = await setupPausedRun(); const { io } = captureIo(); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 1660993e..312b8a45 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -3,11 +3,12 @@ import { randomUUID } from 'node:crypto'; import { EngineStateError, type CheckpointState, + type RunHandle, type WorkflowDefinition, type WorkflowEngine, } from '@relavium/core'; import { createRunHistoryStore, loadRunSnapshot, type Db } from '@relavium/db'; -import { WorkflowSchema, type RunStatus } from '@relavium/shared'; +import { MaskedSecretSchema, WorkflowSchema, type RunStatus } from '@relavium/shared'; import { loadResolvedConfig } from '../config/load.js'; import { openLocalDb } from '../db/open.js'; @@ -27,7 +28,7 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; -import { driveRun } from './drive.js'; +import { driveRun, outcomeToExitCode } from './drive.js'; export interface GateCommandArgs extends GateFlags { readonly runId: string; @@ -94,6 +95,7 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): const workflow = parseSnapshot(snapshot.workflowDefinitionSnapshot, args.runId); const inputs = parseInputs(snapshot.inputJson, args.runId); + assertNoMaskedSecretInputs(inputs, args.runId); // The workflow-scoped store records the NEW resume events (persist-before-deliver) and resolves the // workflow id for the engine's identity guard; the checkpointer reconstructs the paused state from the log. @@ -143,7 +145,7 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): providers, host: createCliHost(store, { checkpointer }), }); - let handle; + let handle: RunHandle; try { handle = await engine.resumeFromCheckpoint({ runId: args.runId, @@ -175,15 +177,15 @@ export async function gateCommand(args: GateCommandArgs, deps: GateCommandDeps): io: deps.io, }); - switch (outcome) { - case 'completed': - return EXIT_CODES.success; - case 'paused': - // The resumed run paused again at a LATER gate — resume that one next (exit 3, like a fresh CI pause). - return EXIT_CODES.gatePaused; - default: - return EXIT_CODES.workflowFailed; + if (outcome === undefined) { + // The resumed handle closed with NO events. The engine returns a closed handle when its own internal + // checkpoint re-read already found the run terminal — i.e. a concurrent `relavium gate` settled it in the + // window between our selectGate pre-check and the engine's re-read. That is an idempotent no-op (the run + // already completed), not a failure: exit 0, mirroring the selectGate terminal path. + deps.io.writeOut(`run ${args.runId} already settled; nothing to resume\n`); + return EXIT_CODES.success; } + return outcomeToExitCode(outcome); } finally { opened.close(); } @@ -255,16 +257,16 @@ function parseSnapshot(snapshotJson: string, runId: string): WorkflowDefinition } } -/** - * Restore the run's inputs from the frozen `input_json`. A corrupt / non-object value is an exit-2 fault - * (matching {@link parseSnapshot}) — never a silent `{}`, which would resume with every `{{ inputs.x }}` - * evaluating to `undefined` and fail confusingly at a downstream node instead of cleanly up front. - */ /** A non-null, non-array object — the shape a run's restored inputs must have (a guard, so no `as` cast). */ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Restore the run's inputs from the frozen `input_json`. A corrupt / non-object value is an exit-2 fault + * (matching {@link parseSnapshot}) — never a silent `{}`, which would resume with every `{{ inputs.x }}` + * evaluating to `undefined` and fail confusingly at a downstream node instead of cleanly up front. + */ function parseInputs(inputJson: string, runId: string): Record { let parsed: unknown; try { @@ -284,3 +286,25 @@ function parseInputs(inputJson: string, runId: string): Record } return parsed; // narrowed by isPlainObject — no cast } + +/** + * Fail closed if any restored input is a {@link MaskedSecret} placeholder. The durable `run:started.inputs` + * the engine persists are **masked** — a `secret`-typed input is stored as `{ secret: true, ref }`, never its + * plaintext (ADR-0006/0036). So a cross-process resume genuinely cannot restore the real value: resuming with + * the masked placeholder would let a post-gate `{{ inputs. }}` silently evaluate to the placeholder + * object, diverging from the in-process run. We refuse (exit 2) with an actionable message rather than resume + * a secret-bearing run incorrectly. (Re-providing secret inputs on resume is a tracked follow-up — see + * [deferred-tasks](../../../../docs/roadmap/deferred-tasks.md).) + */ +function assertNoMaskedSecretInputs(inputs: Record, runId: string): void { + const masked = Object.keys(inputs).filter( + (key) => MaskedSecretSchema.safeParse(inputs[key]).success, + ); + if (masked.length > 0) { + throw new CliError( + 'invalid_invocation', + `run ${runId} has secret input(s) [${masked.join(', ')}] that are not persisted in plaintext, so a ` + + `cross-process resume cannot restore them — re-run the workflow instead of resuming.`, + ); + } +} diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index e2b69f3e..44a6ac41 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -22,13 +22,13 @@ import type { GatePrompter } from '../gate/prompter.js'; import { selectGatePrompter } from '../gate/select-prompter.js'; import type { OpenedHistory } from '../history/open.js'; import { CliError } from '../process/errors.js'; -import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import { type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; -import { driveRun } from './drive.js'; +import { driveRun, outcomeToExitCode } from './drive.js'; import { parseInputArgs, resolveInputs } from './inputs.js'; export interface RunCommandArgs { @@ -142,15 +142,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr io: deps.io, }); - switch (outcome) { - case 'completed': - return EXIT_CODES.success; - case 'paused': - return EXIT_CODES.gatePaused; - default: - // failed / cancelled / (an unreachable no-terminal) → non-zero workflow failure. - return EXIT_CODES.workflowFailed; - } + return outcomeToExitCode(outcome); } finally { opened?.close(); } diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts index 8086da12..76191531 100644 --- a/apps/cli/src/gate/clack-prompter.test.ts +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -100,6 +100,26 @@ describe('createClackGatePrompter', () => { expect(text).not.toHaveBeenCalled(); // approved → no comment prompt }); + it('review gate → reject collects a comment, and → cancel returns null', async () => { + const rejected = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(false), text: () => Promise.resolve('looks off') }), + ).prompt(gate({ gateType: 'review' })); + expect(rejected).toEqual({ decision: 'rejected', decidedBy: 'cli', comment: 'looks off' }); + + const cancelled = await createClackGatePrompter( + deps({ confirm: () => Promise.resolve(CANCEL) }), + ).prompt(gate({ gateType: 'review' })); + expect(cancelled).toBeNull(); + }); + + it('the card defaults to auto-reject when the gate has a deadline but no timeoutAction', async () => { + const note = vi.fn<(message: string, title: string) => void>(); + await createClackGatePrompter(deps({ note })).prompt( + gate({ expiresAt: '2026-06-24T11:00:00.000Z' }), // timeoutAction omitted → `?? 'reject'` + ); + expect(note.mock.calls[0]?.[0]).toContain('auto-reject'); + }); + it('shows the deadline in the card when the gate has one', async () => { const note = vi.fn<(message: string, title: string) => void>(); await createClackGatePrompter(deps({ note })).prompt( diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 79a87f80..84e63323 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -59,10 +59,10 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga if (event.gateType === 'input') { // A human types a value: kept as the raw string (predictable). The structured/JSON path is the - // scripted `relavium gate --input ` flag (see decision.ts `parseGateInput`). - const value = await deps.text({ - message: event.message.trim() === '' ? 'Provide input' : event.message, - }); + // scripted `relavium gate --input ` flag (see decision.ts `parseGateInput`). The prompt label is + // a generic 'Enter value' — the gate's message is already shown in the card above, so repeating it on + // the prompt line would just echo the same text twice. + const value = await deps.text({ message: 'Enter value', placeholder: '' }); return deps.isCancel(value) ? null : inputDecision(value); } diff --git a/apps/cli/src/gate/decision.test.ts b/apps/cli/src/gate/decision.test.ts index a239c30c..1de40972 100644 --- a/apps/cli/src/gate/decision.test.ts +++ b/apps/cli/src/gate/decision.test.ts @@ -56,6 +56,9 @@ describe('decisionFromFlags', () => { expect(decisionFromFlags({ approve: true, reject: true })).toMatchObject({ ok: false }); expect(decisionFromFlags({ approve: true, input: 'x' })).toMatchObject({ ok: false }); expect(decisionFromFlags({ reject: true, input: 'x' })).toMatchObject({ ok: false }); + expect(decisionFromFlags({ approve: true, reject: true, input: 'x' })).toMatchObject({ + ok: false, + }); }); it('builds an approval (with optional comment)', () => { diff --git a/apps/cli/src/harness/regression.e2e.test.ts b/apps/cli/src/harness/regression.e2e.test.ts index 6f598eff..c6a3235a 100644 --- a/apps/cli/src/harness/regression.e2e.test.ts +++ b/apps/cli/src/harness/regression.e2e.test.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -308,12 +310,16 @@ describe('engine regression harness (2.K) — offline fixtures over `relavium ru // command — the cross-process path (reload snapshot + reconstruct checkpoint + resumeFromCheckpoint) — and // assert it drives to `run:completed`. This closes 2.K's deferred half over the same offline fixtures. it('resumes a gate-paused run via `relavium gate --approve` to completion (closes 2.K’s deferred half)', async () => { - const client = createClient(':memory:'); - runMigrations(client.db); + // A real FILE db (not `:memory:`) so the gate leg opens a genuinely SEPARATE connection — exercising the + // reopen/reload a fresh-process `relavium gate` does, not a shared in-memory handle. + const dir = mkdtempSync(join(tmpdir(), 'relavium-harness-')); + const dbPath = join(dir, 'history.db'); + const runClient = createClient(dbPath); + runMigrations(runClient.db); try { - // Persist the run so a fresh-process `gate` can reload it (the durable substrate 2.H gives 2.G). + // Persist the run so a fresh connection's `gate` can reload it (the durable substrate 2.H gives 2.G). const openRunStore = (workflow: WorkflowDefinition): OpenedHistory => ({ - store: createRunHistoryStore(client.db, { + store: createRunHistoryStore(runClient.db, { uuid: () => randomUUID(), now: () => Date.now(), workflow: { @@ -325,39 +331,54 @@ describe('engine regression harness (2.K) — offline fixtures over `relavium ru close: () => {}, }); - // 1. Run to the gate → exit 3, persisted to the db. + // 1. Run to the gate → exit 3, persisted to the FILE. const runIo = captureIo(); const runCode = await runCommand( { workflow: join(FIXTURES_DIR, 'human-gate.relavium.yaml'), input: [] }, { io: runIo.io, global: globalOptions(), openRunStore }, ); expect(runCode).toBe(EXIT_CODES.gatePaused); - const runId = parseEvents(runIo.out()).find((e) => e.type === 'run:started')?.runId; + const preEvents = parseEvents(runIo.out()); + const runId = preEvents.find((e) => e.type === 'run:started')?.runId; if (runId === undefined) { throw new Error('expected a run:started event carrying the runId'); } + // Close the run's connection — the resume runs against a brand-new connection to the same file. + runClient.sqlite.close(); - // 2. Resume from a FRESH process (the `gate` command) over the same db → exit 0, driven to completion. + // 2. Resume over a FRESH connection (a separate `gate` process reopening the file) → exit 0, to completion. + const gateClient = createClient(dbPath); + runMigrations(gateClient.db); // idempotent — mirrors openLocalDb's migrate-on-open const gateIo = captureIo(); - const gateCode = await gateCommand( - { runId, approve: true }, - { - io: gateIo.io, - global: globalOptions(), - openDb: () => ({ db: client.db, close: () => {} }), - }, - ); - expect(gateCode).toBe(EXIT_CODES.success); - expect(gateIo.err()).toBe(''); // the resume stream is stdout-pure NDJSON too (ADR-0049) - // The resume emits exactly the continuation: the gate resolves, then the downstream output, then done. - expect(parseEvents(gateIo.out()).map(signature)).toEqual([ - 'human_gate:resumed:gate', - 'node:started:out', - 'node:completed:out', - 'run:completed', - ]); + try { + const gateCode = await gateCommand( + { runId, approve: true }, + { + io: gateIo.io, + global: globalOptions(), + openDb: () => ({ db: gateClient.db, close: () => {} }), + }, + ); + expect(gateCode).toBe(EXIT_CODES.success); + expect(gateIo.err()).toBe(''); // the resume stream is stdout-pure NDJSON too (ADR-0049) + const resumed = parseEvents(gateIo.out()); + // The resume emits exactly the continuation: the gate resolves, then the downstream output, then done. + expect(resumed.map(signature)).toEqual([ + 'human_gate:resumed:gate', + 'node:started:out', + 'node:completed:out', + 'run:completed', + ]); + // 2.G's headline invariant: the resumed stream continues the seq counter GAP-FREE from the pre-resume + // max (lastSequenceNumber + 1), proving the cross-process counter was reseeded — not restarted at 0. + const lastPreSeq = Math.max(...preEvents.map((e) => e.sequenceNumber)); + const resumedSeqs = resumed.map((e) => e.sequenceNumber); + expect(resumedSeqs).toEqual(resumedSeqs.map((_, i) => lastPreSeq + 1 + i)); + } finally { + gateClient.sqlite.close(); + } } finally { - client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); } }); }); diff --git a/apps/cli/src/render/tui/ink-renderer.test.ts b/apps/cli/src/render/tui/ink-renderer.test.ts index 755738a0..c9ba72ca 100644 --- a/apps/cli/src/render/tui/ink-renderer.test.ts +++ b/apps/cli/src/render/tui/ink-renderer.test.ts @@ -150,4 +150,39 @@ describe('createInkRenderer', () => { expect(clearSpy).toHaveBeenCalled(); // the setInterval frame loop was cleared, not leaked (finalize never runs) clearSpy.mockRestore(); }); + + it('writes the persistent summary even if stop() rejects (a waitUntilExit throw must not lose it)', async () => { + const unmount = vi.fn(); + const waitUntilExit = vi.fn(() => Promise.reject(new Error('exit boom'))); + const summaries: string[] = []; + const renderer = createInkRenderer({ + color: false, + mount: () => ({ unmount, waitUntilExit }), + writeSummary: (text) => summaries.push(text), + }); + renderer.onEvent({ type: 'run:cancelled', runId: RUN, timestamp: TS, sequenceNumber: 1 }); + // The stop() rejection still propagates (driveRun's outer catch logs it), but the summary is written first. + await expect(renderer.finalize?.()).rejects.toThrow('exit boom'); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toContain('run cancelled'); + }); + + it('a mount() failure during resume() re-throws (the re-mount path), and the frame loop is not leaked', async () => { + let mounts = 0; + const unmount = vi.fn(); + const waitUntilExit = vi.fn(() => Promise.resolve()); + const mount = (): InkMountInstance => { + mounts += 1; + if (mounts === 2) { + throw new Error('re-mount failed'); // construction mount ok; the resume re-mount throws + } + return { unmount, waitUntilExit }; + }; + const clearSpy = vi.spyOn(globalThis, 'clearInterval'); + const renderer = createInkRenderer({ color: false, mount, writeSummary: () => {} }); + await renderer.suspend?.(); + expect(() => renderer.resume?.()).toThrow('re-mount failed'); + expect(clearSpy).toHaveBeenCalled(); // start() cleared the new frame loop on the mount throw — not leaked + clearSpy.mockRestore(); + }); }); diff --git a/apps/cli/src/render/tui/ink-renderer.ts b/apps/cli/src/render/tui/ink-renderer.ts index 85a4259b..65dacc0f 100644 --- a/apps/cli/src/render/tui/ink-renderer.ts +++ b/apps/cli/src/render/tui/ink-renderer.ts @@ -122,14 +122,20 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { } finalized = true; store.flush(); // paint the final live frame - await stop(); - // The live frames are ephemeral; write the persistent plain-text summary into the scrollback. - const write = - options.writeSummary ?? - ((text: string): void => { - stdout.write(text); - }); - write(store.summaryText()); + // The persistent summary MUST be written even if stop() rejects (ink can reject waitUntilExit() on an + // internal React error during unmount) — otherwise the run's scrollback summary is silently lost and a + // second finalize() is a no-op. The `finally` guarantees the write survives a stop() rejection. + try { + await stop(); + } finally { + // The live frames are ephemeral; write the persistent plain-text summary into the scrollback. + const write = + options.writeSummary ?? + ((text: string): void => { + stdout.write(text); + }); + write(store.summaryText()); + } }, }; } diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 4b4e7f64..1ef2e9dd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -163,17 +163,18 @@ Resolves a pending human gate from the terminal — the surface-agnostic resume ```bash relavium gate --approve relavium gate --reject --comment "Too risky" -relavium gate --input '{"api_key": "..."}' # for gate_type=input +relavium gate --input '{"region": "us-east-1"}' # for gate_type=input relavium gate --gate --approve # disambiguate when >1 gate is pending ``` - Exactly **one** of `--approve` / `--reject` / `--input` is required and they are mutually exclusive; `--comment ` annotates an approve/reject rationale and is invalid with `--input` (which carries the payload). A bad combination is an invalid invocation (exit `2`). - `--input ` is parsed as **JSON when it parses** (`'{"k":1}'` / `'42'` / `'true'` → a structured payload), else kept as the **raw string** (`--input some-token` → `"some-token"`); the result becomes the gate node's output. (The interactive prompt for a `gate_type=input` gate takes the typed value as a raw string.) +- **Do not pass secrets via `--input`.** The value reaches the durable event log (`human_gate:resumed.payload`) and the `--json` stream, and argv itself leaks into `ps` / shell history / CI logs — exactly the exposure `relavium provider set-key`'s stdin-only rule avoids. Use a non-secret gate input; supply secrets through the OS keychain / env (`RELAVIUM__API_KEY`), never a gate payload. - `--gate ` selects **which** pending gate to resolve. The resume contract is `engine.resume(runId, gateId, decision)` — `gateId` is mandatory on the resume path (it is carried on the `human_gate:paused` event; see [sse-event-schema.md](../contracts/sse-event-schema.md) and `resume_run` in [ipc-contract.md](../contracts/ipc-contract.md)). `--gate` is **optional on the CLI**: when exactly one gate is pending the CLI fills it in automatically; when **more than one** gate is pending it is **required**, and omitting it is an invalid invocation (exit `2`) listing the pending `gateId`s. - In **2.G**, read the pending `runId` + `gateId` from the run's own output: the `human_gate:paused` event line under `--json`, or the `paused at gate ()` line the plain/TUI renderer prints. (`relavium status` / `relavium logs ` will also print them once they land at **2.I** — see below.) - **Idempotent.** A doubled decision — the run already finished, or the named gate was already resolved — is a clean exit-`0` no-op, never a double-advance (it leans on the engine's checkpoint/gate-state idempotency). An unknown `runId` is exit `2`. Idempotency is **per gate**, though: on a *sequential* multi-gate workflow a blind repeat *without* `--gate` (after the first decision advanced the run and it re-paused at the **next** gate) auto-fills and resolves *that* gate — so an automated retry-until-exit-`0` loop should **pin `--gate `** to avoid resolving later gates unattended. -> **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate `relavium budget resume` surface ([deferred-tasks](../../roadmap/deferred-tasks.md)). The `relavium gate list` multi-gate listing lands with `relavium status`/`logs` (2.I). +> **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate `relavium budget resume` surface ([deferred-tasks](../../roadmap/deferred-tasks.md)). A run that declares a **`secret`-typed input** cannot be resumed cross-process: secrets are never persisted in plaintext (only a masked placeholder is, ADR-0006/0036), so `relavium gate` **fails closed (exit `2`)** rather than resume with a value it cannot restore — re-run the workflow instead (re-providing secret inputs on resume is a [tracked follow-up](../../roadmap/deferred-tasks.md)). The `relavium gate list` multi-gate listing lands with `relavium status`/`logs` (2.I). ### `relavium provider` diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 5079e963..d323f853 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -489,6 +489,14 @@ Severity is the review's verified rating. Check an item off in the PR that resol + resume path as a human gate, behind a budget-specific command + flags), so it is a small follow-up once 2.G lands — candidate home: alongside 2.I, or its own short workstream. **Deliberately out of 2.G** (a distinct ADR-0028 surface, not in 2.G's acceptance). *(low · apps/cli/src/commands/; ADR-0028)* +- [ ] **Re-provide `secret`-typed inputs on cross-process resume.** The durable `run:started.inputs` are + **masked** (a `secret` input is persisted as `{ secret: true, ref }`, never plaintext — ADR-0006/0036), so a + fresh-process `relavium gate` resume cannot restore the real value. 2.G **fails closed (exit 2)** when a + restored input is a `MaskedSecret` (`assertNoMaskedSecretInputs`, gate.ts) rather than resume with a broken + value. The proper fix lets the operator re-supply the secret on resume (e.g. `relavium gate --secret + token=…` read from stdin like `provider set-key`, or a keychain/env re-resolution keyed by the input + `ref`) so a secret-bearing run becomes resumable. Until then the fail-closed + the + [commands.md](../reference/cli/commands.md) note stand. *(medium · apps/cli/src/commands/gate.ts; ADR-0006)* ## Schema / validation hardening diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 8fc56ee7..de8ffb22 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -371,7 +371,11 @@ catalog and inspect past and active runs. desktop run-detail drawer replays). - `relavium status`: show currently active/paused runs and their per-node status from `runs` + `step_executions`. -- Make all three honor `--json` for scriptable output and the documented exit +- `relavium gate list []`: list the pending human gates (all active runs, or + one run) with their gate type + node id, so an operator can pick the `gateId` to + pass to `relavium gate --gate ` — the multi-gate discovery surface + the 2.G `gate` command's `--gate` requirement points at (canonical: [commands.md](../../reference/cli/commands.md)). +- Make these honor `--json` for scriptable output and the documented exit codes (`2` for an unknown `runId`). **Acceptance:** `list` shows discovered workflows with correct last-run status; @@ -470,7 +474,7 @@ The interactive agent entry point on the CLI ([ADR-0024](../../decisions/0024-ag - **2.N — `relavium chat-resume `.** Reload + continue a persisted session from `history.db`. - **2.O — `relavium chat-list`.** List session history (id, agent, title, last activity). - **2.P — `relavium chat-export `.** Export a session to a `.relavium.yaml` scaffold ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). -- **2.Q — `relavium chat --json` + `relavium agent run` + `relavium gate list`.** A deterministic `--json` `session:*` stream (CI-friendly); a one-shot `relavium agent run --input … [--json] [--fixture …]` on the same `AgentSession` infra; and `relavium gate list` / `gate ` to resolve one of possibly-several pending gates. +- **2.Q — `relavium chat --json` + `relavium agent run`.** A deterministic `--json` `session:*` stream (CI-friendly); and a one-shot `relavium agent run --input … [--json] [--fixture …]` on the same `AgentSession` infra. _(The `relavium gate list` multi-gate discovery command lands with the read commands in **2.I**, per [commands.md](../../reference/cli/commands.md).)_ **Acceptance:** an interactive `relavium chat` streams a multi-turn conversation with a tool call, persists, and resumes; `chat --json` emits a deterministic `session:*` stream; `agent run` invokes a single agent headlessly; chat `/exit` returns exit code 4. @@ -672,11 +676,11 @@ this table does not restate them, it only sequences what remains. - **Gate-closing backbone — `2.G → 2.I → 2.L`:** these three PRs flip the remaining exit criteria (2.K + 2.H + 2.C + 2.E are done). The remaining four (**2.S, 2.R, chat, 2.J**) complete in-phase but do **not** block starting Phase 3. -- **2.K fully closes at step 4 (2.G).** Its deferred gate-resume scenario can only be - exercised once the gate pause/resume surface exists, so 2.L (step 6) must follow 2.G even +- **2.K fully closes at step 1 (2.G).** Its deferred gate-resume scenario can only be + exercised once the gate pause/resume surface exists, so 2.L (step 3) must follow 2.G even though 2.L's nominal dependency is just "2.K". - **The one judgement call — 2.S timing.** Front-load it as the *first* additive lane - (step 7); never tail it behind chat / MCP / filler. It is the long pole, carries the only + (step 4); never tail it behind chat / MCP / filler. It is the long pole, carries the only dedicated security review (the `EgressCapability.fetch` SSRF mechanism), and its injectable ports are inherited by desktop ([§3.B](phase-3-desktop.md)) + VS Code ([§4.N](phase-4-vscode.md)). Pull it even earlier (right after 2.H) if de-risking that security review outweighs reaching From 643a6c23298ae122c30cef7c93c9bc59e6655fb9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 24 Jun 2026 13:03:56 +0300 Subject: [PATCH 6/6] fix(cli): reject a checkpointer over the in-memory store (PR #47 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (Major): createCliHost(undefined, { checkpointer }) wired a host whose read side (the checkpointer) and write side (the default in-memory store) point at different backends, so resumeFromCheckpoint would validate/persist against the wrong store. Reject that pairing at construction (`store instanceof InMemoryRunStore && checkpointer`) — the only valid combination is an explicit durable store + a checkpointer reconstructed from it. + a guard test; the CliHostOptions JSDoc now documents the invariant. Refs: ADR-0050 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/host.test.ts | 17 +++++++++++++++-- apps/cli/src/engine/host.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/engine/host.test.ts b/apps/cli/src/engine/host.test.ts index 2e9f18c7..5bf89a60 100644 --- a/apps/cli/src/engine/host.test.ts +++ b/apps/cli/src/engine/host.test.ts @@ -1,16 +1,29 @@ -import type { Checkpointer } from '@relavium/core'; +import type { Checkpointer, RunStore } from '@relavium/core'; import { describe, expect, it } from 'vitest'; import { createCliHost } from './host.js'; +/** A stand-in DURABLE store (not the in-memory reference) — what the gate path injects alongside a checkpointer. */ +const durableStore: RunStore = { + resolveWorkflowId: () => Promise.resolve('wf'), + persistEvent: () => Promise.resolve(), + listInterruptedRuns: () => Promise.resolve([]), +}; + describe('createCliHost', () => { it('uses an injected checkpointer (the 2.G cross-process gate-resume seam) over the default', async () => { const injected: Checkpointer = { load: () => Promise.resolve(undefined) }; - const host = createCliHost(undefined, { checkpointer: injected }); + const host = createCliHost(durableStore, { checkpointer: injected }); expect(host.checkpointer).toBe(injected); // resumeFromCheckpoint loads from the durable reconstruction expect(await host.checkpointer.load('any')).toBeUndefined(); }); + it('rejects a checkpointer over the in-memory store — that split-backend pairing would resume against the wrong store', () => { + const injected: Checkpointer = { load: () => Promise.resolve(undefined) }; + // Default store (in-memory) + a durable checkpointer = read/write backends diverge → fail loud at wiring. + expect(() => createCliHost(undefined, { checkpointer: injected })).toThrow(/durable RunStore/); + }); + it('defaults to the in-memory checkpointer when none is injected (the run path never resumes)', () => { const host = createCliHost(); expect(host.checkpointer).toBeDefined(); diff --git a/apps/cli/src/engine/host.ts b/apps/cli/src/engine/host.ts index 9bf9898f..6179efc9 100644 --- a/apps/cli/src/engine/host.ts +++ b/apps/cli/src/engine/host.ts @@ -15,6 +15,10 @@ export interface CliHostOptions { * cross-process gate-resume path (**2.G**), where it must reconstruct from the durable event log (the * `createHistoryCheckpointer` over the SQLite store). Omitted on the `run` path, which never resumes from a * checkpoint, so it defaults to the in-memory reconstruction (a no-op `undefined` over the durable store). + * + * MUST be paired with an explicit **durable** `store` reconstructed from the SAME backend — the engine reads + * the checkpoint from here but resolves/persists through `host.store`, so a checkpointer over the default + * in-memory store is a split-backend wiring bug that `createCliHost` rejects at construction. */ readonly checkpointer?: Checkpointer; } @@ -33,6 +37,15 @@ export function createCliHost( store: RunStore = new InMemoryRunStore(), options?: CliHostOptions, ): ExecutionHost { + // A durable checkpointer reads from one backend (the persisted event log) while the host's `store` does the + // resolveWorkflowId / persistEvent writes; if `store` is the in-memory reference (incl. the default), the two + // point at DIFFERENT backends and `resumeFromCheckpoint` would validate/persist against the wrong store. Fail + // loud at wiring time — the only valid pairing is a durable `store` + a checkpointer reconstructed from it. + if (options?.checkpointer !== undefined && store instanceof InMemoryRunStore) { + throw new Error( + 'createCliHost: a checkpointer requires an explicit durable RunStore (the checkpointer must reconstruct from the same store the run persists to)', + ); + } return { clock: { now: () => new Date().toISOString() }, ids: { newId: () => randomUUID() },