From e3aff46328b2764c61de8759e587feb781fed490 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 00:08:04 +0300 Subject: [PATCH 1/6] =?UTF-8?q?docs(roadmap):=20mark=202.K=20Done=20(PR=20?= =?UTF-8?q?#43)=20across=20the=20status=20surfaces=20=E2=80=94=20M3=20reac?= =?UTF-8?q?hed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #43 merged: the engine regression harness (2.K) is live in the required CI gate. With 2.F + 2.K both Done, global milestone M3 is reached. - phase-2-cli.md: status line + the §2.K header marked ✅ Done (PR #43, 2026-06-23); the "Remaining build order" status note now shows 2.K done / M3 reached / next: 2.H (its gate-resume + agent-replay halves stay deferred, landing with 2.G / later). - current.md: 2.K ✅ Done, M3 reached; "Next pickup: 2.H" pointing at the build-order queue. - CLAUDE.md + README.md: Phase-2 status sentence now records 2.K + M3 reached. Next pickup is 2.H (durable run history) — the highest-leverage feeder. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 9 +++++---- README.md | 5 +++-- docs/roadmap/current.md | 9 ++++++--- docs/roadmap/phases/phase-2-cli.md | 10 +++++----- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c686f9d..acf892e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,11 @@ non-agent node handlers) with live streaming, per-node-boundary checkpoint/resum multimodal media I/O (input, inline output, and the async generation job loop). All three adapters (Anthropic, OpenAI/DeepSeek, Gemini) and the agent-first `AgentSession` entry point (multi-turn sessions, persistence, export-to-workflow) are shipped. -**Phase 2 (CLI, milestone M3) is in progress** — the CLI skeleton (2.A) and config -resolution (2.B) have landed (PR #40), `relavium run` is wired to the engine -(2.D, the M3 keystone — PR #41), and the `--json` CI machine-output contract has -landed (2.F — PR #42, ADR-0049). For live status, per-PR history, +**Phase 2 (CLI) is underway and milestone M3 is reached** — the CLI skeleton (2.A) and config +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), and the engine regression harness (2.K — PR #43) +completes M3. The next pickup is 2.H (durable run history). 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 d9092e74..b65b0433 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ One engine, three modes behind the one `LLMProvider` seam: **Phase 1 — Engine and LLM is complete** (2026-06-21): the engine runs end-to-end on 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), and its -`--json` CI machine-output contract have landed. For live status and the full roadmap, see +underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its +`--json` CI machine-output contract, and the engine regression harness 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 670d7607..b7b2d055 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -41,9 +41,12 @@ process contract (**2.A**) and the two-level config-resolution loader (**2.B**), **2.D** (`relavium run` wired to `@relavium/core` — the M3 keystone and first real engine consumer), ✅ Done (PR #41, 2026-06-22), which also adds the `defaultProviders()` seam registry; and **2.F** (the `--json` CI machine-output contract — pure-NDJSON stdout, diagnostics → stderr), -✅ Done (PR #42, 2026-06-22) behind [ADR-0049](../decisions/0049-cli-machine-output-contract.md). -**Next on the spine:** **2.K** (the engine regression harness) to reach **M3**, with the **2.E** -(ink TUI) and **2.H** (durable run history) run-surface feeders open. The CLI also lands the inbound MCP client (2.R, +✅ Done (PR #42, 2026-06-22) behind [ADR-0049](../decisions/0049-cli-machine-output-contract.md); +and **2.K** (the engine regression harness, now the engine's CI regression gate), ✅ Done +(PR #43, 2026-06-23) — **reaching global milestone M3**. +**Next pickup:** **2.H** (durable run history — the highest-leverage feeder, unblocking +2.I / 2.G / 2.M / 2.S), with the **2.E** (ink TUI) feeder also open; 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 [sequencing plan](phases/phase-2-cli.md#sequencing--parallelization). diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index e52f5e4d..6272c5df 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). The global milestone in play is **M3** (reached at 2.F + 2.K); **2.K** (the engine regression harness) is 🔄 **in review (PR #43)** — M3 lands when it merges. The status-aware order for everything still open (next pickup: **2.H**) 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**. The status-aware order for everything still open (next pickup: **2.H**) 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) @@ -388,7 +388,7 @@ in and out of a project — the "workflow file is the invite" distribution path. `run`s; `import` rejects a malformed or slug-colliding file with exit `2`; `export` output contains no secret material and re-imports cleanly elsewhere. -### 2.K — Engine regression harness in CI +### 2.K — Engine regression harness in CI — ✅ **Done (PR #43)** Adopt the CLI as the engine's canonical integration-test harness so every engine change is exercised end-to-end. Completes M3 with `2.D` + `2.F`. @@ -642,8 +642,8 @@ 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-22):** ✅ **2.A · 2.B · 2.D · 2.F** done · 🔄 **2.K** first cut in review -> (PR #43; its gate-resume + agent-replay halves deferred). +> **Status (2026-06-23):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K** done — **M3 reached** · next pickup: **2.H**. +> (2.K's gate-resume + agent-replay halves remain deferred — they land with 2.G / later.) | Next | Lane | Why now | Blockers (all met on arrival) | |---|---|---|---| @@ -659,7 +659,7 @@ this table does not restate them, it only sequences what remains. | **10. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | - **Gate-closing backbone — `2.H → 2.C → 2.E → 2.G → 2.I → 2.L`:** these six PRs flip all - seven exit criteria (2.K is already merging). The remaining four (**2.S, 2.R, chat, 2.J**) + seven exit criteria (2.K is 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 d8705d5ac8e91334b8b5e317a72dcf9508e33434 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 06:50:30 +0300 Subject: [PATCH 2/6] =?UTF-8?q?docs(decisions):=20ADR-0050=20=E2=80=94=20C?= =?UTF-8?q?LI=20history.db=20at-rest=20posture=20(2.H)=20+=20reconcile=20c?= =?UTF-8?q?anonical=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase-2 CLI's ~/.relavium/history.db (better-sqlite3, ADR-0021) needed an at-rest posture: ADR-0005/0008's "encrypted SQLite" framing is the DESKTOP's SQLCipher path, and ADR-0021 chose better-sqlite3 (no SQLCipher) for the Node side but decided "only the driver," leaving encryption open. ADR-0050 (Accepted): the CLI's history.db is UNENCRYPTED at rest, guarded by 0600/0700 OS permissions applied via explicit chmod (umask-independent; Windows falls back to the %USERPROFILE% NTFS ACL). Safe because the file holds no credentials — keys stay in the keychain (ADR-0006) and the engine masks secrets at the bus (ADR-0036); so the content is run data, not secrets. App-layer AES via the keychain is recorded as a named future upgrade, not silent debt. Cross-surface coexistence with the desktop's SQLCipher at the same path (they cannot share one file) is a named Phase-3 follow-on; no live collision in Phase 2. Reconcile the corpus in lockstep so no canonical home contradicts the ADR: - ADR-0005 + ADR-0008: dated "Amended 2026-06-23" notes scoping "encrypted" to the desktop, pointing to ADR-0050; README index row added. - database-schema.md: per-surface encryption (intro, Mermaid, storage table, agent-session note, session-message at-rest line, the cross-host callout, and §"Encryption at rest"); new §"Secrets at the write boundary" recording the writer's pass-through-plus-assert obligation (the four unsafe columns). - config-spec.md + keychain-and-secrets.md: history.db at-rest note now per-surface. Refs: ADR-0050, phase-2-cli.md 2.H Co-Authored-By: Claude Opus 4.8 (1M context) --- ...005-sqlite-drizzle-local-postgres-cloud.md | 7 + .../0008-local-first-phase-1-cloud-phase-2.md | 7 + .../0050-cli-history-db-at-rest-posture.md | 127 ++++++++++++++++++ docs/decisions/README.md | 1 + docs/reference/contracts/config-spec.md | 2 +- docs/reference/desktop/database-schema.md | 40 ++++-- .../reference/desktop/keychain-and-secrets.md | 4 +- 7 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 docs/decisions/0050-cli-history-db-at-rest-posture.md diff --git a/docs/decisions/0005-sqlite-drizzle-local-postgres-cloud.md b/docs/decisions/0005-sqlite-drizzle-local-postgres-cloud.md index d7b28a3b..1fa1d6a5 100644 --- a/docs/decisions/0005-sqlite-drizzle-local-postgres-cloud.md +++ b/docs/decisions/0005-sqlite-drizzle-local-postgres-cloud.md @@ -9,6 +9,13 @@ > closed by [ADR-0021](0021-node-sqlite-driver-better-sqlite3.md): `@relavium/db` uses > `better-sqlite3` for its Node consumers (the migration runner, tests, and the Phase-2 > CLI). The decision here is unchanged. +> +> Amended 2026-06-23: the "encrypted with SQLCipher" at-rest framing below is the +> **desktop** (`tauri-plugin-sql`) mechanism. The **CLI/Node** surface uses `better-sqlite3`, +> which has no SQLCipher; [ADR-0050](0050-cli-history-db-at-rest-posture.md) settles its +> at-rest posture — `~/.relavium/history.db` is **unencrypted, guarded by `0600`/`0700` OS +> permissions** (the file carries no credentials: keys stay in the keychain and the bus masks +> secrets). The desktop SQLCipher path here is unchanged. ## Context diff --git a/docs/decisions/0008-local-first-phase-1-cloud-phase-2.md b/docs/decisions/0008-local-first-phase-1-cloud-phase-2.md index b81fc90b..3bd41c7f 100644 --- a/docs/decisions/0008-local-first-phase-1-cloud-phase-2.md +++ b/docs/decisions/0008-local-first-phase-1-cloud-phase-2.md @@ -4,6 +4,13 @@ - **Date**: 2026-06-03 - **Related**: [0003-pure-ts-engine-not-langgraph-python.md](0003-pure-ts-engine-not-langgraph-python.md), [0005-sqlite-drizzle-local-postgres-cloud.md](0005-sqlite-drizzle-local-postgres-cloud.md), [0006-os-keychain-for-api-keys.md](0006-os-keychain-for-api-keys.md), [0009-git-native-workflow-yaml.md](0009-git-native-workflow-yaml.md), [0012-managed-inference-dual-mode.md](0012-managed-inference-dual-mode.md) (amends framing: local-first Phase 1 reaffirmed), [product-constraints.md](../product-constraints.md), [tech-stack.md](../tech-stack.md) +> Amended 2026-06-23: "run history and cost data live in a local **encrypted** SQLite file" +> (below) is the **desktop** SQLCipher mechanism. For the **CLI/Node** surface, +> [ADR-0050](0050-cli-history-db-at-rest-posture.md) settles the at-rest posture: +> `~/.relavium/history.db` is **unencrypted, guarded by `0600`/`0700` OS permissions** (it +> carries no credentials — the keychain holds keys and the engine masks secrets at the bus). +> Local-first is unchanged. + ## Context Relavium has to decide *where workflows run* and *what the product depends on to function* — and it has to decide this before any storage, secrets, or engine-hosting choice is locked, because those choices all cascade from it. An execution model is the hardest thing to change later: build for the cloud first and you bake a server, an account system, and a network round-trip into the core; build for local first and you risk a second, painful re-architecture if cloud execution is ever needed. diff --git a/docs/decisions/0050-cli-history-db-at-rest-posture.md b/docs/decisions/0050-cli-history-db-at-rest-posture.md new file mode 100644 index 00000000..4c0be9db --- /dev/null +++ b/docs/decisions/0050-cli-history-db-at-rest-posture.md @@ -0,0 +1,127 @@ +# ADR-0050: CLI run-history `history.db` is unencrypted at rest, guarded by OS file permissions + +- **Status**: Accepted +- **Date**: 2026-06-23 +- **Related**: [0005-sqlite-drizzle-local-postgres-cloud.md](0005-sqlite-drizzle-local-postgres-cloud.md) (refines its at-rest framing for the Node/CLI surface), [0008-local-first-phase-1-cloud-phase-2.md](0008-local-first-phase-1-cloud-phase-2.md) (same), [0006-os-keychain-for-api-keys.md](0006-os-keychain-for-api-keys.md), [0021-node-sqlite-driver-better-sqlite3.md](0021-node-sqlite-driver-better-sqlite3.md), [0036-run-loop-substrate-event-bus-and-execution-host.md](0036-run-loop-substrate-event-bus-and-execution-host.md), [../reference/desktop/database-schema.md](../reference/desktop/database-schema.md), [../reference/desktop/keychain-and-secrets.md](../reference/desktop/keychain-and-secrets.md), [../reference/contracts/config-spec.md](../reference/contracts/config-spec.md), [../roadmap/phases/phase-2-cli.md](../roadmap/phases/phase-2-cli.md) (workstream 2.H) + +## Context + +Phase-2 workstream **2.H** wires durable CLI run history to `~/.relavium/history.db` +through `@relavium/db`, which on the Node/CLI side uses the `better-sqlite3` driver +([ADR-0021](0021-node-sqlite-driver-better-sqlite3.md)). This forces a decision the corpus +has not yet made for the CLI surface: **is that file encrypted at rest, and if so how?** + +The existing record points two ways and must be reconciled: + +- [ADR-0005](0005-sqlite-drizzle-local-postgres-cloud.md) set the local store as "SQLite + + Drizzle, **encrypted with SQLCipher**" — but its mechanism is the **desktop's** + `tauri-plugin-sql` Rust path, with the passphrase derived in the Tauri setup hook. + [ADR-0008](0008-local-first-phase-1-cloud-phase-2.md) restated this as "run history and + cost data live in a local **encrypted** SQLite file." +- [ADR-0021](0021-node-sqlite-driver-better-sqlite3.md) then chose `better-sqlite3` for the + Node-side consumers (tests now, the CLI later) and was explicit that it decided "**only + the underlying driver**," that it "does **not** touch the desktop's encrypted + `tauri-plugin-sql` path," and that the Phase-2 CLI run-history reader is a Node consumer. + `better-sqlite3` is the standard (non-SQLCipher) build: it provides **no** transparent + at-rest encryption. So the CLI cannot inherit the desktop's SQLCipher mechanism, and the + Node-side at-rest posture was left **open**. + +A decisive scoping fact bounds the stakes: **`history.db` holds no credentials.** API keys +live only in the OS keychain ([ADR-0006](0006-os-keychain-for-api-keys.md)) and are resolved +at LLM-call time, never persisted; and the engine **masks secret-typed inputs and tool I/O +at the `RunEventBus` before persistence** ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)), +so `run_events` / `step_executions` / `runs` carry `{ secret: true, ref }` placeholders, never +raw key material. The at-rest question is therefore about protecting run **content** — +prompts, model outputs, token/cost data — i.e. **defense in depth**, not the credential +boundary (which the keychain and bus-masking already own). + +Getting this wrong cuts both ways: silently shipping an unencrypted file would contradict the +"encrypted" framing of 0005/0008 with no record (the drift the decisions corpus exists to +prevent); over-engineering — pulling application-layer crypto and its key management forward — +would bloat 2.H, add an encrypt cost to every event write on the persistence hot path, and +couple this feeder to the not-yet-built keychain workstream (2.C), all for a local, +single-user CLI. + +## Decision + +**The CLI's `~/.relavium/history.db` is unencrypted at rest and guarded by restrictive OS +file permissions: `~/.relavium/` is set to `0700` and `history.db` (with its `-wal` / `-shm` +sidecars) to `0600` — owner-only — applied with an explicit `chmod` (umask-independent, and +applied even to a pre-existing directory, since `mkdir(mode)` is umask-masked and does not +re-permission an existing dir). We do not add application-layer encryption to the Phase-2 CLI.** +On **Windows**, POSIX mode bits do not apply (`chmod` is effectively a no-op); protection there +falls to the per-user `%USERPROFILE%` NTFS ACL — a known cross-platform divergence from `0600`, +not a uniform mechanism. This closes the open question [ADR-0021](0021-node-sqlite-driver-better-sqlite3.md) +left for the Node surface; it does **not** change the desktop. + +Considered options: + +1. **Unencrypted + `0700`/`0600` OS permissions.** *Chosen.* The proportionate at-rest + control for a local single-user CLI: the file carries no credentials (keychain + bus + masking), so owner-only filesystem permissions adequately protect the remaining run + **content**. It unblocks 2.H with no dependency on the 2.C keychain work and adds no + crypto to the per-event write path. +2. **Application-layer AES-256-GCM, key in the OS keychain (`@napi-rs/keyring`).** *Rejected + for now.* The strongest at-rest protection, but it (a) couples 2.H to the unbuilt + provider/keychain workstream (2.C), (b) adds an encrypt/decrypt step to **every** event + write — and the engine awaits `RunStore.persistEvent` *before* delivering the event, so it + is squarely on the run's hot path, and (c) makes `history.db` opaque to ad-hoc inspection + and `relavium logs` debugging. A future ADR may adopt it if the threat model warrants; + this records it as a **named** follow-on, not silent debt. +3. **Ship unencrypted with no ADR.** *Rejected.* Would leave the "encrypted" framing of + 0005/0008 contradicted with no record — exactly the silent drift the corpus forbids. + +**Scope.** This decides only the **CLI / Node surface**. The **desktop** keeps its +SQLCipher-encrypted `tauri-plugin-sql` path ([ADR-0005](0005-sqlite-drizzle-local-postgres-cloud.md), +[ADR-0001](0001-tauri-v2-over-electron.md), [ADR-0018](0018-desktop-execution-and-rust-egress.md)) +unchanged; the Phase-2 cloud history store (PostgreSQL) is out of scope. + +**Cross-surface coexistence — a named Phase-3 follow-on.** +[database-schema.md](../reference/desktop/database-schema.md) previously described a single +`~/.relavium/history.db` opened with SQLCipher by *every* host (desktop, CLI, VS Code), so a +session written on one surface opens on another. Making the CLI's copy **unencrypted** breaks +that: a standard `better-sqlite3` build cannot open a SQLCipher file (nor vice-versa), so the +desktop (SQLCipher) and the CLI (unencrypted) **cannot share one file at that path**. In Phase 2 +this is **not** a live conflict — the desktop does not exist yet (Phase 3) and the CLI is the sole +writer of `history.db`. **Reconciling the cross-surface shared-path posture is therefore a named +Phase-3 obligation**: when the desktop lands, its ADR decides among (i) a uniformly-unencrypted +shared store (OS permissions across surfaces), (ii) per-surface separate files (dropping +single-file cross-surface resume), or (iii) a CLI/Node SQLCipher-capable build. Until then there is +**no** cross-surface shared session/run store, and [database-schema.md](../reference/desktop/database-schema.md) +is updated to say so. (The per-project `runs.db` is unaffected — it is already unencrypted and +git-committed, holding only non-sensitive metadata.) + +**Companion obligation (no new decision).** The 2.H history writer is **pass-through** for +secrets: it never re-masks (the engine already masked at the bus) but **asserts at the write +boundary** that the unsafe columns — `run_events.payload_json`, the `step_executions` +input/output/error JSON, `run_costs`, and `runs.workflow_definition_snapshot` — carry no raw +secret, as defense in depth against an upstream regression. This pass-through-plus-assert +contract is recorded in [database-schema.md](../reference/desktop/database-schema.md); it is +the engine's existing guarantee ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), +[ADR-0006](0006-os-keychain-for-api-keys.md)), not a new decision. + +## Consequences + +### Positive + +- **Unblocks 2.H now** with no dependency on the 2.C keychain work and no per-write crypto on + the persistence hot path (`persistEvent` is awaited before delivery). +- `history.db` stays **inspectable** for debugging and `relavium logs` without a decrypt step. +- The **no-credentials-at-rest** guarantee is preserved independently of file encryption, by + the OS keychain ([ADR-0006](0006-os-keychain-for-api-keys.md)) and bus-masking + ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)). +- The 0005/0008 framing is **reconciled on the record** (dated amendment notes pointing here), + so there is no silent contradiction in the corpus. + +### Negative + +- Run **content** (prompts, model outputs, token/cost data) is unencrypted on disk: anyone + with read access past the OS permissions — a stolen unlocked disk, an unencrypted backup, + another local process running as the same user — can read it. Mitigations: owner-only + `0600`/`0700` permissions; the file holds no credentials; OS full-disk encryption remains + the user's first line; the posture is documented in + [keychain-and-secrets.md](../reference/desktop/keychain-and-secrets.md). Option 2 + (app-layer AES via the keychain) is the named upgrade path if a future threat model needs it. +- A deliberate **divergence from the desktop** (SQLCipher) and a partial divergence from the + original "encrypted local SQLite" intent of 0005/0008 — accepted knowingly, scoped to the + CLI surface, and recorded rather than silent. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 30382281..0e1ac6eb 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -93,6 +93,7 @@ flowchart TD | 0047 | [CLI framework — `commander` + `ink` + `@clack/prompts`, confined to `apps/cli`](0047-cli-framework-commander-ink-clack.md) | Accepted | 2026-06-22 | | 0048 | [TOML parser for config files — `smol-toml`, confined to the `apps/cli` config loader](0048-toml-config-parser.md) | Accepted | 2026-06-22 | | 0049 | [CLI machine-output contract (`--json` NDJSON stream + stderr diagnostics)](0049-cli-machine-output-contract.md) | Accepted | 2026-06-22 | +| 0050 | [CLI run-history `history.db` is unencrypted at rest, guarded by OS file permissions (refines 0005/0008 for the Node/CLI surface)](0050-cli-history-db-at-rest-posture.md) | Accepted | 2026-06-23 | ## Creating a new ADR diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 63a45c5f..891a5246 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -18,7 +18,7 @@ Created on first launch. Holds user-wide preferences and the registry of MCP ser ~/.relavium/ config.toml # global preferences, MCP server registrations, update channel ipc.json # desktop loopback server discovery (port, authToken, pid) — see ipc-contract.md - history.db # cross-project run history (SQLite, encrypted at rest) — see desktop/database-schema.md + history.db # cross-project run history (SQLite; desktop: SQLCipher, CLI: unencrypted + 0600/0700 OS perms — ADR-0050) — see desktop/database-schema.md secrets.enc # OPTIONAL encrypted-file key fallback (headless/CI) — see keychain-and-secrets.md tmp/ # scratch space agents may write to under the sandbox tier ``` diff --git a/docs/reference/desktop/database-schema.md b/docs/reference/desktop/database-schema.md index 761adaa4..7ce21a5e 100644 --- a/docs/reference/desktop/database-schema.md +++ b/docs/reference/desktop/database-schema.md @@ -7,14 +7,14 @@ - **Scope**: Phase 1, local-first. SQLite via `tauri-plugin-sql`, schema managed by Drizzle ORM in `packages/db` (see [project-structure.md](../../project-structure.md)). - **Related**: [keychain-and-secrets.md](keychain-and-secrets.md), [tauri-plugins.md](tauri-plugins.md), [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md), [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md), [../../architecture/managed-inference.md](../../architecture/managed-inference.md), [../../architecture/local-first-and-security.md](../../architecture/local-first-and-security.md) -This is the canonical reference for the **local** run-history and catalog database that the desktop app persists on the user's machine. There is no cloud, no account, and no server in Phase 1 — every table below lives in a single encrypted SQLite file. The Phase-2 PostgreSQL divergences are described at the end and detailed in [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md). +This is the canonical reference for the **local** run-history and catalog database that the desktop app (and the Phase-2 CLI) persists on the user's machine. There is no cloud, no account, and no server in Phase 1 — every table below lives in a single local SQLite file. **At-rest encryption is per-surface:** the desktop opens `history.db` with SQLCipher, while the Phase-2 **CLI** opens the same file with `better-sqlite3` **unencrypted, guarded by `0600`/`0700` OS permissions** ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) — see [Encryption at rest](#encryption-at-rest). The Phase-2 PostgreSQL divergences are described at the end and detailed in [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md). ## Storage layout ```mermaid flowchart TB subgraph Global["~/.relavium/ (global, cross-project)"] - H["history.db
(SQLCipher-encrypted)
full run + event + cost history"] + H["history.db
(desktop: SQLCipher · CLI: 0600/0700 OS perms)
full run + event + cost history"] end subgraph Project["{projectRoot}/.relavium/ (per-project, git-committed)"] R["runs.db
(run metadata only,
no event payloads)"] @@ -30,7 +30,7 @@ Two SQLite databases exist: | Database | Path | Encryption | Contents | Git | |----------|------|-----------|----------|-----| -| Global history | `~/.relavium/history.db` | SQLCipher (key from OS keychain) | Full runs, every event, every cost row, the catalog tables | Never committed | +| Global history | `~/.relavium/history.db` | Desktop: SQLCipher (key from OS keychain) · CLI: none — `0600`/`0700` OS perms ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) | Full runs, every event, every cost row, the catalog tables | Never committed | | Project history | `{projectRoot}/.relavium/runs.db` | None | Run **metadata only** (no event payloads) so teammates see historical run summaries after a `git pull` | Committed | The database is opened with `PRAGMA journal_mode = WAL` (readers never block the writer and vice-versa — but SQLite still allows **only one writer at a time**, so engine authors must funnel `run_events` and other hot-path writes through a single serialized writer, never concurrent writers) and `PRAGMA foreign_keys = ON` per connection (SQLite does **not** enforce foreign keys by default). Run events in `history.db` are pruned after 90 days by a background job that runs on app launch. @@ -319,7 +319,8 @@ CREATE INDEX idx_run_costs_run ON run_costs (run_id); These two tables persist **agent sessions** (the agent-first chat entry point — [ADR-0024](../../decisions/0024-agent-first-entry-point-agentsession.md), [agent-session-spec.md](../contracts/agent-session-spec.md)). They live in the **same -`~/.relavium/history.db`** (SQLCipher-encrypted) as run history — there is **no** separate +`~/.relavium/history.db`** (desktop: SQLCipher-encrypted; CLI: unencrypted + `0600`/`0700` OS perms, +[ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) as run history — there is **no** separate `sessions.db`. They are **bound to a session**, deliberately **distinct** from the per-step run [`messages`](#messages) table (which is bound to `step_executions` within a workflow run); the two share a shape family but must not be merged, because a session and a run have different lifecycles. @@ -400,16 +401,24 @@ CREATE INDEX idx_session_messages_session ON session_messages (session_id, cr > A `secret`-typed value is never persisted into `session_messages` — per > [ADR-0029](../../decisions/0029-tool-policy-hardening.md) secrets are rejected from prompt/tool text > at parse, so they never reach a message body. The user's own conversational content is stored here -> and is protected by `history.db`'s SQLCipher encryption at rest. +> and is protected at rest by `history.db`'s SQLCipher encryption on the desktop, and by `0600`/`0700` +> OS file permissions on the CLI ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)). -> **Cross-host access (CLI / VS Code) — how a session resumes across surfaces.** The same encrypted -> `history.db` is opened by the non-Tauri hosts: the **CLI** uses the `better-sqlite3` path +> **Cross-host access (CLI / VS Code) and the at-rest divergence.** The same `history.db` path is +> opened by the non-Tauri hosts: the **CLI** uses the `better-sqlite3` path > ([ADR-0021](../../decisions/0021-node-sqlite-driver-better-sqlite3.md)); the **VS Code extension host** > uses a **wasm SQLite** build (no native module — respects > [ADR-0003](../../decisions/0003-pure-ts-engine-not-langgraph-python.md)'s no-arbitrary-native-modules -> constraint). Both derive the SQLCipher key the same way as the desktop -> ([keychain-and-secrets.md](keychain-and-secrets.md)), so a session written on one surface opens on -> another — there is no per-surface session store. +> constraint). **At-rest encryption is per-surface and not yet unified:** the desktop opens the file with +> SQLCipher, but the **CLI opens it unencrypted** (no passphrase), guarded by `0600`/`0700` OS permissions +> ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)). A standard `better-sqlite3` build +> cannot open a SQLCipher file (nor vice-versa), so the desktop (SQLCipher) and the CLI (unencrypted) +> **cannot share one file at `~/.relavium/history.db`**. In Phase 2 there is **no live collision** — the +> desktop does not exist yet (Phase 3) and the CLI is the sole writer. **Cross-surface session/run resume +> across the desktop and CLI is therefore a named Phase-3 follow-on** +> ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)): the desktop phase reconciles the +> shared-path posture (a uniformly-unencrypted shared store, per-surface separate files, or a CLI/Node +> SQLCipher-capable build). Until then there is **no** cross-surface shared session/run store. ### Media tables (1.AF) @@ -471,7 +480,16 @@ CREATE INDEX idx_media_references_handle ON media_references (handle); ## Encryption at rest -`history.db` is opened with SQLCipher. The passphrase is derived from a stable machine secret (combined with the OS keychain entry) so the database opens on restart without prompting the user; see [keychain-and-secrets.md](keychain-and-secrets.md). The per-project `runs.db` is **not** encrypted because it is intentionally git-committed and contains only non-sensitive run metadata (no prompts, completions, or tokens). +At-rest encryption of `history.db` is **per-surface**: + +- **Desktop:** opened with SQLCipher. The passphrase is derived from a stable machine secret (combined with the OS keychain entry) so the database opens on restart without prompting the user; see [keychain-and-secrets.md](keychain-and-secrets.md). +- **CLI (Phase 2):** opened with `better-sqlite3` **unencrypted**, guarded by owner-only OS file permissions — `~/.relavium/` at `0700` and `history.db` (with its `-wal`/`-shm` sidecars) at `0600`, set with an explicit `chmod` (umask-independent, applied even to a pre-existing directory). On Windows, POSIX mode bits do not apply (`chmod` is a no-op); protection falls to the per-user `%USERPROFILE%` NTFS ACL. The file holds **no credentials** — keys stay in the OS keychain ([ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md)) and the engine masks secrets at the bus before persistence ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) — so the unencrypted-at-rest content is run data (prompts, outputs, costs), not secrets. Rationale and the cross-surface Phase-3 follow-on: [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md). + +The per-project `runs.db` is **not** encrypted on any surface because it is intentionally git-committed and contains only non-sensitive run metadata (no prompts, completions, or tokens). + +### Secrets at the write boundary + +The history writer is **pass-through** for secrets — it never re-masks (the engine already masked at the `RunEventBus`, [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) — but **asserts at the write boundary** that the unsafe columns carry no raw secret, as defense in depth against an upstream regression: `run_events.payload_json`, the `step_executions` `input_json` / `output_json` / `error_json`, `run_costs`, and `runs.workflow_definition_snapshot`. A raw API key, `Authorization` header, or `secret`-typed value must never appear in these columns; a `secret`-typed input persists only as its `{ secret: true, ref }` placeholder. ## Phase 2 (PostgreSQL) divergences diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index c07d2855..696883ce 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -54,7 +54,9 @@ There is **no** encrypted-file key store in v1.0. A headless/CI fallback for env ## Database passphrase (SQLCipher) -The global run-history database (`~/.relavium/history.db`) is encrypted with SQLCipher (see [database-schema.md](database-schema.md)). Its passphrase is derived from the same stable machine secret combined with a keychain entry, so the database opens automatically on restart without prompting the user. The passphrase is set in the Rust `setup()` hook **before** the SQL plugin initializes — if it is not present when the database is opened, the open fails. +The global run-history database (`~/.relavium/history.db`) is encrypted with SQLCipher **on the desktop** (see [database-schema.md](database-schema.md)). Its passphrase is derived from the same stable machine secret combined with a keychain entry, so the database opens automatically on restart without prompting the user. The passphrase is set in the Rust `setup()` hook **before** the SQL plugin initializes — if it is not present when the database is opened, the open fails. + +The **Phase-2 CLI does not use SQLCipher**: it opens the same `history.db` with `better-sqlite3` **unencrypted**, guarded by `0600`/`0700` OS file permissions ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)). This is safe because the file holds **no credentials** — provider keys stay here in the OS keychain, and the engine masks `secret`-typed inputs and tool I/O before they are persisted. The two at-rest postures cannot share one file at the same path; reconciling cross-surface coexistence is a named Phase-3 follow-on (ADR-0050). ## What never holds a secret From 1ab2516f3f87839b1f53e369dfe8de4bfec6f1eb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 07:18:06 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(db,cli):=202.H=20=E2=80=94=20durable?= =?UTF-8?q?=20run=20history=20via=20@relavium/db=20(RunStore=20+=20read=20?= =?UTF-8?q?API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tables already existed (schema.ts + migration 0000); 2.H is the missing SQLite writer + the CLI wiring that injects it as the engine's RunStore. No core change — the RunStore port + createCliHost(store) seam were already in place. @relavium/db: - run-history-store.ts: createRunHistoryStore(db, deps) — the three RunStore methods (resolveWorkflowId slug→UUID upsert per ADR-0022; persistEvent; listInterruptedRuns) plus the 2.I/2.G read API (listRuns / loadRun / loadRunEvents). persistEvent folds each durable event into run_events (full event, lossless) + derived runs/step_executions/ run_costs, all in one transaction (derived-first so run:started's runs row precedes its FK-referencing run_events row). Per-node cost = the delta of node:completed .cumulativeCostMicrocents (cost:updated is streamed, not persisted), so sum(run_costs) == runs.total_cost_microcents; token/cost totals fold incrementally so status shows live partials. A fault rejects the promise (ADR-0050 fatal posture). - Pass-through for secrets: persists the already bus-masked event verbatim; a secrets fixture asserts no raw secret reaches the unsafe columns (ADR-0036/0006/0050). - time.ts: extracted isoToEpochMs/epochMsToIso (shared with session-store). apps/cli: - history/open.ts: open ~/.relavium/history.db (better-sqlite3), migrate-on-first-use, chmod 0600 the db + WAL/SHM sidecars; ensureGlobalConfigDir now chmod 0700 the home dir (explicit, umask-independent; Windows → %USERPROFILE% ACL) — the ADR-0050 at-rest posture. - run.ts: inject the SQLite store via an optional openRunStore dep + close it in finally; specs.ts wires it in production. Tests/2.K harness omit it → the in-memory store, so they never touch the user's home. load.ts now exposes homeDir. Tests: 9 store unit tests (fold, seq/UNIQUE integrity, status transitions, interrupted runs, secrets) + 2 CLI e2e (real run → history.db at a temp home; 0600/0700 perms; a gate-paused run's events reconstruct a checkpoint via reconstructCheckpointState — the 2.G substrate). Full gate green; engine purity + seam intact; Leakwatch 0. Refs: ADR-0050, phase-2-cli.md 2.H Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/package.json | 1 + apps/cli/src/commands/run.ts | 85 +++-- apps/cli/src/commands/specs.ts | 4 +- apps/cli/src/config/load.ts | 4 +- apps/cli/src/config/paths.ts | 13 +- apps/cli/src/history/open.e2e.test.ts | 108 ++++++ apps/cli/src/history/open.ts | 59 ++++ packages/db/src/index.ts | 12 + packages/db/src/run-history-store.test.ts | 262 +++++++++++++++ packages/db/src/run-history-store.ts | 392 ++++++++++++++++++++++ packages/db/src/session-store.ts | 17 +- packages/db/src/time.ts | 17 + pnpm-lock.yaml | 3 + 13 files changed, 924 insertions(+), 53 deletions(-) create mode 100644 apps/cli/src/history/open.e2e.test.ts create mode 100644 apps/cli/src/history/open.ts create mode 100644 packages/db/src/run-history-store.test.ts create mode 100644 packages/db/src/run-history-store.ts create mode 100644 packages/db/src/time.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index acb6a1f8..17514e64 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@relavium/core": "workspace:*", + "@relavium/db": "workspace:*", "@relavium/llm": "workspace:*", "@relavium/shared": "workspace:*", "commander": "catalog:", diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 425385bd..65085db9 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -13,11 +13,13 @@ import { buildEngine as defaultBuildEngine, type BuildEngineOptions, } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; import { createProviderResolver, neededProviderIds, type ProviderResolver, } from '../engine/providers.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 { CliIo } from '../process/io.js'; @@ -38,6 +40,11 @@ export interface RunCommandDeps { readonly buildEngine?: (options?: BuildEngineOptions) => Promise; /** Injectable provider seam (key pre-flight + the engine's resolver). Defaults to the env resolver. */ readonly providers?: ProviderResolver; + /** + * Production wires the durable SQLite run-history store (2.H) here, per workflow; the unit tests and the + * 2.K harness omit it, keeping the in-memory `RunStore` so they never open `~/.relavium/history.db`. + */ + readonly openRunStore?: (workflow: WorkflowDefinition, homeDir: string) => OpenedHistory; } type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused'; @@ -55,8 +62,9 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // One resolver shared by the key pre-flight and the engine, reading the CLI's env seam (io.env). const providers = deps.providers ?? createProviderResolver(deps.io.env); - // Config (2.B) — a malformed layer surfaces as exit 2; the project dir powers id/slug discovery. - const { projectConfigDir } = loadResolvedConfig({ + // Config (2.B) — a malformed layer surfaces as exit 2; the project dir powers id/slug discovery, + // homeDir locates `~/.relavium/history.db` (2.H). + const { projectConfigDir, homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, }); @@ -85,41 +93,52 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr providers.keyFor(id); } - const engine = await build({ providers }); - const handle = engine.start({ workflow: def, inputs }); - - const renderer = deps.global.json ? createJsonRenderer(deps.io) : createPlainRenderer(deps.io); - let outcome: RunOutcome | undefined; - // Register the cancel handler immediately before the consume loop — no statement between it and the - // `try` whose `finally` removes it, so the listener can never leak on an intervening throw. - const onSigint = (): void => { - handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal) - }; - process.once('SIGINT', onSigint); + // Durable history (2.H): open `~/.relavium/history.db` and run THIS workflow on a host backed by the + // SQLite `RunStore`, so every node-boundary/terminal event is persisted before delivery (ADR-0036). Tests + // and the 2.K harness omit `openRunStore` → the in-memory default host, no DB touched. `close()` releases + // the connection at run end. A persist failure rejects out of the engine (ADR-0050 fatal posture). + const opened = deps.openRunStore?.(def, homeDir); try { - 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 (and need 2.H persistence); for 2.D 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 (deferred-tasks). - break; + const engine = await build( + opened === undefined ? { providers } : { providers, host: createCliHost(opened.store) }, + ); + const handle = engine.start({ workflow: def, inputs }); + + const renderer = deps.global.json ? createJsonRenderer(deps.io) : createPlainRenderer(deps.io); + let outcome: RunOutcome | undefined; + // Register the cancel handler immediately before the consume loop — no statement between it and the + // `try` whose `finally` removes it, so the listener can never leak on an intervening throw. + const onSigint = (): void => { + handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal) + }; + process.once('SIGINT', onSigint); + try { + 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 { + process.removeListener('SIGINT', onSigint); } - } finally { - process.removeListener('SIGINT', onSigint); - } - 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; + 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; + } + } finally { + opened?.close(); } } diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index 49336774..2e1312c0 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; +import { openHistoryStore } from '../history/open.js'; import { CliError } from '../process/errors.js'; import type { ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; @@ -109,7 +110,8 @@ function registerRun(program: Command, ctx?: CommandContext): void { run.action(async (workflow: string, opts: { input?: readonly string[] }) => { ctx.result.exitCode = await runCommand( { workflow, input: opts.input ?? [] }, - { io: ctx.io, global: ctx.global }, + // Production wires durable run history (2.H) — the real CLI persists to ~/.relavium/history.db. + { io: ctx.io, global: ctx.global, openRunStore: openHistoryStore }, ); }); } diff --git a/apps/cli/src/config/load.ts b/apps/cli/src/config/load.ts index 404e97c2..6b0a581a 100644 --- a/apps/cli/src/config/load.ts +++ b/apps/cli/src/config/load.ts @@ -76,6 +76,8 @@ export interface LoadedConfig { readonly config: ResolvedConfig; /** The discovered project `.relavium/` directory, or `undefined` when outside a project. */ readonly projectConfigDir: string | undefined; + /** The resolved home directory — the root of `~/.relavium/` (where `history.db` lives, 2.H/ADR-0050). */ + readonly homeDir: string; } /** @@ -102,7 +104,7 @@ export function loadResolvedConfig(options: LoadConfigOptions): LoadedConfig { ); } - return { config: resolveConfig({ global, workspace, project }), projectConfigDir }; + return { config: resolveConfig({ global, workspace, project }), projectConfigDir, homeDir: home }; } function errnoCode(err: unknown): string | undefined { diff --git a/apps/cli/src/config/paths.ts b/apps/cli/src/config/paths.ts index 64458032..101b777c 100644 --- a/apps/cli/src/config/paths.ts +++ b/apps/cli/src/config/paths.ts @@ -1,4 +1,4 @@ -import { mkdirSync, statSync } from 'node:fs'; +import { chmodSync, mkdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -13,10 +13,19 @@ export function globalConfigDir(home: string = homedir()): string { return join(home, '.relavium'); } -/** Lazily create `~/.relavium/` (and its `tmp/`) on first run. Idempotent. */ +/** + * Lazily create `~/.relavium/` (and its `tmp/`) on first run, owner-only (`0700`). Idempotent. + * + * The `0700` is the at-rest control for the unencrypted CLI `history.db` it will hold + * ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). The explicit + * `chmod` is deliberate: `mkdir`'s mode is umask-masked AND would not re-permission an already-existing + * directory, so a dir created earlier (e.g. by 2.B) is corrected here. On Windows POSIX mode bits do not + * apply (`chmod` is effectively a no-op) — protection there falls to the `%USERPROFILE%` NTFS ACL. + */ export function ensureGlobalConfigDir(home: string = homedir()): string { const dir = globalConfigDir(home); mkdirSync(join(dir, 'tmp'), { recursive: true }); + chmodSync(dir, 0o700); return dir; } diff --git a/apps/cli/src/history/open.e2e.test.ts b/apps/cli/src/history/open.e2e.test.ts new file mode 100644 index 00000000..caabdb78 --- /dev/null +++ b/apps/cli/src/history/open.e2e.test.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { reconstructCheckpointState } from '@relavium/core'; +import { createClient, createRunHistoryStore, type RunHistoryStore } from '@relavium/db'; +import type { RunEvent } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { runCommand } from '../commands/run.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo } from '../test-support.js'; +import { openHistoryStore } from './open.js'; + +/** + * 2.H end-to-end: a real `relavium run` (the production engine + SQLite `RunStore`) persists to a + * `history.db` under a TEMP home, so the test never touches the user's `~/.relavium/`. The injected + * `openRunStore` redirects the store's home to the temp dir (it ignores the runCommand-derived homeDir), + * which is the seam tests use to avoid the real home. Asserts the durable rows, the ADR-0050 `0600`/`0700` + * at-rest permissions, and that the persisted events reconstruct a checkpoint in a fresh connection + * (the cross-process resume substrate 2.G consumes). + */ + +const FIXTURES = fileURLToPath(new URL('../harness/fixtures/', import.meta.url)); + +function globalOptions(): GlobalOptions { + return { json: true, color: false, cwd: FIXTURES, configPath: undefined, verbosity: 'normal' }; +} + +/** Re-open the temp history.db read-only-ish for assertions (a fresh connection — the run's was closed). */ +function reopen(home: string): { store: RunHistoryStore; close: () => void } { + const client = createClient(join(home, '.relavium', 'history.db')); + const store = createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'reader', name: 'reader', definitionJson: '{}' }, + }); + return { store, close: () => client.sqlite.close() }; +} + +describe('2.H durable run history — real run → history.db (temp home)', () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'relavium-2h-')); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('persists a completed run (migrate-on-first-use) with owner-only perms', async () => { + const { io } = captureIo(); + const code = await runCommand( + { workflow: join(FIXTURES, 'sequential.relavium.yaml'), input: ['n=3'] }, + { io, global: globalOptions(), openRunStore: (wf) => openHistoryStore(wf, home) }, + ); + expect(code).toBe(EXIT_CODES.success); + + const dbPath = join(home, '.relavium', 'history.db'); + expect(existsSync(dbPath)).toBe(true); + if (process.platform !== 'win32') { + // ADR-0050: history.db is unencrypted, guarded by 0600 (file) / 0700 (dir) OS permissions. + expect(statSync(dbPath).mode & 0o777).toBe(0o600); + expect(statSync(join(home, '.relavium')).mode & 0o777).toBe(0o700); + } + + const { store, close } = reopen(home); + try { + const runs = store.listRuns(); + expect(runs).toHaveLength(1); + expect(runs[0]?.status).toBe('completed'); + const events = store.loadRunEvents(runs[0]?.id ?? ''); + expect(events[0]?.type).toBe('run:started'); + expect(events.at(-1)?.type).toBe('run:completed'); + expect(events.map((e) => e.sequenceNumber)).toEqual(events.map((_, i) => i)); // gap-free + } finally { + close(); + } + }); + + it('persists a gate-paused run sufficient to reconstruct a checkpoint in a fresh connection (2.G substrate)', async () => { + const { io } = captureIo(); + const code = await runCommand( + { workflow: join(FIXTURES, 'human-gate.relavium.yaml'), input: [] }, + { io, global: globalOptions(), openRunStore: (wf) => openHistoryStore(wf, home) }, + ); + expect(code).toBe(EXIT_CODES.gatePaused); + + const { store, close } = reopen(home); + try { + const run = store.listRuns()[0]; + expect(run?.status).toBe('paused'); + const interrupted = await store.listInterruptedRuns(); + expect(interrupted.find((r) => r.runId === run?.id)?.resumable).toBe(true); + + const events: RunEvent[] = store.loadRunEvents(run?.id ?? ''); + expect(events.some((e) => e.type === 'human_gate:paused')).toBe(true); + // The durable log is sufficient for a fresh process to rebuild run state (the 2.G resume path). + expect(() => reconstructCheckpointState(events)).not.toThrow(); + expect(reconstructCheckpointState(events)).toBeDefined(); + } finally { + close(); + } + }); +}); diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts new file mode 100644 index 00000000..fbe4ede4 --- /dev/null +++ b/apps/cli/src/history/open.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'node:crypto'; +import { chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { WorkflowDefinition } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + runMigrations, + type RunHistoryStore, +} from '@relavium/db'; + +import { ensureGlobalConfigDir, globalConfigDir } from '../config/paths.js'; + +/** An opened history store plus the handle to close its SQLite connection at run end. */ +export interface OpenedHistory { + readonly store: RunHistoryStore; + readonly close: () => void; +} + +/** + * Open `~/.relavium/history.db` for one CLI run (workstream **2.H**): lazy-create + `0700` the home dir, + * open it via `better-sqlite3`, apply migrations, then `0600` the db + its `-wal`/`-shm` sidecars — the + * unencrypted-at-rest CLI posture guarded by OS permissions + * ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). + * + * The store records THIS workflow: its frozen snapshot feeds `runs.workflow_definition_snapshot` (the + * events the engine emits don't carry the graph). Production (`commands/specs.ts`) wires this; the unit + * tests and the 2.K harness omit it and keep the in-memory store, so they never touch the user's home. + */ +export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): OpenedHistory { + ensureGlobalConfigDir(homeDir); // creates ~/.relavium/ at 0700 (ADR-0050) + const path = join(globalConfigDir(homeDir), 'history.db'); + const client = createClient(path); + runMigrations(client.db); + // Owner-only on the db + its WAL/SHM sidecars (a sidecar may not exist yet — tolerate its absence). + for (const suffix of ['', '-wal', '-shm']) { + try { + chmodSync(`${path}${suffix}`, 0o600); + } catch { + // No WAL checkpoint yet → the sidecar is absent; nothing to permission. + } + } + const store = createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.name ?? workflow.workflow.id, // `name` is optional in the schema; fall back to the slug + definitionJson: JSON.stringify(workflow), + }, + }); + return { + store, + close: () => { + client.sqlite.close(); + }, + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index faf2aef4..85137f9c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -68,6 +68,18 @@ export { type SessionMessageMeta, } from './session-store.js'; +// Run history (2.H) — the SQLite-backed RunStore the CLI host injects (durable persist-before-deliver, +// ADR-0036) plus the list/logs/status read API (2.I) and the cross-process resume substrate (2.G). The +// platform-free engine never imports this — a host wires it over history.db (unencrypted on the CLI, ADR-0050). +export { + createRunHistoryStore, + type RunHistoryStore, + type RunHistoryStoreDeps, + type RunHistoryWorkflow, + type InterruptedRunInfo, + type RunRecord, +} from './run-history-store.js'; + // Media store (1.AF, ADR-0042) — the host-side content-addressed blob store the engine references by // handle. Node-side (node:crypto + node:fs); a host wires one into ExecutionHost.mediaStore. The pure // engine never imports this — it depends only on the @relavium/shared `MediaStore` interface. diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts new file mode 100644 index 00000000..647a686e --- /dev/null +++ b/packages/db/src/run-history-store.test.ts @@ -0,0 +1,262 @@ +import type { RunEvent } from '@relavium/shared'; +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createClient, runMigrations, type DbClient } from './client.js'; +import { runCosts, runEvents, runs, stepExecutions } from './schema.js'; +import { + createRunHistoryStore, + type RunHistoryStore, + type RunHistoryWorkflow, +} from './run-history-store.js'; + +const TS = '2026-06-23T10:00:00.000Z'; +const TS_MS = new Date(TS).getTime(); + +/** Deterministic RFC-4122-shaped ids — the store's row-PK + workflow-id source (no wall-clock/random). */ +function counterUuid(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +} + +const WORKFLOW: RunHistoryWorkflow = { + slug: 'demo', + name: 'Demo', + definitionJson: JSON.stringify({ workflow: { id: 'demo', name: 'Demo', nodes: [], edges: [] } }), +}; + +/** Build a valid RunEvent — `RunEventSchema.parse` (inside persistEvent) is the real validation. */ +function ev( + type: T, + seq: number, + rest: Record, +): RunEvent { + return { type, runId: 'run-1', timestamp: TS, sequenceNumber: seq, ...rest } as RunEvent; +} + +describe('createRunHistoryStore', () => { + let client: DbClient; + let store: RunHistoryStore; + let next: number; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + next = 0; + store = createRunHistoryStore(client.db, { + uuid: () => counterUuid(++next), + now: () => TS_MS, + workflow: WORKFLOW, + }); + }); + + afterEach(() => { + client.sqlite.close(); + }); + + /** Resolve the workflow + persist a `run:started` so a `runs` row (FK target) exists. Returns the workflow UUID. */ + async function startRun(): Promise { + const workflowId = await store.resolveWorkflowId('demo'); + await store.persistEvent( + ev('run:started', 0, { workflowId, inputs: { n: 3 }, executionMode: 'local' }), + ); + return workflowId; + } + + it('resolveWorkflowId upserts by slug and is idempotent', async () => { + const a = await store.resolveWorkflowId('demo'); + const b = await store.resolveWorkflowId('demo'); + expect(a).toBe(b); + expect(client.db.select().from(runs).all()).toHaveLength(0); // no run row yet + }); + + it('run:started creates a runs row with the frozen snapshot, manual trigger, running status', async () => { + const workflowId = await startRun(); + const row = client.db.select().from(runs).where(eq(runs.id, 'run-1')).get(); + expect(row).toBeDefined(); + expect(row?.workflowId).toBe(workflowId); + expect(row?.status).toBe('running'); + expect(row?.triggerType).toBe('manual'); + expect(row?.workflowDefinitionSnapshot).toBe(WORKFLOW.definitionJson); + expect(row?.startedAt).toBe(TS_MS); + }); + + it('folds a node lifecycle into a step_executions row + a run_costs delta', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'double', nodeType: 'transform' })); + await store.persistEvent( + ev('node:completed', 2, { + nodeId: 'double', + output: { doubled: 6 }, + tokensUsed: { input: 10, output: 20 }, + durationMs: 5, + cumulativeCostMicrocents: 700, + }), + ); + + const step = client.db.select().from(stepExecutions).get(); + expect(step?.nodeId).toBe('double'); + expect(step?.status).toBe('completed'); + expect(step?.inputTokens).toBe(10); + expect(step?.outputTokens).toBe(20); + expect(step?.durationMs).toBe(5); + + const cost = client.db.select().from(runCosts).get(); + expect(cost?.nodeId).toBe('double'); + expect(cost?.costMicrocents).toBe(700); // delta from 0 + + const run = client.db.select().from(runs).where(eq(runs.id, 'run-1')).get(); + expect(run?.totalInputTokens).toBe(10); + expect(run?.totalOutputTokens).toBe(20); + expect(run?.totalCostMicrocents).toBe(700); + }); + + it('run_costs deltas sum to the run total across multiple nodes', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'a', nodeType: 'agent' })); + await store.persistEvent( + ev('node:completed', 2, { + nodeId: 'a', + output: {}, + tokensUsed: { input: 1, output: 1 }, + durationMs: 1, + cumulativeCostMicrocents: 300, + }), + ); + await store.persistEvent(ev('node:started', 3, { nodeId: 'b', nodeType: 'agent' })); + await store.persistEvent( + ev('node:completed', 4, { + nodeId: 'b', + output: {}, + tokensUsed: { input: 1, output: 1 }, + durationMs: 1, + cumulativeCostMicrocents: 1000, + }), + ); + await store.persistEvent( + ev('run:completed', 5, { + outputs: { ok: true }, + totalTokensUsed: { input: 2, output: 2 }, + totalCostMicrocents: 1000, + durationMs: 10, + }), + ); + + const costs = client.db.select().from(runCosts).all(); + expect(costs.map((c) => c.costMicrocents)).toEqual([300, 700]); // deltas + const total = costs.reduce((s, c) => s + c.costMicrocents, 0); + const run = client.db.select().from(runs).where(eq(runs.id, 'run-1')).get(); + expect(total).toBe(run?.totalCostMicrocents); // acceptance: sum(run_costs) == runs.total_cost_microcents + expect(run?.status).toBe('completed'); + }); + + it('persists a gap-free seq stream and rejects a duplicate (run_id, seq)', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'x', nodeType: 'input' })); + const seqs = client.db + .select({ s: runEvents.seq }) + .from(runEvents) + .all() + .map((r) => r.s); + expect(seqs).toEqual([0, 1]); + await expect( + store.persistEvent(ev('node:started', 1, { nodeId: 'y', nodeType: 'input' })), + ).rejects.toThrow(); // UNIQUE(run_id, seq) + }); + + it('loadRunEvents round-trips the full event log in seq order', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'x', nodeType: 'output' })); + const events = store.loadRunEvents('run-1'); + expect(events.map((e) => [e.type, e.sequenceNumber])).toEqual([ + ['run:started', 0], + ['node:started', 1], + ]); + }); + + it('maps terminal events to runs.status (completed / failed / cancelled)', async () => { + for (const [runId, terminal] of [ + [ + 'run-c', + ev('run:completed', 1, { + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }), + ], + [ + 'run-f', + ev('run:failed', 1, { + error: { code: 'internal', message: 'boom', retryable: false }, + partialOutputs: {}, + }), + ], + ['run-x', ev('run:cancelled', 1, {})], + ] as const) { + const wf = await store.resolveWorkflowId(`wf-${runId}`); + await store.persistEvent({ + ...ev('run:started', 0, { workflowId: wf, inputs: {}, executionMode: 'local' }), + runId, + }); + await store.persistEvent({ ...terminal, runId }); + } + const status = (id: string): string | undefined => + client.db.select({ s: runs.status }).from(runs).where(eq(runs.id, id)).get()?.s; + expect(status('run-c')).toBe('completed'); + expect(status('run-f')).toBe('failed'); + expect(status('run-x')).toBe('cancelled'); + }); + + it('listInterruptedRuns reports a gate-paused run resumable and a mid-run one not', async () => { + // Paused (resumable) run. + const wfP = await store.resolveWorkflowId('paused'); + await store.persistEvent({ + ...ev('run:started', 0, { workflowId: wfP, inputs: {}, executionMode: 'local' }), + runId: 'run-p', + }); + await store.persistEvent({ + ...ev('human_gate:paused', 1, { + nodeId: 'gate', + gateId: 'g1', + gateType: 'approval', + message: 'ok?', + }), + runId: 'run-p', + }); + // Mid-run (no terminal) run. + const wfM = await store.resolveWorkflowId('mid'); + await store.persistEvent({ + ...ev('run:started', 0, { workflowId: wfM, inputs: {}, executionMode: 'local' }), + runId: 'run-m', + }); + + const interrupted = await store.listInterruptedRuns(); + const byId = new Map(interrupted.map((r) => [r.runId, r])); + expect(byId.get('run-p')?.resumable).toBe(true); + expect(byId.get('run-p')?.lastSequenceNumber).toBe(1); + expect(byId.get('run-m')?.resumable).toBe(false); + }); + + // Security fixture — the engine masks a secret-typed input at the bus as { secret: true, ref }; the store + // is pass-through. Assert the masked placeholder is what lands, and the raw secret value never appears in + // any unsafe column (run_events.payload_json, runs.input_json). Defense in depth — ADR-0050. + it('never persists a raw secret — the masked placeholder is stored verbatim', async () => { + const RAW = ['sk', 'live', 'DEADBEEF'].join('-'); // a fake key, built so no contiguous literal exists + const workflowId = await store.resolveWorkflowId('secret-wf'); + await store.persistEvent({ + ...ev('run:started', 0, { + workflowId, + inputs: { api_key: { secret: true, ref: 'keychain://relavium/anthropic' } }, + executionMode: 'local', + }), + runId: 'run-s', + }); + + const runRow = client.db.select().from(runs).where(eq(runs.id, 'run-s')).get(); + const eventRow = client.db.select().from(runEvents).where(eq(runEvents.runId, 'run-s')).get(); + expect(runRow?.inputJson).toContain('"secret":true'); + expect(runRow?.inputJson).not.toContain(RAW); + expect(eventRow?.payloadJson).toContain('"secret":true'); + expect(eventRow?.payloadJson).not.toContain(RAW); + }); +}); diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts new file mode 100644 index 00000000..26642e81 --- /dev/null +++ b/packages/db/src/run-history-store.ts @@ -0,0 +1,392 @@ +import { RunEventSchema, type RunEvent, type RunStatus } from '@relavium/shared'; +import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; + +import type { Db } from './client.js'; +import { + runCosts, + runEvents, + runs, + stepExecutions, + workflows, + type NewRunCostRow, + type NewRunEventRow, + type NewRunRow, + type NewStepExecutionRow, + type RunRow, +} from './schema.js'; +import { epochMsToIso, isoToEpochMs } from './time.js'; + +/** + * Durable CLI run history (workstream **2.H**) — the SQLite-backed `RunStore` the CLI host injects in + * place of the in-memory reference, plus the read API `relavium list`/`logs`/`status` (2.I) and the + * cross-process resume substrate (2.G) consume. It writes the four run-history tables + * (`runs` / `step_executions` / `run_events` / `run_costs`) the engine's emit-time `persistEvent` + * chokepoint feeds (ADR-0036 persist-before-deliver), mirroring `session-store.ts`: the mappers are the + * single domain↔row + validation boundary, ids/timestamps are caller/event-supplied, and timestamps cross + * the ISO↔epoch-ms edge in `time.ts`. + * + * **Scope of what reaches `persistEvent`.** The bus persists only the *durable* events; the streamed + * `agent:*` / `cost:updated` events go through `#bus.emit` and never reach here (run-event-contract; + * engine.ts). So the durable per-node cost source is `node:completed.cumulativeCostMicrocents` (a run-wide + * running-total **snapshot**), not `cost:updated` — a `run_costs` row stores the *delta* of that snapshot, + * so `sum(run_costs.cost_microcents) == runs.total_cost_microcents`. `run:started.workflowId` is a UUID, + * never the slug (ADR-0022); the slug→UUID upsert is `resolveWorkflowId`. + * + * **Secrets.** The engine masks `secret`-typed inputs / tool I/O at the bus before this store sees an event + * (ADR-0036, ADR-0006); the writer is **pass-through** — it persists the already-masked event verbatim and + * never re-masks. The no-raw-secret invariant on the unsafe columns (`run_events.payload_json`, the + * `step_executions` JSON, `run_costs`, `runs.workflow_definition_snapshot`) is the engine's guarantee, + * verified end-to-end by the secrets fixture in this package's tests (database-schema.md §"Secrets at the + * write boundary", [ADR-0050](../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). + * + * At-rest encryption posture for `history.db` is per-surface: the CLI's file is unencrypted, guarded by + * `0600`/`0700` OS permissions ([ADR-0050](../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). + * That is the host's open-path concern (`apps/cli/src/history`), not this store's. + */ + +/** A run with a `run:started` but no terminal event — for startup crash reconciliation (core `InterruptedRun`). */ +export interface InterruptedRunInfo { + readonly runId: string; + readonly workflowId: string; + /** `true` when the run was suspended at a gate (resumable); `false` when it died mid-execution. */ + readonly resumable: boolean; + /** The highest `sequenceNumber` already persisted for this run. */ + readonly lastSequenceNumber: number; +} + +/** A run summary for `relavium list` / `status` (2.I) — row-derived, with ISO timestamps. */ +export interface RunRecord { + readonly id: string; + readonly workflowId: string; + readonly status: RunStatus; + readonly executionMode: string; + readonly triggerType: string; + readonly startedAt?: string; + readonly completedAt?: string; + readonly totalInputTokens: number; + readonly totalOutputTokens: number; + readonly totalCostMicrocents: number; + readonly createdAt: string; + readonly updatedAt: string; +} + +/** The catalog identity + frozen graph of the workflow a store instance records (events don't carry the graph). */ +export interface RunHistoryWorkflow { + readonly slug: string; + readonly name: string; + /** `JSON.stringify(WorkflowDefinition)` — the frozen `runs.workflow_definition_snapshot` for replay/resume. */ + readonly definitionJson: string; +} + +export interface RunHistoryStoreDeps { + /** Row-PK source (the engine supplies run/node ids inside events, but DB row ids are the store's). */ + readonly uuid: () => string; + /** epoch-ms clock — used only for the `workflows` catalog row, which `resolveWorkflowId` mints before any event. */ + readonly now: () => number; + /** The single workflow this store records (one per `relavium run`); supplies the durable snapshot. */ + readonly workflow: RunHistoryWorkflow; +} + +/** + * The durable run-history store. The first three methods are the engine's `RunStore` port (async to match + * the seam, synchronous under `better-sqlite3`); the rest are the 2.I/2.G read API. + */ +export interface RunHistoryStore { + resolveWorkflowId: (slug: string) => Promise; + persistEvent: (event: RunEvent) => Promise; + listInterruptedRuns: () => Promise; + /** All runs (newest first), excluding soft-deleted — for `relavium list`. */ + listRuns: () => RunRecord[]; + /** One run by id, or `undefined` — for `relavium status`. */ + loadRun: (runId: string) => RunRecord | undefined; + /** A run's full event log in `seq` order — for `relavium logs` and the 2.G resume reconstruct. */ + loadRunEvents: (runId: string) => RunEvent[]; +} + +const NON_TERMINAL_STATUSES = ['pending', 'running', 'paused'] as const; + +function fromRunRow(row: RunRow): RunRecord { + return { + id: row.id, + workflowId: row.workflowId, + status: row.status, + executionMode: row.executionMode, + triggerType: row.triggerType, + ...(row.startedAt === null ? {} : { startedAt: epochMsToIso(row.startedAt) }), + ...(row.completedAt === null ? {} : { completedAt: epochMsToIso(row.completedAt) }), + totalInputTokens: row.totalInputTokens, + totalOutputTokens: row.totalOutputTokens, + totalCostMicrocents: row.totalCostMicrocents, + createdAt: epochMsToIso(row.createdAt), + updatedAt: epochMsToIso(row.updatedAt), + }; +} + +/** Wire a {@link RunHistoryStore} over a `@relavium/db` connection. */ +export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHistoryStore { + /** The run-wide cumulative cost already persisted on `runs` — the baseline a node-cost delta subtracts from. */ + const currentRunCost = (runId: string): number => + db.select({ c: runs.totalCostMicrocents }).from(runs).where(eq(runs.id, runId)).get()?.c ?? 0; + + /** Apply an event's derived `runs`/`step_executions`/`run_costs` writes (`run:started` inserts the runs row). */ + const applyDerived = (event: RunEvent, runId: string, ts: number): void => { + switch (event.type) { + case 'run:started': { + const row: NewRunRow = { + id: runId, + workflowId: event.workflowId, + workflowDefinitionSnapshot: deps.workflow.definitionJson, + status: 'running', + executionMode: event.executionMode, + triggerType: 'manual', + inputJson: JSON.stringify(event.inputs), + startedAt: ts, + createdAt: ts, + updatedAt: ts, + }; + db.insert(runs).values(row).run(); + return; + } + case 'node:started': { + const row: NewStepExecutionRow = { + id: deps.uuid(), + runId, + nodeId: event.nodeId, + nodeType: event.nodeType, + attemptNumber: event.attemptNumber ?? 1, + status: 'running', + startedAt: ts, + createdAt: ts, + updatedAt: ts, + }; + db.insert(stepExecutions).values(row).run(); + return; + } + case 'node:completed': { + const prev = currentRunCost(runId); + const cumulative = event.cumulativeCostMicrocents ?? prev; + const nodeCost = Math.max(0, cumulative - prev); + db.insert(runCosts) + .values({ + id: deps.uuid(), + runId, + nodeId: event.nodeId, + inputTokens: event.tokensUsed.input, + outputTokens: event.tokensUsed.output, + costMicrocents: nodeCost, + createdAt: ts, + } satisfies NewRunCostRow) + .run(); + db.update(stepExecutions) + .set({ + status: 'completed', + outputJson: JSON.stringify(event.output), + inputTokens: event.tokensUsed.input, + outputTokens: event.tokensUsed.output, + costMicrocents: nodeCost, + durationMs: event.durationMs, + completedAt: ts, + updatedAt: ts, + }) + .where(stepMatch(runId, event.nodeId, event.attemptNumber)) + .run(); + db.update(runs) + .set({ + totalInputTokens: sql`${runs.totalInputTokens} + ${event.tokensUsed.input}`, + totalOutputTokens: sql`${runs.totalOutputTokens} + ${event.tokensUsed.output}`, + totalCostMicrocents: cumulative, + updatedAt: ts, + }) + .where(eq(runs.id, runId)) + .run(); + return; + } + case 'node:failed': { + db.update(stepExecutions) + .set({ + status: 'failed', + errorJson: JSON.stringify(event.error), + completedAt: ts, + updatedAt: ts, + }) + .where(stepMatch(runId, event.nodeId, event.attemptNumber)) + .run(); + return; + } + case 'human_gate:paused': + case 'budget:paused': + case 'run:paused': { + db.update(runs).set({ status: 'paused', updatedAt: ts }).where(eq(runs.id, runId)).run(); + return; + } + case 'human_gate:resumed': { + db.update(runs).set({ status: 'running', updatedAt: ts }).where(eq(runs.id, runId)).run(); + return; + } + case 'run:completed': { + db.update(runs) + .set({ + status: 'completed', + outputJson: JSON.stringify(event.outputs), + totalInputTokens: event.totalTokensUsed.input, + totalOutputTokens: event.totalTokensUsed.output, + totalCostMicrocents: event.totalCostMicrocents, + completedAt: ts, + updatedAt: ts, + }) + .where(eq(runs.id, runId)) + .run(); + return; + } + case 'run:failed': { + db.update(runs) + .set({ + status: 'failed', + errorJson: JSON.stringify(event.error), + outputJson: JSON.stringify(event.partialOutputs), + completedAt: ts, + updatedAt: ts, + }) + .where(eq(runs.id, runId)) + .run(); + return; + } + case 'run:cancelled': { + db.update(runs) + .set({ status: 'cancelled', completedAt: ts, updatedAt: ts }) + .where(eq(runs.id, runId)) + .run(); + return; + } + default: + // node:skipped / node:retrying / media_job:submitted / run:timeout (+ any future durable event): + // captured in run_events below; no derived runs/step/cost write in 2.H's scope. (A skipped node has + // no nodeType on its event, so it gets no step_executions row — the run_events log records the skip.) + return; + } + }; + + /** + * Persist one event: derived writes FIRST, then the `run_events` append — so `run:started`'s `runs` row + * (the FK target of `run_events.run_id`) exists before its event row. The whole pair is one transaction + * (the caller wraps it), so a crash never leaves a derived row without its event, or vice-versa. + */ + const fold = (event: RunEvent, runId: string, ts: number): void => { + applyDerived(event, runId, ts); + const eventRow: NewRunEventRow = { + id: deps.uuid(), + runId, + seq: event.sequenceNumber, + eventType: event.type, + nodeId: 'nodeId' in event ? event.nodeId : null, + // The full canonical RunEvent (lossless); the seq/eventType/nodeId/ts columns are denormalized projections. + payloadJson: JSON.stringify(event), + ts, + }; + db.insert(runEvents).values(eventRow).run(); + }; + + return { + resolveWorkflowId: (slug) => { + const existing = db + .select({ id: workflows.id }) + .from(workflows) + .where(and(eq(workflows.slug, slug), isNull(workflows.deletedAt))) + .get(); + if (existing !== undefined) { + return Promise.resolve(existing.id); + } + const id = deps.uuid(); + const t = deps.now(); + db.insert(workflows) + .values({ + id, + name: deps.workflow.name, + slug, + definition: deps.workflow.definitionJson, + createdAt: t, + updatedAt: t, + }) + .run(); + return Promise.resolve(id); + }, + + persistEvent: (event) => { + // Synchronous (better-sqlite3) but Promise-returning to honor the async RunStore port — a fault (bad + // event, UNIQUE(run_id, seq), FK, disk) becomes a REJECTED promise, never a synchronous throw, so the + // engine's `await persistEvent(...)` (durability-first: ADR-0050 fatal posture) and any `.catch` see it. + try { + const parsed = RunEventSchema.parse(event); // validate on the way in (round-trip + envelope) + const runId = parsed.runId; + if (runId === undefined) { + // The bus never routes a session-only event to the run store (ADR-0036); fail loud if it ever does. + throw new Error(`run-history store received a non-run event: ${parsed.type}`); + } + const ts = isoToEpochMs(parsed.timestamp); + // One transaction per event: the run_events append and its derived rows land atomically, so a crash + // can never leave a derived row without its event (or vice-versa). + db.transaction(() => { + fold(parsed, runId, ts); + }); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } + }, + + listInterruptedRuns: () => { + const rows = db + .select() + .from(runs) + .where(and(inArray(runs.status, [...NON_TERMINAL_STATUSES]), isNull(runs.deletedAt))) + .all(); + const interrupted = rows.map((row): InterruptedRunInfo => { + const last = + db + .select({ m: sql`max(${runEvents.seq})` }) + .from(runEvents) + .where(eq(runEvents.runId, row.id)) + .get()?.m ?? 0; + return { + runId: row.id, + workflowId: row.workflowId, + resumable: row.status === 'paused', + lastSequenceNumber: last, + }; + }); + return Promise.resolve(interrupted); + }, + + listRuns: () => + db + .select() + .from(runs) + .where(isNull(runs.deletedAt)) + .orderBy(desc(runs.createdAt)) + .all() + .map(fromRunRow), + + loadRun: (runId) => { + const row = db.select().from(runs).where(eq(runs.id, runId)).get(); + return row === undefined ? undefined : fromRunRow(row); + }, + + loadRunEvents: (runId) => + db + .select({ payloadJson: runEvents.payloadJson }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(asc(runEvents.seq)) + .all() + .map((r) => RunEventSchema.parse(JSON.parse(r.payloadJson))), + }; +} + +/** Match the `step_executions` row for a node attempt (the node-retry dispatch index; absent ⇒ 1). */ +function stepMatch(runId: string, nodeId: string, attemptNumber: number | undefined) { + return and( + eq(stepExecutions.runId, runId), + eq(stepExecutions.nodeId, nodeId), + eq(stepExecutions.attemptNumber, attemptNumber ?? 1), + ); +} diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 94ba5de0..73bc4494 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -15,6 +15,7 @@ import { type NewSessionMessageRow, type SessionMessageRow, } from './schema.js'; +import { epochMsToIso, isoToEpochMs } from './time.js'; /** * Session persistence (workstream **1.X**) — the directly-stored, append-only transcript layer over the @@ -37,22 +38,6 @@ import { * wiring + cross-restart resume are the later sub-spine (1.Y / 1.AA). */ -/** - * ISO-8601 (domain) → epoch-ms (storage). Input is an already-validated ISO string. The round-trip through - * epoch-ms preserves the **instant** but normalizes to UTC at millisecond precision: a sub-millisecond or - * non-UTC-offset input (which `isoTimestamp` permits, matching the run-event envelope) reads back as the - * same instant in canonical `…Z` form, not byte-identical. Epoch-ms storage is by design - * (database-schema.md §conventions — "timezone handled in app code"). - */ -function isoToEpochMs(iso: string): number { - return new Date(iso).getTime(); -} - -/** epoch-ms (storage) → ISO-8601 (domain), canonical UTC millisecond form. Deterministic — no wall-clock read. */ -function epochMsToIso(ms: number): string { - return new Date(ms).toISOString(); -} - /** * Optional denormalized metadata for a `session_messages` row that is **not** part of the canonical * {@link SessionMessage} transcript: a plain-text projection (display/search), the OpenAI-shape diff --git a/packages/db/src/time.ts b/packages/db/src/time.ts new file mode 100644 index 00000000..bc308cd1 --- /dev/null +++ b/packages/db/src/time.ts @@ -0,0 +1,17 @@ +/** + * The single domain ↔ storage timestamp boundary for `@relavium/db`: ISO-8601 strings in the domain + * (the run-event / session envelopes), epoch-millisecond `INTEGER`s in storage (database-schema.md + * §conventions — "timezone handled in app code"). Pure + deterministic (no wall-clock read), so a row + * round-trips an instant: the epoch-ms form preserves the **instant** but normalizes to canonical UTC + * millisecond `…Z` form, not byte-identical to a sub-millisecond / non-UTC-offset input. + */ + +/** ISO-8601 (domain) → epoch-ms (storage). Input is an already-validated ISO string. */ +export function isoToEpochMs(iso: string): number { + return new Date(iso).getTime(); +} + +/** epoch-ms (storage) → ISO-8601 (domain), canonical UTC millisecond form. */ +export function epochMsToIso(ms: number): string { + return new Date(ms).toISOString(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 945fd43d..9686a827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@relavium/core': specifier: workspace:* version: link:../../packages/core + '@relavium/db': + specifier: workspace:* + version: link:../../packages/db '@relavium/llm': specifier: workspace:* version: link:../../packages/llm From 4c7fa9b5014806b2853b64bc7e6a5829e81b9a36 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 07:36:23 +0300 Subject: [PATCH 4/6] fix(db): 2.H reviewer follow-ups (db + cli + docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the relavium-reviewer findings on the 2.H implementation (1 High, 4 Medium, 2 Low; 0 blockers): - [High] history/open.ts: the main `history.db` chmod 0600 is now a hard failure (it is guaranteed to exist; ADR-0050's at-rest guarantee rests on it) — only the WAL/SHM sidecars stay best-effort. - [Med] run.ts: a pre-run history-open fault (cannot create/open/migrate history.db) now maps to a CliError(invalid_invocation) → exit 2, not exit 1 — a CI/--json consumer can tell "db couldn't open" from "a node failed mid-run". - [Med] run-history-store.ts: handle node:retrying — mark the retried attempt's step_executions row `failed` so it can't linger as a ghost `running` row for 2.I status. - [Med] run-history-store.test.ts: extend the secrets fixture to every unsafe column (step_executions output/error JSON + runs.workflow_definition_snapshot), and add a node:retrying test. - [Med] session-store.ts: the "encrypted history.db" comment is now per-surface (desktop SQLCipher / CLI 0600/0700, ADR-0050). - [Low] run-history-store.ts: listInterruptedRuns is one GROUP BY query, not N+1. - [Low] database-schema.md: intro now says "same path" (not "same file") — consistent with the cross-host callout that the two surfaces cannot share one file. The reviewer independently verified clean: transaction ordering, the cost-delta invariant under serial delivery, engine purity, the @relavium/llm seam, in-memory test isolation, and RunHistoryStore↔RunStore assignability. Full gate green; Leakwatch 0. Refs: ADR-0050, phase-2-cli.md 2.H Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/run.ts | 14 +++- apps/cli/src/history/open.ts | 7 +- docs/reference/desktop/database-schema.md | 2 +- packages/db/src/run-history-store.test.ts | 81 +++++++++++++++++++---- packages/db/src/run-history-store.ts | 57 ++++++++++++---- packages/db/src/session-store.ts | 5 +- 6 files changed, 134 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 65085db9..8a60e099 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -97,7 +97,19 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // SQLite `RunStore`, so every node-boundary/terminal event is persisted before delivery (ADR-0036). Tests // and the 2.K harness omit `openRunStore` → the in-memory default host, no DB touched. `close()` releases // the connection at run end. A persist failure rejects out of the engine (ADR-0050 fatal posture). - const opened = deps.openRunStore?.(def, homeDir); + let opened: OpenedHistory | undefined; + try { + opened = deps.openRunStore?.(def, homeDir); + } catch (err) { + // A pre-run history fault (cannot create / open / migrate ~/.relavium/history.db) is an INVOCATION + // fault (exit 2), not a workflow failure (exit 1) — surface it as such, before the engine starts, so a + // `--json`/CI consumer can tell "the history db couldn't open" from "a node failed mid-run". + throw new CliError( + 'invalid_invocation', + `could not open the run history database: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } try { const engine = await build( opened === undefined ? { providers } : { providers, host: createCliHost(opened.store) }, diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index fbe4ede4..d6e4ce5c 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -33,8 +33,11 @@ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): const path = join(globalConfigDir(homeDir), 'history.db'); const client = createClient(path); runMigrations(client.db); - // Owner-only on the db + its WAL/SHM sidecars (a sidecar may not exist yet — tolerate its absence). - for (const suffix of ['', '-wal', '-shm']) { + // The db file is guaranteed to exist here (better-sqlite3 created it; runMigrations wrote it), so a chmod + // failure on IT must be LOUD — ADR-0050's whole at-rest guarantee is this 0600. Its WAL/SHM sidecars may + // not exist yet (no checkpoint), so those alone are best-effort. + chmodSync(path, 0o600); + for (const suffix of ['-wal', '-shm']) { try { chmodSync(`${path}${suffix}`, 0o600); } catch { diff --git a/docs/reference/desktop/database-schema.md b/docs/reference/desktop/database-schema.md index 7ce21a5e..1ea28f24 100644 --- a/docs/reference/desktop/database-schema.md +++ b/docs/reference/desktop/database-schema.md @@ -7,7 +7,7 @@ - **Scope**: Phase 1, local-first. SQLite via `tauri-plugin-sql`, schema managed by Drizzle ORM in `packages/db` (see [project-structure.md](../../project-structure.md)). - **Related**: [keychain-and-secrets.md](keychain-and-secrets.md), [tauri-plugins.md](tauri-plugins.md), [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md), [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md), [../../architecture/managed-inference.md](../../architecture/managed-inference.md), [../../architecture/local-first-and-security.md](../../architecture/local-first-and-security.md) -This is the canonical reference for the **local** run-history and catalog database that the desktop app (and the Phase-2 CLI) persists on the user's machine. There is no cloud, no account, and no server in Phase 1 — every table below lives in a single local SQLite file. **At-rest encryption is per-surface:** the desktop opens `history.db` with SQLCipher, while the Phase-2 **CLI** opens the same file with `better-sqlite3` **unencrypted, guarded by `0600`/`0700` OS permissions** ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) — see [Encryption at rest](#encryption-at-rest). The Phase-2 PostgreSQL divergences are described at the end and detailed in [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md). +This is the canonical reference for the **local** run-history and catalog database that the desktop app (and the Phase-2 CLI) persists on the user's machine. There is no cloud, no account, and no server in Phase 1 — every table below lives in a single local SQLite file. **At-rest encryption is per-surface:** the desktop opens `history.db` with SQLCipher, while the Phase-2 **CLI** opens the same **path** with `better-sqlite3` **unencrypted, guarded by `0600`/`0700` OS permissions** ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)). Because a standard `better-sqlite3` build cannot open a SQLCipher file (nor vice-versa), the two surfaces **cannot share one file** — see [Encryption at rest](#encryption-at-rest) and the cross-host callout under the agent-session tables. The Phase-2 PostgreSQL divergences are described at the end and detailed in [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md). ## Storage layout diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 647a686e..30df64d2 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -237,26 +237,83 @@ describe('createRunHistoryStore', () => { expect(byId.get('run-m')?.resumable).toBe(false); }); - // Security fixture — the engine masks a secret-typed input at the bus as { secret: true, ref }; the store - // is pass-through. Assert the masked placeholder is what lands, and the raw secret value never appears in - // any unsafe column (run_events.payload_json, runs.input_json). Defense in depth — ADR-0050. - it('never persists a raw secret — the masked placeholder is stored verbatim', async () => { + it('marks a retried attempt failed so no step row lingers in `running`', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'flaky', nodeType: 'agent' })); + await store.persistEvent( + ev('node:retrying', 2, { + nodeId: 'flaky', + attemptNumber: 1, + error: { code: 'provider_unavailable', message: '503', retryable: true }, + delayMs: 10, + }), + ); + await store.persistEvent( + ev('node:started', 3, { nodeId: 'flaky', nodeType: 'agent', attemptNumber: 2 }), + ); + await store.persistEvent( + ev('node:completed', 4, { + nodeId: 'flaky', + output: {}, + tokensUsed: { input: 1, output: 1 }, + durationMs: 1, + attemptNumber: 2, + }), + ); + const steps = client.db.select().from(stepExecutions).all(); + expect(steps.map((s) => [s.attemptNumber, s.status]).sort()).toEqual([ + [1, 'failed'], // the retried attempt is terminal, not a ghost `running` + [2, 'completed'], + ]); + }); + + // Security fixture — the engine masks a secret-typed value at the bus as { secret: true, ref }; the store is + // pass-through. Assert the masked placeholder lands and the RAW value never appears in ANY unsafe column + // (database-schema.md §"Secrets at the write boundary"): run_events.payload_json, runs.input_json, + // runs.workflow_definition_snapshot, and the step_executions input/output/error JSON. Defense in depth, ADR-0050. + it('never persists a raw secret — the masked placeholder only, across every unsafe column', async () => { const RAW = ['sk', 'live', 'DEADBEEF'].join('-'); // a fake key, built so no contiguous literal exists + const masked = { secret: true, ref: 'keychain://relavium/anthropic' } as const; const workflowId = await store.resolveWorkflowId('secret-wf'); await store.persistEvent({ - ...ev('run:started', 0, { - workflowId, - inputs: { api_key: { secret: true, ref: 'keychain://relavium/anthropic' } }, - executionMode: 'local', + ...ev('run:started', 0, { workflowId, inputs: { api_key: masked }, executionMode: 'local' }), + runId: 'run-s', + }); + await store.persistEvent({ + ...ev('node:started', 1, { nodeId: 'n', nodeType: 'agent' }), + runId: 'run-s', + }); + // A masked value can ride a node output too — assert step_executions.output_json stays masked. + await store.persistEvent({ + ...ev('node:completed', 2, { + nodeId: 'n', + output: { echoed: masked }, + tokensUsed: { input: 0, output: 0 }, + durationMs: 1, }), runId: 'run-s', }); const runRow = client.db.select().from(runs).where(eq(runs.id, 'run-s')).get(); - const eventRow = client.db.select().from(runEvents).where(eq(runEvents.runId, 'run-s')).get(); + const stepRow = client.db + .select() + .from(stepExecutions) + .where(eq(stepExecutions.runId, 'run-s')) + .get(); + const eventRows = client.db.select().from(runEvents).where(eq(runEvents.runId, 'run-s')).all(); + expect(runRow?.inputJson).toContain('"secret":true'); - expect(runRow?.inputJson).not.toContain(RAW); - expect(eventRow?.payloadJson).toContain('"secret":true'); - expect(eventRow?.payloadJson).not.toContain(RAW); + expect(stepRow?.outputJson).toContain('"secret":true'); + // No unsafe column contains the raw value. + for (const value of [ + runRow?.inputJson, + runRow?.workflowDefinitionSnapshot, + stepRow?.inputJson, + stepRow?.outputJson, + stepRow?.errorJson, + ...eventRows.map((e) => e.payloadJson), + ]) { + expect(value ?? '').not.toContain(RAW); + } }); }); diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 26642e81..37c8976c 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -258,10 +258,26 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis .run(); return; } + case 'node:retrying': { + // The attempt that just failed gets a terminal `failed` status; the next `node:started(attempt+1)` + // inserts a fresh row. Without this, the intermediate attempt's row would linger as `running` forever + // and surface as a ghost step in `relavium status` (2.I). The terminal `node:failed` (budget + // exhausted) closes the LAST attempt's row via the same `stepMatch`. + db.update(stepExecutions) + .set({ + status: 'failed', + errorJson: JSON.stringify(event.error), + completedAt: ts, + updatedAt: ts, + }) + .where(stepMatch(runId, event.nodeId, event.attemptNumber)) + .run(); + return; + } default: - // node:skipped / node:retrying / media_job:submitted / run:timeout (+ any future durable event): - // captured in run_events below; no derived runs/step/cost write in 2.H's scope. (A skipped node has - // no nodeType on its event, so it gets no step_executions row — the run_events log records the skip.) + // node:skipped / media_job:submitted / run:timeout (+ any future durable event): captured in + // run_events below; no derived runs/step/cost write in 2.H's scope. (A skipped node has no nodeType + // on its event, so it gets no step_executions row — the run_events log records the skip.) return; } }; @@ -340,20 +356,33 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis .from(runs) .where(and(inArray(runs.status, [...NON_TERMINAL_STATUSES]), isNull(runs.deletedAt))) .all(); - const interrupted = rows.map((row): InterruptedRunInfo => { - const last = - db - .select({ m: sql`max(${runEvents.seq})` }) - .from(runEvents) - .where(eq(runEvents.runId, row.id)) - .get()?.m ?? 0; - return { + if (rows.length === 0) { + return Promise.resolve([]); + } + // One aggregating query for the per-run last seq (a single GROUP BY, not N+1) — this is a RunStore + // port method the desktop/cloud surfaces also implement, so it must scale past a single-user CLI. + const lastByRun = new Map( + db + .select({ runId: runEvents.runId, m: sql`max(${runEvents.seq})` }) + .from(runEvents) + .where( + inArray( + runEvents.runId, + rows.map((r) => r.id), + ), + ) + .groupBy(runEvents.runId) + .all() + .map((r) => [r.runId, r.m]), + ); + const interrupted = rows.map( + (row): InterruptedRunInfo => ({ runId: row.id, workflowId: row.workflowId, resumable: row.status === 'paused', - lastSequenceNumber: last, - }; - }); + lastSequenceNumber: lastByRun.get(row.id) ?? 0, + }), + ); return Promise.resolve(interrupted); }, diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 73bc4494..524353df 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -34,8 +34,9 @@ import { epochMsToIso, isoToEpochMs } from './time.js'; * {@link SessionMessageMeta} — NULL/0 when the durable parts array is the sole source of a row. * * This package is host-facing (it uses `better-sqlite3`); the platform-free engine never imports it. The - * desktop / CLI open the encrypted `history.db` and wire this store; the per-turn `AgentSession`→store - * wiring + cross-restart resume are the later sub-spine (1.Y / 1.AA). + * desktop / CLI open `history.db` and wire this store — the desktop with SQLCipher (ADR-0005), the CLI + * unencrypted, guarded by `0600`/`0700` OS permissions (ADR-0050); the per-turn `AgentSession`→store wiring + * + cross-restart resume are the later sub-spine (1.Y / 1.AA). */ /** From 9e953632347adfdcaa4a6fbc72f282a28cec4141 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 09:45:54 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(db,cli):=202.H=20=E2=80=94=20address=20?= =?UTF-8?q?the=20external=20review=20round=20(3=20reviews,=20all=20PASS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three reviews verdict PASS/ship-it; findings were test-coverage + doc-precision (0 blockers, 2 Medium, several Low). The consensus actionable item (flagged by all three) was the WAL/SHM chmod catch. - [Med · consensus] open.ts: the WAL/SHM sidecar chmod catch is now ENOENT-narrow — a real failure (EPERM/EIO) on an existing sidecar throws instead of silently leaving it world-readable; only genuine absence (no checkpoint yet) is tolerated. - [Med] run-history-store.test.ts: add the fan-out cost-invariant test (interleaved node:completed cumulatives still telescope to runs.total) — closes the explicit 2.H parallel acceptance criterion that was only covered serially. - [Low] add a backward-compat test (node:completed without cumulativeCostMicrocents → 0). - [Low] run-history-store.ts: comment that per-node run_costs attribution is approximate under a fan-out (the run-level SUM stays exact); note the known limitation that a failed/cancelled run's total can undercount (no total on run:failed in the shared schema — out of 2.H scope). - [Low] clarify the secrets fixture's step_executions.input_json check (always '{}' by design — vacuous-but-complete), so a reader doesn't assume node inputs are captured there. - [Low] database-schema.md + ADR-0050: reword "asserts at the write boundary" → the writer is pass-through, the no-raw-secret invariant is the upstream masking guarantee regression-guarded by the package secrets fixture (a runtime secret scan on opaque JSON is infeasible) — so a future desktop/cloud writer isn't expected to implement one. db 96 tests, cli 125; full gate green; Leakwatch 0; packages/core untouched. Refs: ADR-0050, phase-2-cli.md 2.H Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/history/open.ts | 18 ++++++- .../0050-cli-history-db-at-rest-posture.md | 15 +++--- docs/reference/desktop/database-schema.md | 2 +- packages/db/src/run-history-store.test.ts | 53 ++++++++++++++++++- packages/db/src/run-history-store.ts | 12 +++++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index d6e4ce5c..2680c41b 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -40,8 +40,13 @@ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): for (const suffix of ['-wal', '-shm']) { try { chmodSync(`${path}${suffix}`, 0o600); - } catch { - // No WAL checkpoint yet → the sidecar is absent; nothing to permission. + } catch (err) { + // Tolerate ONLY the sidecar's absence (no WAL checkpoint has happened yet). A real chmod failure + // (EPERM, EIO) on an existing sidecar must surface — it holds the same run data as the db, so the + // same 0600 guarantee applies; swallowing it would leave it world-readable (ADR-0050). + if (errnoCode(err) !== 'ENOENT') { + throw err; + } } } const store = createRunHistoryStore(client.db, { @@ -60,3 +65,12 @@ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): }, }; } + +/** The `errno` code of a Node fs error (`ENOENT`, `EPERM`, …), or `undefined` if it is not one. */ +function errnoCode(err: unknown): string | undefined { + if (typeof err === 'object' && err !== null && 'code' in err) { + const code: unknown = err.code; + return typeof code === 'string' ? code : undefined; + } + return undefined; +} diff --git a/docs/decisions/0050-cli-history-db-at-rest-posture.md b/docs/decisions/0050-cli-history-db-at-rest-posture.md index 4c0be9db..da7d8dd1 100644 --- a/docs/decisions/0050-cli-history-db-at-rest-posture.md +++ b/docs/decisions/0050-cli-history-db-at-rest-posture.md @@ -92,13 +92,14 @@ is updated to say so. (The per-project `runs.db` is unaffected — it is already git-committed, holding only non-sensitive metadata.) **Companion obligation (no new decision).** The 2.H history writer is **pass-through** for -secrets: it never re-masks (the engine already masked at the bus) but **asserts at the write -boundary** that the unsafe columns — `run_events.payload_json`, the `step_executions` -input/output/error JSON, `run_costs`, and `runs.workflow_definition_snapshot` — carry no raw -secret, as defense in depth against an upstream regression. This pass-through-plus-assert -contract is recorded in [database-schema.md](../reference/desktop/database-schema.md); it is -the engine's existing guarantee ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), -[ADR-0006](0006-os-keychain-for-api-keys.md)), not a new decision. +secrets: it never re-masks (the engine already masked at the bus) and adds no runtime +secret-detection (infeasible on opaque JSON). The no-raw-secret invariant on the unsafe columns +— `run_events.payload_json`, the `step_executions` input/output/error JSON, `run_costs`, and +`runs.workflow_definition_snapshot` — is the engine's upstream masking guarantee +([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), +[ADR-0006](0006-os-keychain-for-api-keys.md)), regression-guarded by the package's secrets +fixture; it is recorded in [database-schema.md](../reference/desktop/database-schema.md), and +is not a new decision. ## Consequences diff --git a/docs/reference/desktop/database-schema.md b/docs/reference/desktop/database-schema.md index 1ea28f24..307f631d 100644 --- a/docs/reference/desktop/database-schema.md +++ b/docs/reference/desktop/database-schema.md @@ -489,7 +489,7 @@ The per-project `runs.db` is **not** encrypted on any surface because it is inte ### Secrets at the write boundary -The history writer is **pass-through** for secrets — it never re-masks (the engine already masked at the `RunEventBus`, [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) — but **asserts at the write boundary** that the unsafe columns carry no raw secret, as defense in depth against an upstream regression: `run_events.payload_json`, the `step_executions` `input_json` / `output_json` / `error_json`, `run_costs`, and `runs.workflow_definition_snapshot`. A raw API key, `Authorization` header, or `secret`-typed value must never appear in these columns; a `secret`-typed input persists only as its `{ secret: true, ref }` placeholder. +The history writer is **pass-through** for secrets — it never re-masks (the engine already masked secret-typed inputs and tool I/O at the `RunEventBus`, [ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) and adds **no** runtime secret-detection (infeasible on opaque JSON). The no-raw-secret invariant on the unsafe columns — `run_events.payload_json`, the `step_executions` `input_json` / `output_json` / `error_json`, `run_costs`, and `runs.workflow_definition_snapshot` — is therefore the **upstream masking guarantee**, regression-guarded by the package's **secrets fixture** (`run-history-store.test.ts`): a raw API key, `Authorization` header, or `secret`-typed value must never appear in these columns; a `secret`-typed value persists only as its `{ secret: true, ref }` placeholder. (A future desktop/cloud history writer inherits the same contract — masking is upstream, verification is by fixture; it must not be expected to implement a runtime secret scan.) ## Phase 2 (PostgreSQL) divergences diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 30df64d2..6535bd08 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -149,6 +149,55 @@ describe('createRunHistoryStore', () => { expect(run?.status).toBe('completed'); }); + it('keeps sum(run_costs) == runs.total under a fan-out (interleaved node:completed)', async () => { + // Both branches start, then complete out of start-order — the cumulative snapshot on the SECOND + // node:completed already includes the first's cost. Per-node attribution is interleave-dependent, but + // the deltas must still telescope to the run total (the 2.H acceptance invariant under parallel). + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'a', nodeType: 'transform' })); + await store.persistEvent(ev('node:started', 2, { nodeId: 'b', nodeType: 'transform' })); + await store.persistEvent( + ev('node:completed', 3, { + nodeId: 'b', + output: {}, + tokensUsed: { input: 1, output: 1 }, + durationMs: 1, + cumulativeCostMicrocents: 500, // b finishes first; cumulative = b's cost + }), + ); + await store.persistEvent( + ev('node:completed', 4, { + nodeId: 'a', + output: {}, + tokensUsed: { input: 1, output: 1 }, + durationMs: 1, + cumulativeCostMicrocents: 900, // a finishes second; cumulative = b(500) + a(400) + }), + ); + const costs = client.db.select().from(runCosts).all(); + const run = client.db.select().from(runs).where(eq(runs.id, 'run-1')).get(); + expect(costs.reduce((s, c) => s + c.costMicrocents, 0)).toBe(900); + expect(run?.totalCostMicrocents).toBe(900); // sum == total regardless of completion interleave + }); + + it('records zero cost for a node:completed without cumulativeCostMicrocents (backward-compat)', async () => { + await startRun(); + await store.persistEvent(ev('node:started', 1, { nodeId: 'x', nodeType: 'transform' })); + await store.persistEvent( + ev('node:completed', 2, { + nodeId: 'x', + output: {}, + tokensUsed: { input: 0, output: 0 }, + durationMs: 1, + // cumulativeCostMicrocents absent — a pre-field replayed log; the delta path must yield 0. + }), + ); + expect(client.db.select().from(runCosts).get()?.costMicrocents).toBe(0); + expect( + client.db.select().from(runs).where(eq(runs.id, 'run-1')).get()?.totalCostMicrocents, + ).toBe(0); + }); + it('persists a gap-free seq stream and rejects a duplicate (run_id, seq)', async () => { await startRun(); await store.persistEvent(ev('node:started', 1, { nodeId: 'x', nodeType: 'input' })); @@ -304,7 +353,9 @@ describe('createRunHistoryStore', () => { expect(runRow?.inputJson).toContain('"secret":true'); expect(stepRow?.outputJson).toContain('"secret":true'); - // No unsafe column contains the raw value. + // No unsafe column contains the raw value. `stepRow.inputJson` is always '{}' (node:started carries no + // runtime input payload by design — the store never writes it), so its check is vacuous-but-complete: + // it documents that the column is covered and stays empty, not that node inputs are captured here. for (const value of [ runRow?.inputJson, runRow?.workflowDefinitionSnapshot, diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 37c8976c..27119219 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -163,6 +163,13 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis return; } case 'node:completed': { + // Per-node cost = the delta of the run-wide cumulative snapshot since the last boundary. The + // run-level SUM is exact (the deltas telescope to the final cumulative; events arrive serially in + // sequenceNumber order even for parallel branches). The per-node ATTRIBUTION is approximate under a + // fan-out: a sibling that accrued cost before this node:completed inflates this delta and shrinks the + // sibling's — so `relavium status`/`logs` per-node cost is exact only for serial execution. Absent + // cumulative (backward-compat for pre-field logs) ⇒ delta 0; `Math.max(0, …)` guards a non-monotonic + // cumulative (a deeper engine bug). const prev = currentRunCost(runId); const cumulative = event.cumulativeCostMicrocents ?? prev; const nodeCost = Math.max(0, cumulative - prev); @@ -239,6 +246,11 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis return; } case 'run:failed': { + // Known limitation: run:failed / run:cancelled carry no total-cost field in the run-event schema, + // and the failing node's cost is unrecoverable here (node:failed has no cost; cost:updated is not + // persisted). So a failed run's runs.total_cost_microcents is the last node:completed cumulative — + // it may undercount spend incurred after it. `sum(run_costs) == total` still holds (both undercount). + // A true failed-run total needs a shared-schema change (a total on run:failed) — out of 2.H scope. db.update(runs) .set({ status: 'failed', From d28eee3d86f6a13b9fa794b11a2d9a6c76cfb829 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 23 Jun 2026 10:12:31 +0300 Subject: [PATCH 6/6] =?UTF-8?q?fix(db):=202.H=20follow-up=20review=20?= =?UTF-8?q?=E2=80=94=20cost=20monotonicity,=20atomic=20upsert,=20single-qu?= =?UTF-8?q?ery=20interrupted,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the still-valid findings from the latest review pass (skips noted below): - runs.total_cost_microcents now writes `prev + nodeCost` (= max(prev, cumulative)), not the raw cumulative snapshot — so the run total stays monotonic and always equals sum(run_costs) even if a snapshot regressed (a deeper engine bug). - resolveWorkflowId: insert-or-ignore (ON CONFLICT DO NOTHING) + read-back, atomic against a concurrent `relavium run` on the same slug (the old SELECT-then-INSERT could race two processes onto the active-slug UNIQUE index). - listInterruptedRuns: one LEFT JOIN + coalesce(max(seq),0) grouped by the run PK — drops the second round-trip and the inArray(ids) that would hit SQLite's host-parameter limit at scale. - Merge the duplicate node:failed / node:retrying case blocks (Sonar S-dup) — same step-failed write. - test: the multi-node run_costs assertion now sorts the deltas (SQLite gives no row order without ORDER BY); the `ev` fixture helper strongly types `rest` per variant (a wrong/missing field is now a compile error — the assembly cast remains but widens nothing). Skipped, with reason: - "blockquote blank line" (database-schema.md): the blank is BETWEEN two distinct blockquotes (the session-message at-rest note and the cross-host note), not inside one — both render correctly. - "gap-free seq rejection test": the store persists the bus-assigned seq; a gap is not write-rejected (only UNIQUE(run_id,seq) rejects duplicates). Gap-freeness is the RunEventBus guarantee (ADR-0036), so the suggested seq-3-after-0,1 test would not throw. - tokensUsed / totalTokensUsed optional-chaining: both are REQUIRED on their schemas; RunEventSchema .parse (before the fold) rejects a missing one — the optional chaining would be dead code. db 96 tests; full gate green; Leakwatch 0; packages/core untouched. Refs: ADR-0050, phase-2-cli.md 2.H Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/src/run-history-store.test.ts | 25 ++++-- packages/db/src/run-history-store.ts | 95 ++++++++++------------- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 6535bd08..eac826d2 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -24,13 +24,27 @@ const WORKFLOW: RunHistoryWorkflow = { definitionJson: JSON.stringify({ workflow: { id: 'demo', name: 'Demo', nodes: [], edges: [] } }), }; -/** Build a valid RunEvent — `RunEventSchema.parse` (inside persistEvent) is the real validation. */ +/** The variant-specific fields of a RunEvent (everything but the envelope) — so call sites are type-checked. */ +type EventBody = Omit< + Extract, + 'type' | 'runId' | 'timestamp' | 'sequenceNumber' +>; + +/** + * Build a RunEvent for the fixtures: `rest` is strongly typed PER VARIANT (a wrong/missing field is a + * compile error). The final assembly assertion is unavoidable — TS can't prove a generic spread reconstructs + * the exact union member — but it widens nothing: every field is already type-checked, and `RunEventSchema` + * .parse (inside persistEvent) is the authoritative runtime validation. + */ function ev( type: T, seq: number, - rest: Record, -): RunEvent { - return { type, runId: 'run-1', timestamp: TS, sequenceNumber: seq, ...rest } as RunEvent; + rest: EventBody, +): Extract { + return { type, runId: 'run-1', timestamp: TS, sequenceNumber: seq, ...rest } as Extract< + RunEvent, + { type: T } + >; } describe('createRunHistoryStore', () => { @@ -142,7 +156,8 @@ describe('createRunHistoryStore', () => { ); const costs = client.db.select().from(runCosts).all(); - expect(costs.map((c) => c.costMicrocents)).toEqual([300, 700]); // deltas + // Order-independent (SQLite gives no row order without ORDER BY) — assert the multiset of deltas. + expect(costs.map((c) => c.costMicrocents).sort((a, b) => a - b)).toEqual([300, 700]); const total = costs.reduce((s, c) => s + c.costMicrocents, 0); const run = client.db.select().from(runs).where(eq(runs.id, 'run-1')).get(); expect(total).toBe(run?.totalCostMicrocents); // acceptance: sum(run_costs) == runs.total_cost_microcents diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 27119219..2982af38 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -201,14 +201,21 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis .set({ totalInputTokens: sql`${runs.totalInputTokens} + ${event.tokensUsed.input}`, totalOutputTokens: sql`${runs.totalOutputTokens} + ${event.tokensUsed.output}`, - totalCostMicrocents: cumulative, + // `prev + nodeCost` (= max(prev, cumulative)), NOT the raw `cumulative` — so the run total stays + // monotonic + always equals sum(run_costs) even if a snapshot regressed (a deeper engine bug). + totalCostMicrocents: prev + nodeCost, updatedAt: ts, }) .where(eq(runs.id, runId)) .run(); return; } - case 'node:failed': { + case 'node:failed': + case 'node:retrying': { + // Mark the attempt's step row `failed`. For `node:failed` this is the node's TERMINAL failure + // (node-retry budget exhausted); for `node:retrying` it is the intermediate attempt that will + // re-dispatch as a fresh row on the next `node:started`. Either way the attempt's row must not + // linger as `running` (a ghost in `relavium status`, 2.I). Both carry `error` + `attemptNumber`. db.update(stepExecutions) .set({ status: 'failed', @@ -270,22 +277,6 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis .run(); return; } - case 'node:retrying': { - // The attempt that just failed gets a terminal `failed` status; the next `node:started(attempt+1)` - // inserts a fresh row. Without this, the intermediate attempt's row would linger as `running` forever - // and surface as a ghost step in `relavium status` (2.I). The terminal `node:failed` (budget - // exhausted) closes the LAST attempt's row via the same `stepMatch`. - db.update(stepExecutions) - .set({ - status: 'failed', - errorJson: JSON.stringify(event.error), - completedAt: ts, - updatedAt: ts, - }) - .where(stepMatch(runId, event.nodeId, event.attemptNumber)) - .run(); - return; - } default: // node:skipped / media_job:submitted / run:timeout (+ any future durable event): captured in // run_events below; no derived runs/step/cost write in 2.H's scope. (A skipped node has no nodeType @@ -316,14 +307,19 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis return { resolveWorkflowId: (slug) => { - const existing = db - .select({ id: workflows.id }) - .from(workflows) - .where(and(eq(workflows.slug, slug), isNull(workflows.deletedAt))) - .get(); + const find = (): string | undefined => + db + .select({ id: workflows.id }) + .from(workflows) + .where(and(eq(workflows.slug, slug), isNull(workflows.deletedAt))) + .get()?.id; + const existing = find(); if (existing !== undefined) { - return Promise.resolve(existing.id); + return Promise.resolve(existing); } + // Insert-or-ignore, then read back the winning id. Atomic against a concurrent `relavium run` on the + // same slug: a plain SELECT-then-INSERT could let two processes both read empty and both insert, with + // the second hitting the active-slug UNIQUE index — ON CONFLICT DO NOTHING makes the loser a no-op. const id = deps.uuid(); const t = deps.now(); db.insert(workflows) @@ -335,8 +331,9 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis createdAt: t, updatedAt: t, }) + .onConflictDoNothing() .run(); - return Promise.resolve(id); + return Promise.resolve(find() ?? id); }, persistEvent: (event) => { @@ -363,39 +360,31 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis }, listInterruptedRuns: () => { + // One pass: a LEFT JOIN + coalesce(max(seq),0), grouped by the run PK. No second round-trip and no + // `inArray(ids)` (which would hit SQLite's host-parameter limit when many runs are interrupted) — this + // is a RunStore port method the desktop/cloud surfaces also implement, so it must scale. const rows = db - .select() + .select({ + id: runs.id, + workflowId: runs.workflowId, + status: runs.status, + lastSeq: sql`coalesce(max(${runEvents.seq}), 0)`, + }) .from(runs) + .leftJoin(runEvents, eq(runEvents.runId, runs.id)) .where(and(inArray(runs.status, [...NON_TERMINAL_STATUSES]), isNull(runs.deletedAt))) + .groupBy(runs.id) .all(); - if (rows.length === 0) { - return Promise.resolve([]); - } - // One aggregating query for the per-run last seq (a single GROUP BY, not N+1) — this is a RunStore - // port method the desktop/cloud surfaces also implement, so it must scale past a single-user CLI. - const lastByRun = new Map( - db - .select({ runId: runEvents.runId, m: sql`max(${runEvents.seq})` }) - .from(runEvents) - .where( - inArray( - runEvents.runId, - rows.map((r) => r.id), - ), - ) - .groupBy(runEvents.runId) - .all() - .map((r) => [r.runId, r.m]), - ); - const interrupted = rows.map( - (row): InterruptedRunInfo => ({ - runId: row.id, - workflowId: row.workflowId, - resumable: row.status === 'paused', - lastSequenceNumber: lastByRun.get(row.id) ?? 0, - }), + return Promise.resolve( + rows.map( + (row): InterruptedRunInfo => ({ + runId: row.id, + workflowId: row.workflowId, + resumable: row.status === 'paused', + lastSequenceNumber: row.lastSeq, + }), + ), ); - return Promise.resolve(interrupted); }, listRuns: () =>