-
Notifications
You must be signed in to change notification settings - Fork 1
Pipeline Design 17
The design.md file is in a sensitive directory. Let me output the ADR directly instead.
Shipwright currently stores all runtime state as flat files under ~/.shipwright/:
-
events.jsonl — append-only JSONL, no locking, rotates at 50 MB. Re-parsed in full by
dashboard/server.tsviareadEvents()on 18 separate call sites per request cycle. -
daemon-state.json — JSON read-modify-write with
flock(FD 7, 5 s timeout) + atomic tmp-mv. Re-read on every dashboard poll. -
costs.json — JSON array capped at 1000 entries,
flock(FD 200, 10 s timeout) + atomic write. -
memory/{hash}/failures.json, decisions.json — per-repo JSON,
flock10 s + atomic write, capped at 100 entries.
Problems this solves:
-
Dashboard performance —
readEvents()re-parses the entire events.jsonl (up to 50 MB) on every API call.calculateDoraGrades(dashboard/server.ts:1099) does a full linear scan of events for each request. With 18 call sites, a single dashboard page load can parse the same file dozens of times. - Query limitations — computing "cost per day", "stage duration percentiles", or "failure frequency by category" requires full-file scans with in-memory filtering. SQL indexing eliminates this.
-
Concurrency — multiple daemons (fleet mode) writing events concurrently rely on
>>append atomicity, which is not guaranteed for lines exceedingPIPE_BUF(4 KB on macOS). SQLite WAL mode provides proper concurrent-read/single-write semantics. - Data evolution — adding new queryable fields requires changing every consumer. SQL migration files provide structured schema evolution.
Constraints:
- Bash 3.2 compatible (no associative arrays, no
readarray, no${var,,}) -
sqlite3CLI may not be installed on all systems — bash writes must degrade gracefully - Dashboard uses Bun runtime — must use
bun:sqlite(built-in, zero-dep) - Existing JSONL/JSON files are the source of truth for non-dashboard consumers and cannot be removed
- 22 existing test suites must continue passing
Data flow:
Bash scripts (write path) Dashboard (read path)
───────────────────────── ─────────────────────
emit_event() ──┬──▶ events.jsonl server.ts
└──▶ sqlite3 INSERT ├── bun:sqlite queries (primary)
└── file-based fallback (if DB missing)
cost_record() ─┬──▶ costs.json
└──▶ sqlite3 INSERT
daemon_track_job() ─┬──▶ daemon-state.json
└──▶ sqlite3 INSERT/UPDATE
Key decisions:
-
JSONL remains authoritative. Bash scripts always write to JSONL/JSON first. The SQLite insert is a best-effort secondary write guarded by
command -v sqlite3 >/dev/null 2>&1and|| true. If SQLite is unavailable or the write fails, the system behaves identically to today. -
Dashboard reads SQLite first, files second. Each query function in
server.tsattempts a SQL query viabun:sqlite. If the DB file doesn't exist or the query fails, it falls back to the currentreadFileSync+ parse logic. This makes the migration completely non-breaking. -
Two runtimes, one database. Bash uses the
sqlite3CLI binary. TypeScript usesbun:sqlite(Bun's built-in FFI binding). Both access~/.shipwright/shipwright.db. WAL mode (PRAGMA journal_mode=WAL) allows concurrent readers while one writer holds the lock. A 5-second busy timeout (PRAGMA busy_timeout=5000) prevents immediate failures under contention. -
Schema managed via numbered migration files.
scripts/lib/migrations/001_initial_schema.sqlcontains the initial 8-table schema. Aschema_versiontable tracks applied migrations. Both the bash library (sw_db_migrate) and the TypeScript module (ShipwrightDB.migrate()) run the same.sqlfiles, ensuring schema consistency regardless of which runtime initializes the database first. -
Feature-gated in bash. The presence of
sqlite3on PATH controls whether dual-writes happen.SW_SQLITE_DISABLED=1can explicitly opt out. No code changes needed to disable. -
extraTEXT column for overflow. Tables likeeventsandpipeline_runsinclude anextra TEXTcolumn for JSON-encoded fields that don't warrant dedicated columns. This prevents schema churn for uncommon event properties while keeping frequently-queried fields as proper indexed columns.
schema_version (version INTEGER PK, applied_at TEXT, description TEXT)
events (id INTEGER PK AUTOINCREMENT, type TEXT NOT NULL, ts TEXT NOT NULL,
ts_epoch INTEGER NOT NULL, issue INTEGER, stage TEXT, result TEXT,
duration_s REAL, cost_usd REAL, model TEXT, extra TEXT)
→ idx: type, ts_epoch, issue, (type + ts_epoch)
pipeline_runs (id INTEGER PK AUTOINCREMENT, issue INTEGER NOT NULL,
branch TEXT, template TEXT, status TEXT NOT NULL DEFAULT 'running',
started_epoch INTEGER NOT NULL, completed_epoch INTEGER,
duration_s REAL, pr_url TEXT, worktree_path TEXT, extra TEXT)
→ idx: status, issue, started_epoch
pipeline_stages (id INTEGER PK AUTOINCREMENT, run_id INTEGER NOT NULL,
stage TEXT NOT NULL, status TEXT NOT NULL,
started_epoch INTEGER, completed_epoch INTEGER,
duration_s REAL, cost_usd REAL,
FOREIGN KEY (run_id) REFERENCES pipeline_runs(id))
cost_entries (id INTEGER PK AUTOINCREMENT, ts_epoch INTEGER NOT NULL,
input_tokens INTEGER, output_tokens INTEGER, model TEXT,
stage TEXT, issue INTEGER, cost_usd REAL NOT NULL)
→ idx: ts_epoch, model, issue
sessions (id TEXT PK, machine TEXT, started_at TEXT, last_heartbeat TEXT,
daemon_pid INTEGER, state TEXT DEFAULT 'active', active_jobs TEXT)
metrics (id INTEGER PK AUTOINCREMENT, metric_name TEXT NOT NULL,
value REAL NOT NULL, period TEXT, period_start TEXT,
computed_at TEXT NOT NULL)
→ idx: (metric_name + period_start)
memory_entries (id INTEGER PK AUTOINCREMENT, repo_hash TEXT NOT NULL,
entry_type TEXT NOT NULL, pattern TEXT, root_cause TEXT,
fix TEXT, category TEXT, seen_count INTEGER DEFAULT 1,
frequency REAL, last_seen TEXT, extra TEXT)
→ idx: (repo_hash + entry_type), last_seen-
Bash writes: Every SQLite insert is wrapped in
|| true.sw_db_available()runs once per script invocation (cached in_SW_DB_AVAILABLE). Ifsqlite3is not on PATH, allsw_db_*calls become no-ops. - Dashboard reads: Each SQL query is in try/catch. On any SQLite error, the function falls back to file-based reads. Logged to stderr, not surfaced to user.
-
Migration failures:
schema_versionisn't updated until migration succeeds. Next startup retries. Migrations useBEGIN/COMMITfor atomicity.
shipwright db import reads existing files and bulk-inserts:
-
events.jsonl→events(batched in 1000-row transactions) -
costs.json→cost_entries -
memory/*/failures.json→memory_entries(entry_type='failure') -
memory/*/decisions.json→memory_entries(entry_type='decision')
Idempotent — checks MAX(ts_epoch) and only imports newer entries.
Pros: Single source of truth, no dual-write complexity, simpler long-term.
Cons: Breaking change for ~15 bash consumers that use jq to parse JSON files. Pipeline scripts, cost checks, memory injection, self-optimization would all need rewriting. The flock + atomic-write pattern in sw-cost.sh:158, sw-daemon.sh:1041, and sw-memory.sh:219 is battle-tested; removing it risks regressions across all 22 test suites.
Verdict: Rejected. Migration risk outweighs simplicity. Dual-write validates SQLite before committing to it.
Pros: Better concurrency, richer query language, proven at scale.
Cons: Requires a running server process. Shipwright targets developer laptops and CI runners — a PostgreSQL dependency is a non-starter.
Verdict: Rejected. SQLite's serverless model matches Shipwright's architecture.
Pros: Simpler data model, fast point lookups.
Cons: No SQL — DORA metrics, cost aggregations, activity feeds still need custom code. No CLI for bash. Requires native bindings (unlike bun:sqlite which is built-in).
Verdict: Rejected. SQL aggregation is the primary value.
Pros: Simpler change — cache readEvents() with TTL invalidation.
Cons: Solves only repeated-parse, not query limitations. DORA still requires full scans. No help with concurrency, no schema evolution, memory grows with file size.
Verdict: Rejected. Caching is a band-aid.
| File | Purpose |
|---|---|
scripts/lib/migrations/001_initial_schema.sql |
8 tables, 15 indexes, schema_version tracking |
scripts/lib/db.sh |
Bash SQLite library: sw_db_path(), sw_db_available(), sw_db_migrate(), sw_db_ensure(), sw_db_query(), sw_db_insert_event(), sw_db_insert_cost(), sw_db_insert_pipeline_run(), sw_db_update_pipeline_run(). Double-source-guarded. WAL + 5 s busy timeout. Bash 3.2 compatible. |
scripts/sw-db.sh |
CLI: shipwright db migrate|status|import|export
|
scripts/sw-db-test.sh |
Test suite: schema, CRUD, DORA w/ seeded data, import idempotency, 5-writer concurrency, degradation without sqlite3
|
dashboard/db.ts |
ShipwrightDB class via bun:sqlite: migrations, event CRUD, pipeline queries, SQL DORA grades, cost breakdowns, paginated activity, session mgmt, memory queries |
| File | Change |
|---|---|
scripts/lib/helpers.sh:56 |
Dual-write in emit_event() — source db.sh, gate behind sw_db_available, ` |
scripts/sw-cost.sh:146 |
sw_db_insert_cost inside existing flock in cost_record()
|
scripts/sw-daemon.sh:1466 |
Pipeline lifecycle: sw_db_insert_pipeline_run at spawn, sw_db_update_pipeline_run at reap |
scripts/sw-memory.sh:190 |
sw_db_insert_memory in memory_capture_failure() and decision recording |
dashboard/server.ts:604 |
Import ShipwrightDB, replace 14+ file-scan functions with SQL, keep file fallback |
scripts/sw:~200 |
Add db) case to subcommand router |
package.json:32 |
Append && bash scripts/sw-db-test.sh to test script |
.claude/CLAUDE.md |
Add new files to tables, shipwright db to commands, DB path to runtime state |
-
sqlite3CLI — optional for bash (pre-installed on macOS,apt install sqlite3on Linux) -
bun:sqlite— built-in to Bun, zero additional npm dependencies
-
emit_event()hot path — adds ~5–10 ms persqlite3CLI invocation. Guarded by|| trueand cached availability check. Monitor stage durations post-rollout. -
WAL file growth — fleet mode with many daemons. Mitigated by short-lived queries and default
wal_autocheckpoint(~4 MB). -
Two runtimes, one DB — both must set
PRAGMA journal_mode=WALon every open. Inconsistent journal modes cause corruption. -
Test isolation — must use isolated
$HOME. Existing suites already mock this. -
Missing
sqlite3— all bash writes gated behindcommand -v sqlite3. Dashboard usesbun:sqlite. System degrades to file-only. - Large import — 50 MB events.jsonl takes 10–30 s. Batched transactions + progress output + idempotent.
-
sqlite3 ~/.shipwright/shipwright.db ".tables"shows all 8 tables aftershipwright db migrate -
schema_versiontracks migration 001 with timestamp and description -
emit_event "test.event" "key=val"writes to bothevents.jsonlANDeventstable -
cost_recordwrites to bothcosts.jsonANDcost_entriestable - Dashboard
/api/statusreturns identical DORA grades from SQLite vs file fallback - Dashboard falls back to file reads when
shipwright.dbis deleted mid-run -
shipwright db importpopulates from existing JSONL/JSON, idempotent on re-run -
shipwright db exportproduces valid JSONL from SQLite -
sw-db-test.shpasses: schema, CRUD, DORA, import, concurrent writes (5 writers), degradation - All 22 existing test suites pass (only
package.jsonchanges) - With
sqlite3removed from PATH:emit_eventstill writes JSONL, no errors on stderr -
PRAGMA journal_modereturnswalfrom both bash and TypeScript - No Bash 3.2 incompatibilities (no associative arrays,
readarray,${var,,},${var^^}) - Pipeline stage durations don't regress measurably (< 50 ms overhead per
emit_event)