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.
- package/dist/cli/convoy/engine.d.ts +3 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +22 -1
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +148 -0
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/export.d.ts.map +1 -1
- package/dist/cli/convoy/export.js +5 -0
- package/dist/cli/convoy/export.js.map +1 -1
- package/dist/cli/convoy/export.test.js +31 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +5 -3
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +37 -11
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +204 -4
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +6 -0
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +25 -30
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/run/adapters/claude-code.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude-code.js +13 -0
- package/dist/cli/run/adapters/claude-code.js.map +1 -1
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +8 -0
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +13 -0
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +13 -0
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +26 -0
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +8 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/engine.test.ts +177 -0
- package/src/cli/convoy/engine.ts +22 -1
- package/src/cli/convoy/export.test.ts +37 -0
- package/src/cli/convoy/export.ts +5 -0
- package/src/cli/convoy/store.test.ts +220 -4
- package/src/cli/convoy/store.ts +46 -20
- package/src/cli/convoy/types.ts +6 -0
- package/src/cli/init.ts +25 -30
- package/src/cli/run/adapters/claude-code.ts +13 -1
- package/src/cli/run/adapters/copilot.ts +8 -0
- package/src/cli/run/adapters/cursor.ts +13 -1
- package/src/cli/run/adapters/opencode.ts +13 -1
- package/src/cli/run.ts +23 -0
- package/src/cli/types.ts +9 -0
- package/src/dashboard/dist/index.html +11 -1
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- 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
|
+
})
|
package/src/cli/convoy/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
})
|
package/src/cli/convoy/export.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 ─────────────────────────────────────────────────────────────
|