@doguyilmaz/konvoy 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +300 -0
  3. package/package.json +52 -0
  4. package/src/adapters/claude.ts +83 -0
  5. package/src/adapters/codex.ts +67 -0
  6. package/src/adapters/effort.ts +16 -0
  7. package/src/adapters/index.ts +20 -0
  8. package/src/adapters/kiro.ts +79 -0
  9. package/src/adapters/opencode.ts +64 -0
  10. package/src/adapters/types.ts +108 -0
  11. package/src/args.ts +42 -0
  12. package/src/chart.ts +91 -0
  13. package/src/cli.ts +146 -0
  14. package/src/commands/attach.ts +85 -0
  15. package/src/commands/config.ts +113 -0
  16. package/src/commands/dashboard.ts +26 -0
  17. package/src/commands/doctor.ts +104 -0
  18. package/src/commands/ls.ts +15 -0
  19. package/src/commands/new.ts +24 -0
  20. package/src/commands/resume.ts +14 -0
  21. package/src/commands/rm.ts +28 -0
  22. package/src/commands/roster.ts +37 -0
  23. package/src/commands/send.ts +79 -0
  24. package/src/commands/status.ts +35 -0
  25. package/src/commands/table.ts +75 -0
  26. package/src/commands/update.ts +72 -0
  27. package/src/commands/usage.ts +77 -0
  28. package/src/config/load.ts +335 -0
  29. package/src/config/schema.ts +100 -0
  30. package/src/core/children.ts +62 -0
  31. package/src/core/detect.ts +211 -0
  32. package/src/core/facts.ts +113 -0
  33. package/src/core/gate.ts +73 -0
  34. package/src/core/prelude.ts +121 -0
  35. package/src/core/session.ts +334 -0
  36. package/src/core/turn.ts +263 -0
  37. package/src/dashboard/page.ts +211 -0
  38. package/src/format.ts +98 -0
  39. package/src/paths.ts +33 -0
  40. package/src/pricing.ts +86 -0
  41. package/src/store/db.ts +78 -0
  42. package/src/store/queries.ts +434 -0
  43. package/src/types.ts +71 -0
@@ -0,0 +1,211 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import {
3
+ turnsPerDay,
4
+ turnsPerDayByAgent,
5
+ usageAcrossSessions,
6
+ usageByAgentModel,
7
+ usageForSession,
8
+ } from '../store/queries'
9
+ import type { Config } from '../config/schema'
10
+ import { estimateAgentUsd } from '../pricing'
11
+ import { spend } from '../format'
12
+
13
+ export interface DashboardData {
14
+ title: string
15
+ asOf: string
16
+ agents: {
17
+ agent: string
18
+ turns: number
19
+ inputTokens: number
20
+ outputTokens: number
21
+ spend: string
22
+ estimateUsd: number | null
23
+ }[]
24
+ days: { day: string; count: number }[]
25
+ byAgentDay: { agent: string; day: string; count: number }[]
26
+ totals: { turns: number; inputTokens: number; outputTokens: number }
27
+ }
28
+
29
+ // collect is a pure read of the same tables `konvoy usage --chart` reads, through the same
30
+ // queries — a parallel query here is exactly how the two views would start disagreeing
31
+ export function collect(db: Database, cfg: Config, sessionId?: string): DashboardData {
32
+ const rows = sessionId ? usageForSession(db, sessionId) : usageAcrossSessions(db)
33
+ const modelRows = usageByAgentModel(db, sessionId)
34
+
35
+ const agents = rows.map((r) => ({
36
+ agent: r.agent,
37
+ turns: r.turns,
38
+ inputTokens: r.inputTokens,
39
+ outputTokens: r.outputTokens,
40
+ spend: spend(r),
41
+ estimateUsd: estimateAgentUsd(r.agent, modelRows, cfg.pricing),
42
+ }))
43
+
44
+ const totals = rows.reduce(
45
+ (acc, r) => ({
46
+ turns: acc.turns + r.turns,
47
+ inputTokens: acc.inputTokens + r.inputTokens,
48
+ outputTokens: acc.outputTokens + r.outputTokens,
49
+ }),
50
+ { turns: 0, inputTokens: 0, outputTokens: 0 },
51
+ )
52
+
53
+ return {
54
+ title: '',
55
+ asOf: cfg.pricing.asOf,
56
+ agents,
57
+ days: turnsPerDay(db, sessionId),
58
+ byAgentDay: turnsPerDayByAgent(db, sessionId),
59
+ totals,
60
+ }
61
+ }
62
+
63
+ function escape(text: string): string {
64
+ return text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
65
+ }
66
+
67
+ // every day between the first and last, inclusive — gaps filled with zero downstream — so
68
+ // bars line up between agents the same way the terminal's sparklines line up columns
69
+ function expandDays(start: string, end: string): string[] {
70
+ const days: string[] = []
71
+ for (
72
+ const cursor = new Date(`${start}T00:00:00Z`);
73
+ cursor <= new Date(`${end}T00:00:00Z`);
74
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
75
+ ) {
76
+ days.push(cursor.toISOString().slice(0, 10))
77
+ }
78
+ return days
79
+ }
80
+
81
+ function usdCell(a: DashboardData['agents'][number]): string {
82
+ if (a.estimateUsd === null) return '-'
83
+ return a.spend.startsWith('$') ? `$${a.estimateUsd.toFixed(2)}` : `$${a.estimateUsd.toFixed(3)}`
84
+ }
85
+
86
+ function totalsBarsSvg(days: { day: string; count: number }[]): string {
87
+ if (days.length === 0) return '<p class="empty">no turns recorded yet</p>'
88
+ const range = expandDays(days[0]!.day, days[days.length - 1]!.day)
89
+ const counts = new Map(days.map((d) => [d.day, d.count]))
90
+ const values = range.map((d) => counts.get(d) ?? 0)
91
+ const max = Math.max(...values, 1)
92
+
93
+ const barW = 14
94
+ const gap = 4
95
+ const height = 84
96
+ const floor = height - 14
97
+ const width = range.length * (barW + gap)
98
+
99
+ const bars = values
100
+ .map((v, i) => {
101
+ const h = v > 0 ? Math.max(1, Math.round((v / max) * (floor - 4))) : 0
102
+ const x = i * (barW + gap)
103
+ const y = floor - h
104
+ return `<rect x="${x}" y="${y}" width="${barW}" height="${h}" rx="2"></rect><text x="${x + barW / 2}" y="${height - 2}" text-anchor="middle">${escape(range[i]!.slice(5))}</text>`
105
+ })
106
+ .join('')
107
+
108
+ return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="turns per day">${bars}</svg>`
109
+ }
110
+
111
+ function agentBarsSvg(rows: { agent: string; day: string; count: number }[]): string {
112
+ if (rows.length === 0) return '<p class="empty">no turns recorded yet</p>'
113
+
114
+ const sortedDays = [...new Set(rows.map((r) => r.day))].sort()
115
+ const range = expandDays(sortedDays[0]!, sortedDays[sortedDays.length - 1]!)
116
+
117
+ const byAgent = new Map<string, Map<string, number>>()
118
+ for (const r of rows) {
119
+ if (!byAgent.has(r.agent)) byAgent.set(r.agent, new Map())
120
+ byAgent.get(r.agent)!.set(r.day, r.count)
121
+ }
122
+
123
+ const series = [...byAgent.entries()]
124
+ .sort(([a], [b]) => a.localeCompare(b))
125
+ .map(([agent, dayCounts]) => ({ agent, values: range.map((d) => dayCounts.get(d) ?? 0) }))
126
+
127
+ // one shared max across every agent — a per-row scale would draw a quiet agent's two
128
+ // turns at a busy agent's height, hiding exactly the comparison this chart is for
129
+ const sharedMax = Math.max(...series.flatMap((s) => s.values), 1)
130
+
131
+ const barW = 10
132
+ const gap = 3
133
+ const rowHeight = 34
134
+ const width = range.length * (barW + gap)
135
+
136
+ const rowsHtml = series
137
+ .map(({ agent, values }) => {
138
+ const bars = values
139
+ .map((v, i) => {
140
+ const h = v > 0 ? Math.max(1, Math.round((v / sharedMax) * (rowHeight - 2))) : 0
141
+ const x = i * (barW + gap)
142
+ const y = rowHeight - h
143
+ return `<rect x="${x}" y="${y}" width="${barW}" height="${h}"></rect>`
144
+ })
145
+ .join('')
146
+ return `<div class="agent-row" data-agent="${escape(agent)}"><span class="agent-label">${escape(agent)}</span><svg viewBox="0 0 ${width} ${rowHeight}" width="${width}" height="${rowHeight}" role="img" aria-label="turns per day for ${escape(agent)}">${bars}</svg></div>`
147
+ })
148
+ .join('')
149
+
150
+ return `<div class="agent-bars">${rowsHtml}</div>`
151
+ }
152
+
153
+ const STYLE = `
154
+ :root { color-scheme: light dark; }
155
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 2rem; max-width: 860px; }
156
+ h1 { font-size: 1.25rem; margin-bottom: 0.25rem; }
157
+ .totals { color: #666; margin-top: 0; }
158
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
159
+ th, td { text-align: left; padding: 0.35rem 0.75rem 0.35rem 0; border-bottom: 1px solid #8883; }
160
+ h2 { font-size: 1rem; margin-top: 2rem; }
161
+ svg rect { fill: currentColor; opacity: 0.75; }
162
+ svg text { font-size: 8px; fill: currentColor; opacity: 0.6; }
163
+ .agent-row { display: flex; align-items: center; gap: 0.75rem; margin: 0.4rem 0; }
164
+ .agent-label { width: 5rem; flex: none; }
165
+ .empty, .note { color: #888; }
166
+ `
167
+
168
+ export function renderPage(data: DashboardData): string {
169
+ const title = escape(data.title)
170
+
171
+ const agentRows = data.agents
172
+ .map(
173
+ (a) => `<tr>
174
+ <td>${escape(a.agent)}</td>
175
+ <td>${a.turns}</td>
176
+ <td>${a.inputTokens}</td>
177
+ <td>${a.outputTokens}</td>
178
+ <td>${escape(a.spend)}</td>
179
+ <td>${usdCell(a)}</td>
180
+ </tr>`,
181
+ )
182
+ .join('')
183
+
184
+ const anyPriced = data.agents.some((a) => a.estimateUsd !== null)
185
+
186
+ return `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="utf-8">
190
+ <meta name="viewport" content="width=device-width, initial-scale=1">
191
+ <title>konvoy dashboard — ${title}</title>
192
+ <style>${STYLE}</style>
193
+ </head>
194
+ <body>
195
+ <h1>${title}</h1>
196
+ <p class="totals">${data.totals.turns} turns · ${data.totals.inputTokens} in · ${data.totals.outputTokens} out</p>
197
+
198
+ <table>
199
+ <thead><tr><th>AGENT</th><th>TURNS</th><th>IN</th><th>OUT</th><th>SPEND</th><th>~USD</th></tr></thead>
200
+ <tbody>${agentRows}</tbody>
201
+ </table>
202
+ ${anyPriced ? `<p class="note">~USD is estimated from rates configured as of ${escape(data.asOf || 'an unspecified date')}</p>` : ''}
203
+
204
+ <h2>turns per day</h2>
205
+ ${totalsBarsSvg(data.days)}
206
+
207
+ <h2>turns per day by agent</h2>
208
+ ${agentBarsSvg(data.byAgentDay)}
209
+ </body>
210
+ </html>`
211
+ }
package/src/format.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { AgentId } from './types'
2
+ import type { UsageRow } from './store/queries'
3
+ import { estimateAgentUsd, isPricingConfigured, type ModelUsage, type Pricing } from './pricing'
4
+
5
+ export interface RosterRow {
6
+ agent: AgentId
7
+ status: string
8
+ model: string
9
+ effort: string
10
+ foreignId: string | null
11
+ turns: number
12
+ costUsd: number
13
+ credits: number
14
+ }
15
+
16
+ function table(header: string[], rows: string[][]): string {
17
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)))
18
+ const line = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(' ').trimEnd()
19
+ return [line(header), ...rows.map(line)].join('\n') + '\n'
20
+ }
21
+
22
+ export function formatRoster(rows: RosterRow[]): string {
23
+ return table(
24
+ ['AGENT', 'STATUS', 'MODEL', 'EFFORT', 'SESSION', 'TURNS', 'COST'],
25
+ rows.map((r) => [r.agent, r.status, r.model || '-', r.effort, r.foreignId ?? '-', String(r.turns), cost(r)]),
26
+ )
27
+ }
28
+
29
+ function cost(r: RosterRow): string {
30
+ if (r.costUsd > 0) return `$${r.costUsd.toFixed(2)}`
31
+ if (r.credits > 0) return `${r.credits.toFixed(3)} cr`
32
+ return '-'
33
+ }
34
+
35
+ // exported so the dashboard renders the same string the terminal does, rather than
36
+ // reimplementing the credits-before-dollars rule and risking the two drifting apart
37
+ export function spend(row: UsageRow): string {
38
+ // credits win when both are non-zero: they're what the agent actually charged, and the
39
+ // cost estimator derives its dollar figure from credits the same way — the two must
40
+ // never disagree about which number is the real one for a given row.
41
+ if (row.credits > 0) return `${row.credits.toFixed(3)} cr`
42
+ if (row.costUsd > 0) return `$${row.costUsd.toFixed(2)}`
43
+ return '-'
44
+ }
45
+
46
+ function usdCell(row: UsageRow, modelRows: ModelUsage[], pricing: Pricing): string {
47
+ const est = estimateAgentUsd(row.agent, modelRows, pricing)
48
+ if (est === null) return '-'
49
+ // an already-real dollar figure keeps money's usual 2 decimals; a credit- or token-derived
50
+ // estimate gets the extra precision those small figures are shown with, or it rounds to nothing
51
+ return row.costUsd > 0 ? `$${est.toFixed(2)}` : `$${est.toFixed(3)}`
52
+ }
53
+
54
+ export function formatUsage(rows: UsageRow[], pricing?: Pricing, modelRows: ModelUsage[] = []): string {
55
+ const showUsd = pricing !== undefined && isPricingConfigured(pricing)
56
+ const header = ['AGENT', 'TURNS', 'IN', 'OUT', 'SPEND', ...(showUsd ? ['~USD'] : []), 'GATE']
57
+ return table(
58
+ header,
59
+ rows.map((r) => [
60
+ r.agent,
61
+ String(r.turns),
62
+ String(r.inputTokens),
63
+ String(r.outputTokens),
64
+ spend(r),
65
+ ...(showUsd ? [usdCell(r, modelRows, pricing!)] : []),
66
+ r.gateKnown > 0 ? `${r.gatePassed}/${r.gateKnown}` : '-',
67
+ ]),
68
+ )
69
+ }
70
+
71
+ export function duplicateModels(rows: RosterRow[]): string[] {
72
+ const counts = new Map<string, number>()
73
+ for (const r of rows) {
74
+ if (!r.model) continue
75
+ counts.set(r.model, (counts.get(r.model) ?? 0) + 1)
76
+ }
77
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([model]) => model)
78
+ }
79
+
80
+ export interface AgentStatusRow {
81
+ agent: AgentId
82
+ installed: boolean
83
+ version: string | null
84
+ authed: boolean | null
85
+ detail?: string
86
+ }
87
+
88
+ export function formatVersions(rows: AgentStatusRow[]): string {
89
+ return table(
90
+ ['AGENT', 'VERSION', 'AUTH', 'DETAIL'],
91
+ rows.map((r) => [
92
+ r.agent,
93
+ r.installed ? (r.version ?? 'unknown') : 'not installed',
94
+ r.authed === null ? 'unknown' : r.authed ? 'ok' : 'login required',
95
+ r.detail ?? '',
96
+ ]),
97
+ )
98
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,33 @@
1
+ export function join(...parts: string[]): string {
2
+ const absolute = parts[0]?.startsWith('/') ?? false
3
+ const segments: string[] = []
4
+ for (const part of parts) {
5
+ for (const segment of part.split('/')) {
6
+ if (segment === '' || segment === '.') continue
7
+ if (segment === '..') {
8
+ segments.pop()
9
+ continue
10
+ }
11
+ segments.push(segment)
12
+ }
13
+ }
14
+ return (absolute ? '/' : '') + segments.join('/')
15
+ }
16
+
17
+ export function dirname(p: string): string {
18
+ const cut = p.lastIndexOf('/')
19
+ if (cut < 0) return '.'
20
+ if (cut === 0) return '/'
21
+ return p.slice(0, cut)
22
+ }
23
+
24
+ export function home(): string {
25
+ const h = Bun.env.HOME
26
+ if (!h) throw new Error('HOME is not set')
27
+ return h
28
+ }
29
+
30
+ export const configDir = (): string => join(home(), '.config', 'konvoy')
31
+ const dataDir = (): string => join(home(), '.local', 'share', 'konvoy')
32
+ export const dbPath = (): string => join(dataDir(), 'konvoy.db')
33
+ export const sessionDir = (cwd: string, slug: string): string => join(cwd, '.konvoy', slug)
package/src/pricing.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { AgentId } from './types'
2
+ import type { Config } from './config/schema'
3
+
4
+ export type Pricing = Config['pricing']
5
+
6
+ export interface Priced {
7
+ agent: AgentId
8
+ model: string | null
9
+ inputTokens: number
10
+ outputTokens: number
11
+ credits: number
12
+ }
13
+
14
+ export function estimateUsd(row: Priced, pricing: Pricing): number | null {
15
+ // An agent bills one way or the other, never both. Kiro reports token counts alongside the
16
+ // credits it actually charges, so pricing its model as well would double the row.
17
+ if (row.credits > 0) {
18
+ const rate = pricing.credits[row.agent]
19
+ return rate ? row.credits * rate.usdPerCredit : null
20
+ }
21
+
22
+ if (row.model) {
23
+ const rate = pricing.models[row.model]
24
+ if (rate) {
25
+ // inputTokens is the whole context sent, cache reads included, and those bill at a
26
+ // tenth of this rate — so a cache-heavy turn estimates high. Only turns whose CLI
27
+ // reported no cost of its own reach here, which today is never claude's.
28
+ return (row.inputTokens / 1_000_000) * rate.inputPerMTok +
29
+ (row.outputTokens / 1_000_000) * rate.outputPerMTok
30
+ }
31
+ }
32
+
33
+ return null
34
+ }
35
+
36
+ // pricing defaults to empty, so an unconfigured user must never see a ~USD column of dashes —
37
+ // the column exists only once there's at least one rate to estimate from
38
+ export function isPricingConfigured(pricing: Pricing): boolean {
39
+ return Object.keys(pricing.models).length > 0 || Object.keys(pricing.credits).length > 0
40
+ }
41
+
42
+ export interface ModelUsage {
43
+ agent: AgentId
44
+ model: string | null
45
+ inputTokens: number
46
+ outputTokens: number
47
+ costUsd: number
48
+ credits: number
49
+ }
50
+
51
+ // Summed per model, never priced once on the aggregate: a session that ran half its turns on
52
+ // an expensive model and half on a cheap one must not be priced as though it used either one
53
+ // throughout — that's the whole reason `model` lives on the turn instead of the binding.
54
+ export function estimateAgentUsd(agent: AgentId, rows: ModelUsage[], pricing: Pricing): number | null {
55
+ const mine = rows.filter((r) => r.agent === agent)
56
+ // An agent bills one way or the other. Kiro charges credits and reports token counts beside
57
+ // them for information; codex and opencode report only tokens. Deciding per agent rather than
58
+ // per row is what keeps a kiro turn that charged no credits from reading as unpriceable, and
59
+ // a codex turn with real tokens and no model from reading as free.
60
+ const billsInCredits = mine.some((r) => r.credits > 0)
61
+
62
+ let total = 0
63
+ for (const row of mine) {
64
+ if (row.costUsd > 0) {
65
+ total += row.costUsd
66
+ continue
67
+ }
68
+
69
+ if (billsInCredits) {
70
+ if (row.credits === 0) continue
71
+ const rate = pricing.credits[agent]
72
+ if (!rate) return null
73
+ total += row.credits * rate.usdPerCredit
74
+ continue
75
+ }
76
+
77
+ if (row.inputTokens === 0 && row.outputTokens === 0) continue
78
+ const est = estimateUsd(
79
+ { agent, model: row.model, inputTokens: row.inputTokens, outputTokens: row.outputTokens, credits: row.credits },
80
+ pricing,
81
+ )
82
+ if (est === null) return null
83
+ total += est
84
+ }
85
+ return total
86
+ }
@@ -0,0 +1,78 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { dirname } from '../paths'
3
+
4
+ const MIGRATIONS: string[] = [
5
+ `CREATE TABLE session (
6
+ id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, goal TEXT NOT NULL,
7
+ cwd TEXT NOT NULL, lead TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
8
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
9
+ updated_seq INTEGER NOT NULL DEFAULT 0);
10
+ CREATE TABLE binding (
11
+ session_id TEXT NOT NULL, agent TEXT NOT NULL, foreign_id TEXT,
12
+ model TEXT, effort TEXT NOT NULL, permission TEXT NOT NULL,
13
+ status TEXT NOT NULL, turns INTEGER NOT NULL DEFAULT 0,
14
+ cost_usd REAL NOT NULL DEFAULT 0, credits REAL NOT NULL DEFAULT 0, last_seen INTEGER,
15
+ PRIMARY KEY (session_id, agent));
16
+ CREATE TABLE turn (
17
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, agent TEXT NOT NULL,
18
+ prompt TEXT NOT NULL, final TEXT NOT NULL, cost_usd REAL NOT NULL DEFAULT 0,
19
+ credits REAL NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0,
20
+ output_tokens INTEGER NOT NULL DEFAULT 0, kind TEXT, gate_passed INTEGER,
21
+ exit_code INTEGER NOT NULL, error TEXT, error_kind TEXT,
22
+ started_at INTEGER NOT NULL, ended_at INTEGER NOT NULL);
23
+ CREATE INDEX turn_kind_agent ON turn(kind, agent);
24
+ CREATE TABLE lock (
25
+ session_id TEXT PRIMARY KEY, owner TEXT NOT NULL, pid INTEGER NOT NULL,
26
+ acquired_at INTEGER NOT NULL);
27
+ CREATE TABLE event (
28
+ turn_id TEXT NOT NULL, seq INTEGER NOT NULL, type TEXT NOT NULL,
29
+ payload TEXT NOT NULL, ts INTEGER NOT NULL, PRIMARY KEY (turn_id, seq));`,
30
+ `ALTER TABLE turn ADD COLUMN parent_turn_id TEXT;
31
+ ALTER TABLE turn ADD COLUMN model TEXT;`,
32
+ `CREATE INDEX turn_session_id ON turn(session_id);
33
+ CREATE INDEX session_cwd_status ON session(cwd, status);`,
34
+ ]
35
+
36
+ export function openDb(path: string): Database {
37
+ if (path !== ':memory:') {
38
+ const dir = dirname(path)
39
+ Bun.spawnSync(['mkdir', '-p', dir])
40
+ }
41
+ let db: Database
42
+ try {
43
+ db = new Database(path, { create: true, strict: true })
44
+ if (path !== ':memory:') {
45
+ // konvoy runs concurrently by design — a nested konvoy inside an agent, a dashboard beside
46
+ // a send. Set before anything that takes a lock: switching a fresh file to WAL needs an
47
+ // exclusive one, and without the timeout the losers of that first statement throw
48
+ db.exec('PRAGMA busy_timeout = 5000')
49
+ // the journal-mode switch needs an exclusive lock and SQLite does not run the busy handler
50
+ // for it, so with several openers racing the losers get SQLITE_BUSY on this one statement.
51
+ // The mode is persistent in the file: the winner's switch holds for everyone, a loser moves on.
52
+ try {
53
+ db.exec('PRAGMA journal_mode = WAL')
54
+ } catch {
55
+ // another opener is switching it right now
56
+ }
57
+ }
58
+ // one transaction for every pending migration: a loser of the open race re-reads
59
+ // user_version under the write lock and finds nothing left to do, and a process that dies
60
+ // mid-migration leaves no half-applied schema behind
61
+ db.exec('BEGIN IMMEDIATE')
62
+ try {
63
+ const current = (db.query('PRAGMA user_version').get() as { user_version: number }).user_version
64
+ for (let v = current; v < MIGRATIONS.length; v++) {
65
+ db.exec(MIGRATIONS[v]!)
66
+ db.exec(`PRAGMA user_version = ${v + 1}`)
67
+ }
68
+ db.exec('COMMIT')
69
+ } catch (error) {
70
+ db.exec('ROLLBACK')
71
+ throw error
72
+ }
73
+ } catch (error) {
74
+ const message = error instanceof Error ? error.message : String(error)
75
+ throw new Error(`cannot open konvoy database at ${path}: ${message}`)
76
+ }
77
+ return db
78
+ }