opencastle 0.21.1 → 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/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/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
|
@@ -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 ─────────────────────────────────────────────────────────────
|
package/src/cli/convoy/store.ts
CHANGED
|
@@ -9,21 +9,21 @@ import type {
|
|
|
9
9
|
EventRecord,
|
|
10
10
|
} from './types.js'
|
|
11
11
|
|
|
12
|
-
const SCHEMA_VERSION =
|
|
12
|
+
const SCHEMA_VERSION = 3
|
|
13
13
|
|
|
14
14
|
export interface ConvoyStore {
|
|
15
|
-
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
|
|
15
|
+
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
|
|
16
16
|
getConvoy(id: string): ConvoyRecord | undefined
|
|
17
17
|
getLatestConvoy(): ConvoyRecord | undefined
|
|
18
18
|
updateConvoyStatus(
|
|
19
19
|
id: string,
|
|
20
20
|
status: ConvoyStatus,
|
|
21
|
-
extra?: { started_at?: string; finished_at?: string },
|
|
21
|
+
extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
|
|
22
22
|
): void
|
|
23
23
|
insertTask(
|
|
24
24
|
record: Omit<
|
|
25
25
|
TaskRecord,
|
|
26
|
-
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
|
|
26
|
+
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
|
|
27
27
|
>,
|
|
28
28
|
): void
|
|
29
29
|
getTask(id: string, convoyId: string): TaskRecord | undefined
|
|
@@ -33,7 +33,7 @@ export interface ConvoyStore {
|
|
|
33
33
|
convoyId: string,
|
|
34
34
|
status: ConvoyTaskStatus,
|
|
35
35
|
extra?: Partial<
|
|
36
|
-
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
|
|
36
|
+
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
|
|
37
37
|
>,
|
|
38
38
|
): void
|
|
39
39
|
getReadyTasks(convoyId: string): TaskRecord[]
|
|
@@ -61,8 +61,8 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
private initSchema(): void {
|
|
64
|
-
|
|
65
|
-
if (
|
|
64
|
+
let version = (this.db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
|
|
65
|
+
if (version === 0) {
|
|
66
66
|
this.db.exec(`
|
|
67
67
|
CREATE TABLE IF NOT EXISTS convoy (
|
|
68
68
|
id TEXT PRIMARY KEY,
|
|
@@ -72,8 +72,10 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
72
72
|
branch TEXT,
|
|
73
73
|
created_at TEXT NOT NULL,
|
|
74
74
|
started_at TEXT,
|
|
75
|
-
finished_at
|
|
76
|
-
spec_yaml
|
|
75
|
+
finished_at TEXT,
|
|
76
|
+
spec_yaml TEXT NOT NULL,
|
|
77
|
+
total_tokens INTEGER,
|
|
78
|
+
total_cost_usd TEXT
|
|
77
79
|
);
|
|
78
80
|
|
|
79
81
|
CREATE TABLE IF NOT EXISTS task (
|
|
@@ -92,10 +94,14 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
92
94
|
exit_code INTEGER,
|
|
93
95
|
started_at TEXT,
|
|
94
96
|
finished_at TEXT,
|
|
95
|
-
retries
|
|
96
|
-
max_retries
|
|
97
|
-
files
|
|
98
|
-
depends_on
|
|
97
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
99
|
+
files TEXT,
|
|
100
|
+
depends_on TEXT,
|
|
101
|
+
prompt_tokens INTEGER,
|
|
102
|
+
completion_tokens INTEGER,
|
|
103
|
+
total_tokens INTEGER,
|
|
104
|
+
cost_usd TEXT
|
|
99
105
|
);
|
|
100
106
|
|
|
101
107
|
CREATE TABLE IF NOT EXISTS worker (
|
|
@@ -122,14 +128,26 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
122
128
|
);
|
|
123
129
|
`)
|
|
124
130
|
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
131
|
+
version = SCHEMA_VERSION
|
|
125
132
|
}
|
|
126
|
-
if (
|
|
133
|
+
if (version === 1) {
|
|
127
134
|
this.db.exec('ALTER TABLE task ADD COLUMN adapter TEXT')
|
|
128
135
|
this.db.exec('PRAGMA user_version = 2')
|
|
136
|
+
version = 2
|
|
137
|
+
}
|
|
138
|
+
if (version === 2) {
|
|
139
|
+
this.db.exec('ALTER TABLE task ADD COLUMN prompt_tokens INTEGER')
|
|
140
|
+
this.db.exec('ALTER TABLE task ADD COLUMN completion_tokens INTEGER')
|
|
141
|
+
this.db.exec('ALTER TABLE task ADD COLUMN total_tokens INTEGER')
|
|
142
|
+
this.db.exec('ALTER TABLE task ADD COLUMN cost_usd TEXT')
|
|
143
|
+
this.db.exec('ALTER TABLE convoy ADD COLUMN total_tokens INTEGER')
|
|
144
|
+
this.db.exec('ALTER TABLE convoy ADD COLUMN total_cost_usd TEXT')
|
|
145
|
+
this.db.exec('PRAGMA user_version = 3')
|
|
146
|
+
version = 3
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
|
|
150
|
+
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
|
|
133
151
|
this.db
|
|
134
152
|
.prepare(
|
|
135
153
|
`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml)
|
|
@@ -153,10 +171,10 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
153
171
|
updateConvoyStatus(
|
|
154
172
|
id: string,
|
|
155
173
|
status: ConvoyStatus,
|
|
156
|
-
extra?: { started_at?: string; finished_at?: string },
|
|
174
|
+
extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
|
|
157
175
|
): void {
|
|
158
176
|
const sets = ['status = :status']
|
|
159
|
-
const params: Record<string, string | null> = { id, status }
|
|
177
|
+
const params: Record<string, string | number | null> = { id, status }
|
|
160
178
|
|
|
161
179
|
if (extra?.started_at !== undefined) {
|
|
162
180
|
sets.push('started_at = :started_at')
|
|
@@ -166,6 +184,14 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
166
184
|
sets.push('finished_at = :finished_at')
|
|
167
185
|
params.finished_at = extra.finished_at
|
|
168
186
|
}
|
|
187
|
+
if (extra?.total_tokens !== undefined) {
|
|
188
|
+
sets.push('total_tokens = :total_tokens')
|
|
189
|
+
params.total_tokens = extra.total_tokens
|
|
190
|
+
}
|
|
191
|
+
if (extra?.total_cost_usd !== undefined) {
|
|
192
|
+
sets.push('total_cost_usd = :total_cost_usd')
|
|
193
|
+
params.total_cost_usd = extra.total_cost_usd
|
|
194
|
+
}
|
|
169
195
|
|
|
170
196
|
this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
|
|
171
197
|
}
|
|
@@ -173,7 +199,7 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
173
199
|
insertTask(
|
|
174
200
|
record: Omit<
|
|
175
201
|
TaskRecord,
|
|
176
|
-
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
|
|
202
|
+
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
|
|
177
203
|
>,
|
|
178
204
|
): void {
|
|
179
205
|
this.db
|
|
@@ -207,12 +233,12 @@ class ConvoyStoreImpl implements ConvoyStore {
|
|
|
207
233
|
convoyId: string,
|
|
208
234
|
status: ConvoyTaskStatus,
|
|
209
235
|
extra?: Partial<
|
|
210
|
-
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
|
|
236
|
+
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
|
|
211
237
|
>,
|
|
212
238
|
): void {
|
|
213
239
|
const sets = ['status = :status']
|
|
214
240
|
const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
|
|
215
|
-
const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries'] as const
|
|
241
|
+
const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd'] as const
|
|
216
242
|
|
|
217
243
|
if (extra) {
|
|
218
244
|
for (const field of extraFields) {
|
package/src/cli/convoy/types.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ConvoyRecord {
|
|
|
21
21
|
started_at: string | null
|
|
22
22
|
finished_at: string | null
|
|
23
23
|
spec_yaml: string
|
|
24
|
+
total_tokens: number | null
|
|
25
|
+
total_cost_usd: string | null
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface TaskRecord {
|
|
@@ -43,6 +45,10 @@ export interface TaskRecord {
|
|
|
43
45
|
max_retries: number
|
|
44
46
|
files: string | null
|
|
45
47
|
depends_on: string | null
|
|
48
|
+
prompt_tokens: number | null
|
|
49
|
+
completion_tokens: number | null
|
|
50
|
+
total_tokens: number | null
|
|
51
|
+
cost_usd: string | null
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface WorkerRecord {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
|
|
2
|
+
import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
|
|
3
3
|
|
|
4
4
|
/** Adapter name */
|
|
5
5
|
export const name = 'claude-code'
|
|
@@ -60,10 +60,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
|
|
|
60
60
|
|
|
61
61
|
proc.on('close', (code) => {
|
|
62
62
|
const output = [stdout, stderr].filter(Boolean).join('\n')
|
|
63
|
+
let usage: TokenUsage | undefined
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>
|
|
66
|
+
const u = parsed?.usage as Record<string, number> | undefined
|
|
67
|
+
if (u) {
|
|
68
|
+
const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
|
|
69
|
+
const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
|
|
70
|
+
const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
|
|
71
|
+
usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
|
|
72
|
+
}
|
|
73
|
+
} catch { /* not JSON or no usage — graceful degradation */ }
|
|
63
74
|
resolve({
|
|
64
75
|
success: code === 0,
|
|
65
76
|
output: output.slice(0, 10000), // Cap output size
|
|
66
77
|
exitCode: code ?? -1,
|
|
78
|
+
usage,
|
|
67
79
|
})
|
|
68
80
|
})
|
|
69
81
|
|
|
@@ -100,11 +100,19 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
|
|
|
100
100
|
const timeoutMs = parseTimeout(task.timeout)
|
|
101
101
|
const response = await session.sendAndWait({ prompt }, timeoutMs)
|
|
102
102
|
const output = response?.data?.content ?? ''
|
|
103
|
+
const rawUsage = (response?.data as Record<string, unknown> | undefined)?.usage ?? (response as Record<string, unknown> | undefined)?.usage
|
|
104
|
+
const u = rawUsage as Record<string, number> | undefined
|
|
105
|
+
const usageResult = u ? {
|
|
106
|
+
prompt_tokens: u.prompt_tokens ?? u.promptTokens,
|
|
107
|
+
completion_tokens: u.completion_tokens ?? u.completionTokens,
|
|
108
|
+
total_tokens: u.total_tokens ?? u.totalTokens,
|
|
109
|
+
} : undefined
|
|
103
110
|
|
|
104
111
|
return {
|
|
105
112
|
success: true,
|
|
106
113
|
output: output.slice(0, 10_000),
|
|
107
114
|
exitCode: 0,
|
|
115
|
+
usage: usageResult,
|
|
108
116
|
}
|
|
109
117
|
} catch (err: unknown) {
|
|
110
118
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
|
|
2
|
+
import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
|
|
3
3
|
|
|
4
4
|
/** Adapter name */
|
|
5
5
|
export const name = 'cursor'
|
|
@@ -59,10 +59,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
|
|
|
59
59
|
|
|
60
60
|
proc.on('close', (code) => {
|
|
61
61
|
const output = [stdout, stderr].filter(Boolean).join('\n')
|
|
62
|
+
let usage: TokenUsage | undefined
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>
|
|
65
|
+
const u = parsed?.usage as Record<string, number> | undefined
|
|
66
|
+
if (u) {
|
|
67
|
+
const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
|
|
68
|
+
const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
|
|
69
|
+
const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
|
|
70
|
+
usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
|
|
71
|
+
}
|
|
72
|
+
} catch { /* not JSON or no usage — graceful degradation */ }
|
|
62
73
|
resolve({
|
|
63
74
|
success: code === 0,
|
|
64
75
|
output: output.slice(0, 10000), // Cap output size
|
|
65
76
|
exitCode: code ?? -1,
|
|
77
|
+
usage,
|
|
66
78
|
})
|
|
67
79
|
})
|
|
68
80
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
|
|
2
|
+
import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
|
|
3
3
|
|
|
4
4
|
/** Adapter name */
|
|
5
5
|
export const name = 'opencode'
|
|
@@ -53,10 +53,22 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
|
|
|
53
53
|
|
|
54
54
|
proc.on('close', (code) => {
|
|
55
55
|
const output = [stdout, stderr].filter(Boolean).join('\n')
|
|
56
|
+
let usage: TokenUsage | undefined
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>
|
|
59
|
+
const u = parsed?.usage as Record<string, number> | undefined
|
|
60
|
+
if (u) {
|
|
61
|
+
const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
|
|
62
|
+
const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
|
|
63
|
+
const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
|
|
64
|
+
usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
|
|
65
|
+
}
|
|
66
|
+
} catch { /* not JSON or no usage — graceful degradation */ }
|
|
56
67
|
resolve({
|
|
57
68
|
success: code === 0,
|
|
58
69
|
output: output.slice(0, 10000), // Cap output size
|
|
59
70
|
exitCode: code ?? -1,
|
|
71
|
+
usage,
|
|
60
72
|
})
|
|
61
73
|
})
|
|
62
74
|
|
package/src/cli/run.ts
CHANGED
|
@@ -8,6 +8,12 @@ import { createReporter, printExecutionPlan } from './run/reporter.js'
|
|
|
8
8
|
import type { CliContext, RunOptions } from './types.js'
|
|
9
9
|
import type { ConvoyResult } from './convoy/engine.js'
|
|
10
10
|
|
|
11
|
+
function formatTokens(n: number): string {
|
|
12
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
|
13
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
|
14
|
+
return String(n)
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
const HELP = `
|
|
12
18
|
opencastle run [options]
|
|
13
19
|
|
|
@@ -153,6 +159,9 @@ function printConvoyResult(result: ConvoyResult): void {
|
|
|
153
159
|
console.log(` ${g.passed ? '✓' : '✗'} ${g.command}`)
|
|
154
160
|
}
|
|
155
161
|
}
|
|
162
|
+
if (result.cost) {
|
|
163
|
+
console.log(` Tokens: ${formatTokens(result.cost.total_tokens)}`)
|
|
164
|
+
}
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
/**
|
|
@@ -199,6 +208,20 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
199
208
|
console.log(` ${status}: ${count}`)
|
|
200
209
|
}
|
|
201
210
|
console.log(` total: ${tasks.length}`)
|
|
211
|
+
const totalTokens = tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0)
|
|
212
|
+
if (tasks.some(t => t.total_tokens != null)) {
|
|
213
|
+
console.log(`\n Tokens: ${formatTokens(totalTokens)}`)
|
|
214
|
+
const tasksWithTokens = tasks.filter(t => t.total_tokens != null)
|
|
215
|
+
if (tasksWithTokens.length > 0) {
|
|
216
|
+
console.log(`\n Token usage by task:`)
|
|
217
|
+
for (const t of tasksWithTokens) {
|
|
218
|
+
const parts = [formatTokens(t.total_tokens!)]
|
|
219
|
+
if (t.prompt_tokens != null) parts.push(`in: ${formatTokens(t.prompt_tokens)}`)
|
|
220
|
+
if (t.completion_tokens != null) parts.push(`out: ${formatTokens(t.completion_tokens)}`)
|
|
221
|
+
console.log(` ${t.id}: ${parts.join(' | ')}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
202
225
|
} finally {
|
|
203
226
|
store.close()
|
|
204
227
|
}
|
package/src/cli/types.ts
CHANGED
|
@@ -238,6 +238,13 @@ export interface ExecuteOptions {
|
|
|
238
238
|
cwd?: string;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Token usage data from adapter execution. */
|
|
242
|
+
export interface TokenUsage {
|
|
243
|
+
prompt_tokens?: number;
|
|
244
|
+
completion_tokens?: number;
|
|
245
|
+
total_tokens?: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
241
248
|
/** Result from an agent adapter execution. */
|
|
242
249
|
export interface ExecuteResult {
|
|
243
250
|
success: boolean;
|
|
@@ -245,6 +252,8 @@ export interface ExecuteResult {
|
|
|
245
252
|
exitCode: number;
|
|
246
253
|
_timedOut?: boolean;
|
|
247
254
|
taskId?: string;
|
|
255
|
+
/** Token usage data if available from the adapter. */
|
|
256
|
+
usage?: TokenUsage;
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
/** Reporter interface for the run command. */
|
|
@@ -66,6 +66,12 @@ Export
|
|
|
66
66
|
return div.innerHTML;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function formatTokens(n) {
|
|
70
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
71
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
72
|
+
return String(n);
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
// ── SVG Icon Library (empty states) ────────────────────
|
|
70
76
|
|
|
71
77
|
const EMPTY_ICONS = {
|
|
@@ -1133,6 +1139,9 @@ Export
|
|
|
1133
1139
|
html += '<div class="convoy-stat"><span class="convoy-stat__label">Tasks</span><span class="convoy-stat__value">' + done + '/' + total + '</span></div>';
|
|
1134
1140
|
html += '<div class="convoy-stat"><span class="convoy-stat__label">Events</span><span class="convoy-stat__value">' + (convoy.events_count || 0) + '</span></div>';
|
|
1135
1141
|
html += '<div class="convoy-stat"><span class="convoy-stat__label">Started</span><span class="convoy-stat__value">' + (convoy.started_at ? formatTime(convoy.started_at) : '\u2014') + '</span></div>';
|
|
1142
|
+
if (convoy.total_tokens != null) {
|
|
1143
|
+
html += '<div class="convoy-stat"><span class="convoy-stat__label">Tokens</span><span class="convoy-stat__value">' + formatTokens(convoy.total_tokens) + '</span></div>';
|
|
1144
|
+
}
|
|
1136
1145
|
html += '</div>';
|
|
1137
1146
|
|
|
1138
1147
|
html += '<div class="convoy-progress">';
|
|
@@ -1142,7 +1151,7 @@ Export
|
|
|
1142
1151
|
|
|
1143
1152
|
if (convoy.tasks && convoy.tasks.length > 0) {
|
|
1144
1153
|
html += '<table class="sessions-table convoy-tasks">';
|
|
1145
|
-
html += '<thead><tr><th>Task</th><th>Phase</th><th>Agent</th><th>Adapter</th><th>Status</th><th>Retries</th></tr></thead>';
|
|
1154
|
+
html += '<thead><tr><th>Task</th><th>Phase</th><th>Agent</th><th>Adapter</th><th>Status</th><th>Retries</th><th>Tokens</th></tr></thead>';
|
|
1146
1155
|
html += '<tbody>';
|
|
1147
1156
|
convoy.tasks.forEach(function(t) {
|
|
1148
1157
|
const tStatus = t.status === 'done' ? 'success'
|
|
@@ -1155,6 +1164,7 @@ Export
|
|
|
1155
1164
|
html += '<td>' + escapeHtml(t.adapter || '\u2014') + '</td>';
|
|
1156
1165
|
html += '<td><span class="outcome-badge outcome-badge--' + tStatus + '">' + escapeHtml(t.status) + '</span></td>';
|
|
1157
1166
|
html += '<td class="td-num">' + (t.retries || 0) + '</td>';
|
|
1167
|
+
html += '<td class="td-num">' + (t.total_tokens != null ? formatTokens(t.total_tokens) : '\u2014') + '</td>';
|
|
1158
1168
|
html += '</tr>';
|
|
1159
1169
|
});
|
|
1160
1170
|
html += '</tbody></table>';
|