opencastle 0.21.0 → 0.22.0

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 (57) hide show
  1. package/dist/cli/convoy/engine.d.ts +3 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +22 -1
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +148 -0
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/convoy/export.d.ts.map +1 -1
  8. package/dist/cli/convoy/export.js +5 -0
  9. package/dist/cli/convoy/export.js.map +1 -1
  10. package/dist/cli/convoy/export.test.js +31 -0
  11. package/dist/cli/convoy/export.test.js.map +1 -1
  12. package/dist/cli/convoy/store.d.ts +5 -3
  13. package/dist/cli/convoy/store.d.ts.map +1 -1
  14. package/dist/cli/convoy/store.js +37 -11
  15. package/dist/cli/convoy/store.js.map +1 -1
  16. package/dist/cli/convoy/store.test.js +204 -4
  17. package/dist/cli/convoy/store.test.js.map +1 -1
  18. package/dist/cli/convoy/types.d.ts +6 -0
  19. package/dist/cli/convoy/types.d.ts.map +1 -1
  20. package/dist/cli/init.d.ts.map +1 -1
  21. package/dist/cli/init.js +25 -30
  22. package/dist/cli/init.js.map +1 -1
  23. package/dist/cli/run/adapters/claude-code.d.ts.map +1 -1
  24. package/dist/cli/run/adapters/claude-code.js +13 -0
  25. package/dist/cli/run/adapters/claude-code.js.map +1 -1
  26. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  27. package/dist/cli/run/adapters/copilot.js +8 -0
  28. package/dist/cli/run/adapters/copilot.js.map +1 -1
  29. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  30. package/dist/cli/run/adapters/cursor.js +13 -0
  31. package/dist/cli/run/adapters/cursor.js.map +1 -1
  32. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  33. package/dist/cli/run/adapters/opencode.js +13 -0
  34. package/dist/cli/run/adapters/opencode.js.map +1 -1
  35. package/dist/cli/run.d.ts.map +1 -1
  36. package/dist/cli/run.js +26 -0
  37. package/dist/cli/run.js.map +1 -1
  38. package/dist/cli/types.d.ts +8 -0
  39. package/dist/cli/types.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/convoy/engine.test.ts +177 -0
  42. package/src/cli/convoy/engine.ts +22 -1
  43. package/src/cli/convoy/export.test.ts +37 -0
  44. package/src/cli/convoy/export.ts +5 -0
  45. package/src/cli/convoy/store.test.ts +220 -4
  46. package/src/cli/convoy/store.ts +46 -20
  47. package/src/cli/convoy/types.ts +6 -0
  48. package/src/cli/init.ts +25 -30
  49. package/src/cli/run/adapters/claude-code.ts +13 -1
  50. package/src/cli/run/adapters/copilot.ts +8 -0
  51. package/src/cli/run/adapters/cursor.ts +13 -1
  52. package/src/cli/run/adapters/opencode.ts +13 -1
  53. package/src/cli/run.ts +23 -0
  54. package/src/cli/types.ts +9 -0
  55. package/src/dashboard/dist/index.html +11 -1
  56. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  57. package/src/dashboard/src/pages/index.astro +11 -1
@@ -1472,3 +1472,180 @@ describe('diamond dependency skip', () => {
1472
1472
  expect(byId['task-c']).toBe('skipped')
1473
1473
  })
1474
1474
  })
1475
+
1476
+ // ── 21. Cost tracking (usage propagation) ────────────────────────────────────
1477
+
1478
+ describe('cost tracking', () => {
1479
+ it('persists usage data to task record when adapter returns usage', async () => {
1480
+ const adapter = makeAdapter()
1481
+ adapter.execute.mockResolvedValue({
1482
+ success: true,
1483
+ output: 'ok',
1484
+ exitCode: 0,
1485
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
1486
+ } satisfies ExecuteResult)
1487
+
1488
+ const engine = createConvoyEngine({
1489
+ spec: makeSpec(),
1490
+ specYaml: 'name: test',
1491
+ adapter,
1492
+ dbPath,
1493
+ _worktreeManager: makeWorktreeManager(),
1494
+ _mergeQueue: makeMergeQueue(),
1495
+ })
1496
+
1497
+ const result = await engine.run()
1498
+ expect(result.status).toBe('done')
1499
+
1500
+ const store = createConvoyStore(dbPath)
1501
+ const tasks = store.getTasksByConvoy(result.convoyId)
1502
+ store.close()
1503
+ expect(tasks[0].prompt_tokens).toBe(100)
1504
+ expect(tasks[0].completion_tokens).toBe(50)
1505
+ expect(tasks[0].total_tokens).toBe(150)
1506
+ })
1507
+
1508
+ it('leaves cost fields null when adapter returns no usage', async () => {
1509
+ const adapter = makeAdapter()
1510
+ // default makeAdapter returns no usage field
1511
+
1512
+ const engine = createConvoyEngine({
1513
+ spec: makeSpec(),
1514
+ specYaml: 'name: test',
1515
+ adapter,
1516
+ dbPath,
1517
+ _worktreeManager: makeWorktreeManager(),
1518
+ _mergeQueue: makeMergeQueue(),
1519
+ })
1520
+
1521
+ const result = await engine.run()
1522
+
1523
+ const store = createConvoyStore(dbPath)
1524
+ const tasks = store.getTasksByConvoy(result.convoyId)
1525
+ store.close()
1526
+ expect(tasks[0].prompt_tokens).toBeNull()
1527
+ expect(tasks[0].completion_tokens).toBeNull()
1528
+ expect(tasks[0].total_tokens).toBeNull()
1529
+ })
1530
+
1531
+ it('aggregates total_tokens from multiple tasks to convoy record', async () => {
1532
+ const adapter = makeAdapter()
1533
+ adapter.execute
1534
+ .mockResolvedValueOnce({ success: true, output: 'ok', exitCode: 0, usage: { total_tokens: 100 } })
1535
+ .mockResolvedValueOnce({ success: true, output: 'ok', exitCode: 0, usage: { total_tokens: 200 } })
1536
+
1537
+ const spec = makeSpec({ concurrency: 2 }, [
1538
+ { id: 'task-1', depends_on: [] },
1539
+ { id: 'task-2', depends_on: [] },
1540
+ ])
1541
+ const engine = createConvoyEngine({
1542
+ spec,
1543
+ specYaml: 'name: test',
1544
+ adapter,
1545
+ dbPath,
1546
+ _worktreeManager: makeWorktreeManager(),
1547
+ _mergeQueue: makeMergeQueue(),
1548
+ })
1549
+
1550
+ const result = await engine.run()
1551
+
1552
+ const store = createConvoyStore(dbPath)
1553
+ const convoy = store.getConvoy(result.convoyId)
1554
+ store.close()
1555
+ expect(convoy!.total_tokens).toBe(300)
1556
+ })
1557
+
1558
+ it('includes cost in ConvoyResult when usage is available', async () => {
1559
+ const adapter = makeAdapter()
1560
+ adapter.execute.mockResolvedValue({
1561
+ success: true,
1562
+ output: 'ok',
1563
+ exitCode: 0,
1564
+ usage: { total_tokens: 75 },
1565
+ } satisfies ExecuteResult)
1566
+
1567
+ const engine = createConvoyEngine({
1568
+ spec: makeSpec(),
1569
+ specYaml: 'name: test',
1570
+ adapter,
1571
+ dbPath,
1572
+ _worktreeManager: makeWorktreeManager(),
1573
+ _mergeQueue: makeMergeQueue(),
1574
+ })
1575
+
1576
+ const result = await engine.run()
1577
+
1578
+ expect(result.cost).toEqual({ total_tokens: 75 })
1579
+ })
1580
+
1581
+ it('omits cost from ConvoyResult when no usage data is available', async () => {
1582
+ const adapter = makeAdapter()
1583
+ // default makeAdapter returns no usage
1584
+
1585
+ const engine = createConvoyEngine({
1586
+ spec: makeSpec(),
1587
+ specYaml: 'name: test',
1588
+ adapter,
1589
+ dbPath,
1590
+ _worktreeManager: makeWorktreeManager(),
1591
+ _mergeQueue: makeMergeQueue(),
1592
+ })
1593
+
1594
+ const result = await engine.run()
1595
+
1596
+ expect(result.cost).toBeUndefined()
1597
+ })
1598
+
1599
+ it('partial usage fields are persisted correctly (only total_tokens set)', async () => {
1600
+ const adapter = makeAdapter()
1601
+ adapter.execute.mockResolvedValue({
1602
+ success: true,
1603
+ output: 'ok',
1604
+ exitCode: 0,
1605
+ usage: { total_tokens: 42 },
1606
+ } satisfies ExecuteResult)
1607
+
1608
+ const engine = createConvoyEngine({
1609
+ spec: makeSpec(),
1610
+ specYaml: 'name: test',
1611
+ adapter,
1612
+ dbPath,
1613
+ _worktreeManager: makeWorktreeManager(),
1614
+ _mergeQueue: makeMergeQueue(),
1615
+ })
1616
+
1617
+ const result = await engine.run()
1618
+
1619
+ const store = createConvoyStore(dbPath)
1620
+ const tasks = store.getTasksByConvoy(result.convoyId)
1621
+ store.close()
1622
+ expect(tasks[0].total_tokens).toBe(42)
1623
+ expect(tasks[0].prompt_tokens).toBeNull()
1624
+ expect(tasks[0].completion_tokens).toBeNull()
1625
+ })
1626
+
1627
+ it('convoy total_tokens is null when no task has usage', async () => {
1628
+ const adapter = makeAdapter()
1629
+ // default adapter returns no usage
1630
+
1631
+ const engine = createConvoyEngine({
1632
+ spec: makeSpec({ concurrency: 2 }, [
1633
+ { id: 'task-1', depends_on: [] },
1634
+ { id: 'task-2', depends_on: [] },
1635
+ ]),
1636
+ specYaml: 'name: test',
1637
+ adapter,
1638
+ dbPath,
1639
+ _worktreeManager: makeWorktreeManager(),
1640
+ _mergeQueue: makeMergeQueue(),
1641
+ })
1642
+
1643
+ const result = await engine.run()
1644
+
1645
+ const store = createConvoyStore(dbPath)
1646
+ const convoy = store.getConvoy(result.convoyId)
1647
+ store.close()
1648
+ expect(convoy!.total_tokens).toBeNull()
1649
+ expect(result.cost).toBeUndefined()
1650
+ })
1651
+ })
@@ -37,6 +37,7 @@ export interface ConvoyResult {
37
37
  summary: { total: number; done: number; failed: number; skipped: number; timedOut: number }
38
38
  duration: string
39
39
  gateResults?: Array<{ command: string; exitCode: number; passed: boolean }>
40
+ cost?: { total_tokens: number }
40
41
  }
41
42
 
42
43
  export interface ConvoyEngine {
@@ -290,11 +291,19 @@ async function runConvoy(
290
291
  await removeWorktree()
291
292
  }
292
293
 
294
+ const usageExtra: Partial<{ prompt_tokens: number; completion_tokens: number; total_tokens: number }> = {}
295
+ if (result.usage) {
296
+ if (result.usage.prompt_tokens != null) usageExtra.prompt_tokens = result.usage.prompt_tokens
297
+ if (result.usage.completion_tokens != null) usageExtra.completion_tokens = result.usage.completion_tokens
298
+ if (result.usage.total_tokens != null) usageExtra.total_tokens = result.usage.total_tokens
299
+ }
300
+
293
301
  store.withTransaction(() => {
294
302
  store.updateTaskStatus(taskRecord.id, convoyId, 'done', {
295
303
  finished_at: finishedAt,
296
304
  output: result.output,
297
305
  exit_code: result.exitCode,
306
+ ...usageExtra,
298
307
  })
299
308
  store.updateWorkerStatus(workerId, 'done', { finished_at: finishedAt })
300
309
  })
@@ -398,7 +407,18 @@ async function runConvoy(
398
407
  ? 'failed'
399
408
  : 'done'
400
409
 
401
- store.updateConvoyStatus(convoyId, finalStatus, { finished_at: new Date().toISOString() })
410
+ // Aggregate token usage across completed tasks
411
+ let convoyTotalTokens: number | null = null
412
+ for (const t of allTasksFinal) {
413
+ if (t.total_tokens != null) {
414
+ convoyTotalTokens = (convoyTotalTokens ?? 0) + t.total_tokens
415
+ }
416
+ }
417
+
418
+ store.updateConvoyStatus(convoyId, finalStatus, {
419
+ finished_at: new Date().toISOString(),
420
+ total_tokens: convoyTotalTokens,
421
+ })
402
422
 
403
423
  return {
404
424
  convoyId,
@@ -406,6 +426,7 @@ async function runConvoy(
406
426
  summary,
407
427
  duration: formatDuration(Date.now() - startTime),
408
428
  gateResults: spec.gates && spec.gates.length > 0 ? gateResults : undefined,
429
+ cost: convoyTotalTokens != null ? { total_tokens: convoyTotalTokens } : undefined,
409
430
  }
410
431
  }
411
432
 
@@ -187,4 +187,41 @@ describe('exportConvoyToNdjson', () => {
187
187
  process.chdir(originalCwd)
188
188
  }
189
189
  })
190
+
191
+ it('includes cost fields (prompt_tokens, completion_tokens, total_tokens) per task when present', async () => {
192
+ insertConvoy('c1')
193
+ insertTask('t1', 'c1')
194
+ // Manually set token cost on the task record
195
+ store.updateTaskStatus('t1', 'c1', 'done', {
196
+ prompt_tokens: 200,
197
+ completion_tokens: 100,
198
+ total_tokens: 300,
199
+ })
200
+ // Set convoy-level totals
201
+ store.updateConvoyStatus('c1', 'done', { total_tokens: 300 })
202
+ const logsDir = join(tmpDir, 'logs')
203
+
204
+ await exportConvoyToNdjson(store, 'c1', logsDir)
205
+
206
+ const record = JSON.parse(readFileSync(join(logsDir, 'convoys.ndjson'), 'utf8').trim())
207
+ expect(record.tasks[0].prompt_tokens).toBe(200)
208
+ expect(record.tasks[0].completion_tokens).toBe(100)
209
+ expect(record.tasks[0].total_tokens).toBe(300)
210
+ expect(record.total_tokens).toBe(300)
211
+ })
212
+
213
+ it('cost fields are null when no usage data recorded', async () => {
214
+ insertConvoy('c1')
215
+ insertTask('t1', 'c1')
216
+ const logsDir = join(tmpDir, 'logs')
217
+
218
+ await exportConvoyToNdjson(store, 'c1', logsDir)
219
+
220
+ const record = JSON.parse(readFileSync(join(logsDir, 'convoys.ndjson'), 'utf8').trim())
221
+ expect(record.tasks[0].prompt_tokens).toBeNull()
222
+ expect(record.tasks[0].completion_tokens).toBeNull()
223
+ expect(record.tasks[0].total_tokens).toBeNull()
224
+ expect(record.total_tokens).toBeNull()
225
+ expect(record.total_cost_usd).toBeNull()
226
+ })
190
227
  })
@@ -39,8 +39,13 @@ export async function exportConvoyToNdjson(
39
39
  started_at: t.started_at,
40
40
  finished_at: t.finished_at,
41
41
  retries: t.retries,
42
+ prompt_tokens: t.prompt_tokens,
43
+ completion_tokens: t.completion_tokens,
44
+ total_tokens: t.total_tokens,
42
45
  })),
43
46
  events_count: eventsCount,
47
+ total_tokens: convoy.total_tokens,
48
+ total_cost_usd: convoy.total_cost_usd,
44
49
  }
45
50
 
46
51
  const dir = logsDir ?? resolve(process.cwd(), '.opencastle', 'logs')
@@ -84,11 +84,11 @@ describe('DB creation', () => {
84
84
  expect(row.journal_mode).toBe('wal')
85
85
  })
86
86
 
87
- it('sets schema version to 2', () => {
87
+ it('sets schema version to 3', () => {
88
88
  const db = new DatabaseSync(dbPath)
89
89
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
90
90
  db.close()
91
- expect(row.user_version).toBe(2)
91
+ expect(row.user_version).toBe(3)
92
92
  })
93
93
 
94
94
  it('creates all required tables', () => {
@@ -113,7 +113,7 @@ describe('DB creation', () => {
113
113
  store2.close()
114
114
  // Reassign so afterEach does not double-close
115
115
  store = createConvoyStore(dbPath)
116
- expect(row.user_version).toBe(2)
116
+ expect(row.user_version).toBe(3)
117
117
  })
118
118
  })
119
119
 
@@ -192,7 +192,180 @@ describe('schema migration', () => {
192
192
  verifyDb.close()
193
193
 
194
194
  expect(cols.map(c => c.name)).toContain('adapter')
195
- expect(version.user_version).toBe(2)
195
+ // v1 chains through v2→v3 in one init, so final version is 3
196
+ expect(version.user_version).toBe(3)
197
+ })
198
+
199
+ it('schema migration v2 to v3 adds cost columns', () => {
200
+ // Create a v2 database manually (has adapter column but no cost columns)
201
+ const v2DbPath = join(tmpDir, 'v2.db')
202
+ const rawDb = new DatabaseSync(v2DbPath)
203
+ rawDb.exec(`
204
+ CREATE TABLE convoy (
205
+ id TEXT PRIMARY KEY,
206
+ name TEXT NOT NULL,
207
+ spec_hash TEXT NOT NULL,
208
+ status TEXT NOT NULL DEFAULT 'pending',
209
+ branch TEXT,
210
+ created_at TEXT NOT NULL,
211
+ started_at TEXT,
212
+ finished_at TEXT,
213
+ spec_yaml TEXT NOT NULL
214
+ );
215
+ CREATE TABLE task (
216
+ id TEXT PRIMARY KEY,
217
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
218
+ phase INTEGER NOT NULL,
219
+ prompt TEXT NOT NULL,
220
+ agent TEXT NOT NULL DEFAULT 'developer',
221
+ adapter TEXT,
222
+ model TEXT,
223
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
224
+ status TEXT NOT NULL DEFAULT 'pending',
225
+ worker_id TEXT,
226
+ worktree TEXT,
227
+ output TEXT,
228
+ exit_code INTEGER,
229
+ started_at TEXT,
230
+ finished_at TEXT,
231
+ retries INTEGER NOT NULL DEFAULT 0,
232
+ max_retries INTEGER NOT NULL DEFAULT 1,
233
+ files TEXT,
234
+ depends_on TEXT
235
+ );
236
+ CREATE TABLE worker (
237
+ id TEXT PRIMARY KEY,
238
+ task_id TEXT REFERENCES task(id),
239
+ adapter TEXT NOT NULL,
240
+ pid INTEGER,
241
+ session_id TEXT,
242
+ status TEXT NOT NULL DEFAULT 'spawned',
243
+ worktree TEXT,
244
+ created_at TEXT NOT NULL,
245
+ finished_at TEXT,
246
+ last_heartbeat TEXT
247
+ );
248
+ CREATE TABLE event (
249
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
250
+ convoy_id TEXT REFERENCES convoy(id),
251
+ task_id TEXT,
252
+ worker_id TEXT,
253
+ type TEXT NOT NULL,
254
+ data TEXT,
255
+ created_at TEXT NOT NULL
256
+ );
257
+ `)
258
+ rawDb.exec('PRAGMA user_version = 2')
259
+ rawDb.close()
260
+
261
+ // Open with createConvoyStore — should apply the v2→v3 migration
262
+ const v2Store = createConvoyStore(v2DbPath)
263
+ v2Store.close()
264
+
265
+ // Verify cost columns were added
266
+ const verifyDb = new DatabaseSync(v2DbPath)
267
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
268
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
269
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
270
+ verifyDb.close()
271
+
272
+ const taskColNames = taskCols.map(c => c.name)
273
+ expect(taskColNames).toContain('prompt_tokens')
274
+ expect(taskColNames).toContain('completion_tokens')
275
+ expect(taskColNames).toContain('total_tokens')
276
+ expect(taskColNames).toContain('cost_usd')
277
+
278
+ const convoyColNames = convoyCols.map(c => c.name)
279
+ expect(convoyColNames).toContain('total_tokens')
280
+ expect(convoyColNames).toContain('total_cost_usd')
281
+
282
+ expect(version.user_version).toBe(3)
283
+ })
284
+
285
+ it('schema migration v1 to v3 chains correctly in a single init', () => {
286
+ // Create a v1 database (task table without adapter or cost columns)
287
+ const v1DbPath = join(tmpDir, 'v1-chain.db')
288
+ const rawDb = new DatabaseSync(v1DbPath)
289
+ rawDb.exec(`
290
+ CREATE TABLE convoy (
291
+ id TEXT PRIMARY KEY,
292
+ name TEXT NOT NULL,
293
+ spec_hash TEXT NOT NULL,
294
+ status TEXT NOT NULL DEFAULT 'pending',
295
+ branch TEXT,
296
+ created_at TEXT NOT NULL,
297
+ started_at TEXT,
298
+ finished_at TEXT,
299
+ spec_yaml TEXT NOT NULL
300
+ );
301
+ CREATE TABLE task (
302
+ id TEXT PRIMARY KEY,
303
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
304
+ phase INTEGER NOT NULL,
305
+ prompt TEXT NOT NULL,
306
+ agent TEXT NOT NULL DEFAULT 'developer',
307
+ model TEXT,
308
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
309
+ status TEXT NOT NULL DEFAULT 'pending',
310
+ worker_id TEXT,
311
+ worktree TEXT,
312
+ output TEXT,
313
+ exit_code INTEGER,
314
+ started_at TEXT,
315
+ finished_at TEXT,
316
+ retries INTEGER NOT NULL DEFAULT 0,
317
+ max_retries INTEGER NOT NULL DEFAULT 1,
318
+ files TEXT,
319
+ depends_on TEXT
320
+ );
321
+ CREATE TABLE worker (
322
+ id TEXT PRIMARY KEY,
323
+ task_id TEXT REFERENCES task(id),
324
+ adapter TEXT NOT NULL,
325
+ pid INTEGER,
326
+ session_id TEXT,
327
+ status TEXT NOT NULL DEFAULT 'spawned',
328
+ worktree TEXT,
329
+ created_at TEXT NOT NULL,
330
+ finished_at TEXT,
331
+ last_heartbeat TEXT
332
+ );
333
+ CREATE TABLE event (
334
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
335
+ convoy_id TEXT REFERENCES convoy(id),
336
+ task_id TEXT,
337
+ worker_id TEXT,
338
+ type TEXT NOT NULL,
339
+ data TEXT,
340
+ created_at TEXT NOT NULL
341
+ );
342
+ `)
343
+ rawDb.exec('PRAGMA user_version = 1')
344
+ rawDb.close()
345
+
346
+ // Open with createConvoyStore — should chain v1→v2→v3 in one init
347
+ const v1Store = createConvoyStore(v1DbPath)
348
+ v1Store.close()
349
+
350
+ // Verify all columns from both migrations are present
351
+ const verifyDb = new DatabaseSync(v1DbPath)
352
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
353
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
354
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
355
+ verifyDb.close()
356
+
357
+ const taskColNames = taskCols.map(c => c.name)
358
+ expect(taskColNames).toContain('adapter')
359
+ expect(taskColNames).toContain('prompt_tokens')
360
+ expect(taskColNames).toContain('completion_tokens')
361
+ expect(taskColNames).toContain('total_tokens')
362
+ expect(taskColNames).toContain('cost_usd')
363
+
364
+ const convoyColNames = convoyCols.map(c => c.name)
365
+ expect(convoyColNames).toContain('total_tokens')
366
+ expect(convoyColNames).toContain('total_cost_usd')
367
+
368
+ expect(version.user_version).toBe(3)
196
369
  })
197
370
  })
198
371
 
@@ -238,6 +411,25 @@ describe('convoy CRUD', () => {
238
411
  expect(retrieved.status).toBe('done')
239
412
  expect(retrieved.finished_at).toBe(ts)
240
413
  })
414
+
415
+ it('cost fields are null by default on a new convoy', () => {
416
+ store.insertConvoy(makeConvoy())
417
+ const retrieved = store.getConvoy('convoy-1')!
418
+ expect(retrieved.total_tokens).toBeNull()
419
+ expect(retrieved.total_cost_usd).toBeNull()
420
+ })
421
+
422
+ it('updateConvoyStatus persists total_tokens and total_cost_usd', () => {
423
+ store.insertConvoy(makeConvoy())
424
+ store.updateConvoyStatus('convoy-1', 'done', {
425
+ finished_at: '2026-01-01T01:00:00.000Z',
426
+ total_tokens: 5000,
427
+ total_cost_usd: '0.015000',
428
+ })
429
+ const retrieved = store.getConvoy('convoy-1')!
430
+ expect(retrieved.total_tokens).toBe(5000)
431
+ expect(retrieved.total_cost_usd).toBe('0.015000')
432
+ })
241
433
  })
242
434
 
243
435
  // ── task CRUD ─────────────────────────────────────────────────────────────────
@@ -311,6 +503,30 @@ describe('task CRUD', () => {
311
503
  expect(task.finished_at).toBe(ts)
312
504
  expect(task.retries).toBe(1)
313
505
  })
506
+
507
+ it('cost fields are null by default on a new task', () => {
508
+ store.insertTask(makeTask())
509
+ const task = store.getTask('task-1', 'convoy-1')!
510
+ expect(task.prompt_tokens).toBeNull()
511
+ expect(task.completion_tokens).toBeNull()
512
+ expect(task.total_tokens).toBeNull()
513
+ expect(task.cost_usd).toBeNull()
514
+ })
515
+
516
+ it('updateTaskStatus persists cost fields', () => {
517
+ store.insertTask(makeTask())
518
+ store.updateTaskStatus('task-1', 'convoy-1', 'done', {
519
+ prompt_tokens: 1200,
520
+ completion_tokens: 800,
521
+ total_tokens: 2000,
522
+ cost_usd: '0.006000',
523
+ })
524
+ const task = store.getTask('task-1', 'convoy-1')!
525
+ expect(task.prompt_tokens).toBe(1200)
526
+ expect(task.completion_tokens).toBe(800)
527
+ expect(task.total_tokens).toBe(2000)
528
+ expect(task.cost_usd).toBe('0.006000')
529
+ })
314
530
  })
315
531
 
316
532
  // ── getReadyTasks ─────────────────────────────────────────────────────────────