opencastle 0.21.1 → 0.23.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 (79) hide show
  1. package/dist/cli/convoy/engine.d.ts +4 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +23 -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 +1 -0
  8. package/dist/cli/convoy/export.d.ts.map +1 -1
  9. package/dist/cli/convoy/export.js +39 -0
  10. package/dist/cli/convoy/export.js.map +1 -1
  11. package/dist/cli/convoy/export.test.js +31 -0
  12. package/dist/cli/convoy/export.test.js.map +1 -1
  13. package/dist/cli/convoy/pipeline.d.ts +35 -0
  14. package/dist/cli/convoy/pipeline.d.ts.map +1 -0
  15. package/dist/cli/convoy/pipeline.js +353 -0
  16. package/dist/cli/convoy/pipeline.js.map +1 -0
  17. package/dist/cli/convoy/pipeline.test.d.ts +2 -0
  18. package/dist/cli/convoy/pipeline.test.d.ts.map +1 -0
  19. package/dist/cli/convoy/pipeline.test.js +778 -0
  20. package/dist/cli/convoy/pipeline.test.js.map +1 -0
  21. package/dist/cli/convoy/store.d.ts +18 -4
  22. package/dist/cli/convoy/store.d.ts.map +1 -1
  23. package/dist/cli/convoy/store.js +119 -14
  24. package/dist/cli/convoy/store.js.map +1 -1
  25. package/dist/cli/convoy/store.test.js +413 -4
  26. package/dist/cli/convoy/store.test.js.map +1 -1
  27. package/dist/cli/convoy/types.d.ts +21 -0
  28. package/dist/cli/convoy/types.d.ts.map +1 -1
  29. package/dist/cli/dashboard.d.ts.map +1 -1
  30. package/dist/cli/dashboard.js +1 -0
  31. package/dist/cli/dashboard.js.map +1 -1
  32. package/dist/cli/run/adapters/claude-code.d.ts.map +1 -1
  33. package/dist/cli/run/adapters/claude-code.js +13 -0
  34. package/dist/cli/run/adapters/claude-code.js.map +1 -1
  35. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  36. package/dist/cli/run/adapters/copilot.js +8 -0
  37. package/dist/cli/run/adapters/copilot.js.map +1 -1
  38. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  39. package/dist/cli/run/adapters/cursor.js +13 -0
  40. package/dist/cli/run/adapters/cursor.js.map +1 -1
  41. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  42. package/dist/cli/run/adapters/opencode.js +13 -0
  43. package/dist/cli/run/adapters/opencode.js.map +1 -1
  44. package/dist/cli/run/schema.d.ts +5 -1
  45. package/dist/cli/run/schema.d.ts.map +1 -1
  46. package/dist/cli/run/schema.js +41 -8
  47. package/dist/cli/run/schema.js.map +1 -1
  48. package/dist/cli/run/schema.test.js +194 -5
  49. package/dist/cli/run/schema.test.js.map +1 -1
  50. package/dist/cli/run.d.ts.map +1 -1
  51. package/dist/cli/run.js +167 -2
  52. package/dist/cli/run.js.map +1 -1
  53. package/dist/cli/types.d.ts +11 -1
  54. package/dist/cli/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli/convoy/engine.test.ts +177 -0
  57. package/src/cli/convoy/engine.ts +24 -1
  58. package/src/cli/convoy/export.test.ts +37 -0
  59. package/src/cli/convoy/export.ts +46 -0
  60. package/src/cli/convoy/pipeline.test.ts +939 -0
  61. package/src/cli/convoy/pipeline.ts +430 -0
  62. package/src/cli/convoy/store.test.ts +452 -4
  63. package/src/cli/convoy/store.ts +152 -23
  64. package/src/cli/convoy/types.ts +23 -0
  65. package/src/cli/dashboard.ts +1 -0
  66. package/src/cli/run/adapters/claude-code.ts +13 -1
  67. package/src/cli/run/adapters/copilot.ts +8 -0
  68. package/src/cli/run/adapters/cursor.ts +13 -1
  69. package/src/cli/run/adapters/opencode.ts +13 -1
  70. package/src/cli/run/schema.test.ts +244 -5
  71. package/src/cli/run/schema.ts +49 -8
  72. package/src/cli/run.ts +163 -2
  73. package/src/cli/types.ts +12 -1
  74. package/src/dashboard/dist/_astro/{index.DyyaCW8L.css → index.Cq68OHaZ.css} +1 -1
  75. package/src/dashboard/dist/index.html +225 -3
  76. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  77. package/src/dashboard/src/pages/index.astro +241 -2
  78. package/src/dashboard/src/styles/dashboard.css +116 -0
  79. package/src/orchestrator/customizations/KNOWN-ISSUES.md +1 -1
@@ -32,6 +32,7 @@ function makeConvoy(overrides: Partial<Parameters<ConvoyStore['insertConvoy']>[0
32
32
  branch: null,
33
33
  created_at: new Date().toISOString(),
34
34
  spec_yaml: 'name: test',
35
+ pipeline_id: null,
35
36
  ...overrides,
36
37
  }
37
38
  }
@@ -69,6 +70,19 @@ function makeWorker(overrides: Partial<Parameters<ConvoyStore['insertWorker']>[0
69
70
  }
70
71
  }
71
72
 
73
+ function makePipeline(overrides: Partial<Parameters<ConvoyStore['insertPipeline']>[0]> = {}) {
74
+ return {
75
+ id: 'pipeline-1',
76
+ name: 'Test Pipeline',
77
+ status: 'pending' as const,
78
+ branch: null,
79
+ spec_yaml: 'name: test-pipeline\nversion: 2',
80
+ convoy_specs: JSON.stringify(['convoys/step1.yml', 'convoys/step2.yml']),
81
+ created_at: new Date().toISOString(),
82
+ ...overrides,
83
+ }
84
+ }
85
+
72
86
  // ── DB creation and WAL mode ──────────────────────────────────────────────────
73
87
 
74
88
  describe('DB creation', () => {
@@ -84,11 +98,11 @@ describe('DB creation', () => {
84
98
  expect(row.journal_mode).toBe('wal')
85
99
  })
86
100
 
87
- it('sets schema version to 2', () => {
101
+ it('sets schema version to 4', () => {
88
102
  const db = new DatabaseSync(dbPath)
89
103
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
90
104
  db.close()
91
- expect(row.user_version).toBe(2)
105
+ expect(row.user_version).toBe(4)
92
106
  })
93
107
 
94
108
  it('creates all required tables', () => {
@@ -102,6 +116,7 @@ describe('DB creation', () => {
102
116
  expect(names).toContain('task')
103
117
  expect(names).toContain('worker')
104
118
  expect(names).toContain('event')
119
+ expect(names).toContain('pipeline')
105
120
  })
106
121
 
107
122
  it('reopening an existing DB does not reset schema version', () => {
@@ -113,7 +128,7 @@ describe('DB creation', () => {
113
128
  store2.close()
114
129
  // Reassign so afterEach does not double-close
115
130
  store = createConvoyStore(dbPath)
116
- expect(row.user_version).toBe(2)
131
+ expect(row.user_version).toBe(4)
117
132
  })
118
133
  })
119
134
 
@@ -192,7 +207,263 @@ describe('schema migration', () => {
192
207
  verifyDb.close()
193
208
 
194
209
  expect(cols.map(c => c.name)).toContain('adapter')
195
- expect(version.user_version).toBe(2)
210
+ // v1 chains through v2→v3→v4 in one init, so final version is 4
211
+ expect(version.user_version).toBe(4)
212
+ })
213
+
214
+ it('schema migration v2 to v3 adds cost columns', () => {
215
+ // Create a v2 database manually (has adapter column but no cost columns)
216
+ const v2DbPath = join(tmpDir, 'v2.db')
217
+ const rawDb = new DatabaseSync(v2DbPath)
218
+ rawDb.exec(`
219
+ CREATE TABLE convoy (
220
+ id TEXT PRIMARY KEY,
221
+ name TEXT NOT NULL,
222
+ spec_hash TEXT NOT NULL,
223
+ status TEXT NOT NULL DEFAULT 'pending',
224
+ branch TEXT,
225
+ created_at TEXT NOT NULL,
226
+ started_at TEXT,
227
+ finished_at TEXT,
228
+ spec_yaml TEXT NOT NULL
229
+ );
230
+ CREATE TABLE task (
231
+ id TEXT PRIMARY KEY,
232
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
233
+ phase INTEGER NOT NULL,
234
+ prompt TEXT NOT NULL,
235
+ agent TEXT NOT NULL DEFAULT 'developer',
236
+ adapter TEXT,
237
+ model TEXT,
238
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
239
+ status TEXT NOT NULL DEFAULT 'pending',
240
+ worker_id TEXT,
241
+ worktree TEXT,
242
+ output TEXT,
243
+ exit_code INTEGER,
244
+ started_at TEXT,
245
+ finished_at TEXT,
246
+ retries INTEGER NOT NULL DEFAULT 0,
247
+ max_retries INTEGER NOT NULL DEFAULT 1,
248
+ files TEXT,
249
+ depends_on TEXT
250
+ );
251
+ CREATE TABLE worker (
252
+ id TEXT PRIMARY KEY,
253
+ task_id TEXT REFERENCES task(id),
254
+ adapter TEXT NOT NULL,
255
+ pid INTEGER,
256
+ session_id TEXT,
257
+ status TEXT NOT NULL DEFAULT 'spawned',
258
+ worktree TEXT,
259
+ created_at TEXT NOT NULL,
260
+ finished_at TEXT,
261
+ last_heartbeat TEXT
262
+ );
263
+ CREATE TABLE event (
264
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
265
+ convoy_id TEXT REFERENCES convoy(id),
266
+ task_id TEXT,
267
+ worker_id TEXT,
268
+ type TEXT NOT NULL,
269
+ data TEXT,
270
+ created_at TEXT NOT NULL
271
+ );
272
+ `)
273
+ rawDb.exec('PRAGMA user_version = 2')
274
+ rawDb.close()
275
+
276
+ // Open with createConvoyStore — should apply the v2→v3 migration
277
+ const v2Store = createConvoyStore(v2DbPath)
278
+ v2Store.close()
279
+
280
+ // Verify cost columns were added
281
+ const verifyDb = new DatabaseSync(v2DbPath)
282
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
283
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
284
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
285
+ verifyDb.close()
286
+
287
+ const taskColNames = taskCols.map(c => c.name)
288
+ expect(taskColNames).toContain('prompt_tokens')
289
+ expect(taskColNames).toContain('completion_tokens')
290
+ expect(taskColNames).toContain('total_tokens')
291
+ expect(taskColNames).toContain('cost_usd')
292
+
293
+ const convoyColNames = convoyCols.map(c => c.name)
294
+ expect(convoyColNames).toContain('total_tokens')
295
+ expect(convoyColNames).toContain('total_cost_usd')
296
+
297
+ expect(version.user_version).toBe(4)
298
+ })
299
+
300
+ it('schema migration v1 to v3 chains correctly in a single init', () => {
301
+ // Create a v1 database (task table without adapter or cost columns)
302
+ const v1DbPath = join(tmpDir, 'v1-chain.db')
303
+ const rawDb = new DatabaseSync(v1DbPath)
304
+ rawDb.exec(`
305
+ CREATE TABLE convoy (
306
+ id TEXT PRIMARY KEY,
307
+ name TEXT NOT NULL,
308
+ spec_hash TEXT NOT NULL,
309
+ status TEXT NOT NULL DEFAULT 'pending',
310
+ branch TEXT,
311
+ created_at TEXT NOT NULL,
312
+ started_at TEXT,
313
+ finished_at TEXT,
314
+ spec_yaml TEXT NOT NULL
315
+ );
316
+ CREATE TABLE task (
317
+ id TEXT PRIMARY KEY,
318
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
319
+ phase INTEGER NOT NULL,
320
+ prompt TEXT NOT NULL,
321
+ agent TEXT NOT NULL DEFAULT 'developer',
322
+ model TEXT,
323
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
324
+ status TEXT NOT NULL DEFAULT 'pending',
325
+ worker_id TEXT,
326
+ worktree TEXT,
327
+ output TEXT,
328
+ exit_code INTEGER,
329
+ started_at TEXT,
330
+ finished_at TEXT,
331
+ retries INTEGER NOT NULL DEFAULT 0,
332
+ max_retries INTEGER NOT NULL DEFAULT 1,
333
+ files TEXT,
334
+ depends_on TEXT
335
+ );
336
+ CREATE TABLE worker (
337
+ id TEXT PRIMARY KEY,
338
+ task_id TEXT REFERENCES task(id),
339
+ adapter TEXT NOT NULL,
340
+ pid INTEGER,
341
+ session_id TEXT,
342
+ status TEXT NOT NULL DEFAULT 'spawned',
343
+ worktree TEXT,
344
+ created_at TEXT NOT NULL,
345
+ finished_at TEXT,
346
+ last_heartbeat TEXT
347
+ );
348
+ CREATE TABLE event (
349
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
350
+ convoy_id TEXT REFERENCES convoy(id),
351
+ task_id TEXT,
352
+ worker_id TEXT,
353
+ type TEXT NOT NULL,
354
+ data TEXT,
355
+ created_at TEXT NOT NULL
356
+ );
357
+ `)
358
+ rawDb.exec('PRAGMA user_version = 1')
359
+ rawDb.close()
360
+
361
+ // Open with createConvoyStore — should chain v1→v2→v3 in one init
362
+ const v1Store = createConvoyStore(v1DbPath)
363
+ v1Store.close()
364
+
365
+ // Verify all columns from both migrations are present
366
+ const verifyDb = new DatabaseSync(v1DbPath)
367
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
368
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
369
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
370
+ verifyDb.close()
371
+
372
+ const taskColNames = taskCols.map(c => c.name)
373
+ expect(taskColNames).toContain('adapter')
374
+ expect(taskColNames).toContain('prompt_tokens')
375
+ expect(taskColNames).toContain('completion_tokens')
376
+ expect(taskColNames).toContain('total_tokens')
377
+ expect(taskColNames).toContain('cost_usd')
378
+
379
+ const convoyColNames = convoyCols.map(c => c.name)
380
+ expect(convoyColNames).toContain('total_tokens')
381
+ expect(convoyColNames).toContain('total_cost_usd')
382
+
383
+ expect(version.user_version).toBe(4)
384
+ })
385
+
386
+ it('schema migration v3 to v4 creates pipeline table and adds pipeline_id to convoy', () => {
387
+ const v3DbPath = join(tmpDir, 'v3.db')
388
+ const rawDb = new DatabaseSync(v3DbPath)
389
+ rawDb.exec(`
390
+ CREATE TABLE convoy (
391
+ id TEXT PRIMARY KEY,
392
+ name TEXT NOT NULL,
393
+ spec_hash TEXT NOT NULL,
394
+ status TEXT NOT NULL DEFAULT 'pending',
395
+ branch TEXT,
396
+ created_at TEXT NOT NULL,
397
+ started_at TEXT,
398
+ finished_at TEXT,
399
+ spec_yaml TEXT NOT NULL,
400
+ total_tokens INTEGER,
401
+ total_cost_usd TEXT
402
+ );
403
+ CREATE TABLE task (
404
+ id TEXT PRIMARY KEY,
405
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
406
+ phase INTEGER NOT NULL,
407
+ prompt TEXT NOT NULL,
408
+ agent TEXT NOT NULL DEFAULT 'developer',
409
+ adapter TEXT,
410
+ model TEXT,
411
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
412
+ status TEXT NOT NULL DEFAULT 'pending',
413
+ worker_id TEXT,
414
+ worktree TEXT,
415
+ output TEXT,
416
+ exit_code INTEGER,
417
+ started_at TEXT,
418
+ finished_at TEXT,
419
+ retries INTEGER NOT NULL DEFAULT 0,
420
+ max_retries INTEGER NOT NULL DEFAULT 1,
421
+ files TEXT,
422
+ depends_on TEXT,
423
+ prompt_tokens INTEGER,
424
+ completion_tokens INTEGER,
425
+ total_tokens INTEGER,
426
+ cost_usd TEXT
427
+ );
428
+ CREATE TABLE worker (
429
+ id TEXT PRIMARY KEY,
430
+ task_id TEXT REFERENCES task(id),
431
+ adapter TEXT NOT NULL,
432
+ pid INTEGER,
433
+ session_id TEXT,
434
+ status TEXT NOT NULL DEFAULT 'spawned',
435
+ worktree TEXT,
436
+ created_at TEXT NOT NULL,
437
+ finished_at TEXT,
438
+ last_heartbeat TEXT
439
+ );
440
+ CREATE TABLE event (
441
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
442
+ convoy_id TEXT REFERENCES convoy(id),
443
+ task_id TEXT,
444
+ worker_id TEXT,
445
+ type TEXT NOT NULL,
446
+ data TEXT,
447
+ created_at TEXT NOT NULL
448
+ );
449
+ `)
450
+ rawDb.exec('PRAGMA user_version = 3')
451
+ rawDb.close()
452
+
453
+ const v3Store = createConvoyStore(v3DbPath)
454
+ v3Store.close()
455
+
456
+ const verifyDb = new DatabaseSync(v3DbPath)
457
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>
458
+ const tables = verifyDb
459
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
460
+ .all() as Array<{ name: string }>
461
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
462
+ verifyDb.close()
463
+
464
+ expect(convoyCols.map(c => c.name)).toContain('pipeline_id')
465
+ expect(tables.map(t => t.name)).toContain('pipeline')
466
+ expect(version.user_version).toBe(4)
196
467
  })
197
468
  })
198
469
 
@@ -238,6 +509,25 @@ describe('convoy CRUD', () => {
238
509
  expect(retrieved.status).toBe('done')
239
510
  expect(retrieved.finished_at).toBe(ts)
240
511
  })
512
+
513
+ it('cost fields are null by default on a new convoy', () => {
514
+ store.insertConvoy(makeConvoy())
515
+ const retrieved = store.getConvoy('convoy-1')!
516
+ expect(retrieved.total_tokens).toBeNull()
517
+ expect(retrieved.total_cost_usd).toBeNull()
518
+ })
519
+
520
+ it('updateConvoyStatus persists total_tokens and total_cost_usd', () => {
521
+ store.insertConvoy(makeConvoy())
522
+ store.updateConvoyStatus('convoy-1', 'done', {
523
+ finished_at: '2026-01-01T01:00:00.000Z',
524
+ total_tokens: 5000,
525
+ total_cost_usd: '0.015000',
526
+ })
527
+ const retrieved = store.getConvoy('convoy-1')!
528
+ expect(retrieved.total_tokens).toBe(5000)
529
+ expect(retrieved.total_cost_usd).toBe('0.015000')
530
+ })
241
531
  })
242
532
 
243
533
  // ── task CRUD ─────────────────────────────────────────────────────────────────
@@ -311,6 +601,30 @@ describe('task CRUD', () => {
311
601
  expect(task.finished_at).toBe(ts)
312
602
  expect(task.retries).toBe(1)
313
603
  })
604
+
605
+ it('cost fields are null by default on a new task', () => {
606
+ store.insertTask(makeTask())
607
+ const task = store.getTask('task-1', 'convoy-1')!
608
+ expect(task.prompt_tokens).toBeNull()
609
+ expect(task.completion_tokens).toBeNull()
610
+ expect(task.total_tokens).toBeNull()
611
+ expect(task.cost_usd).toBeNull()
612
+ })
613
+
614
+ it('updateTaskStatus persists cost fields', () => {
615
+ store.insertTask(makeTask())
616
+ store.updateTaskStatus('task-1', 'convoy-1', 'done', {
617
+ prompt_tokens: 1200,
618
+ completion_tokens: 800,
619
+ total_tokens: 2000,
620
+ cost_usd: '0.006000',
621
+ })
622
+ const task = store.getTask('task-1', 'convoy-1')!
623
+ expect(task.prompt_tokens).toBe(1200)
624
+ expect(task.completion_tokens).toBe(800)
625
+ expect(task.total_tokens).toBe(2000)
626
+ expect(task.cost_usd).toBe('0.006000')
627
+ })
314
628
  })
315
629
 
316
630
  // ── getReadyTasks ─────────────────────────────────────────────────────────────
@@ -522,6 +836,140 @@ describe('withTransaction', () => {
522
836
  })
523
837
  })
524
838
 
839
+ // ── pipeline CRUD ─────────────────────────────────────────────────────────────
840
+
841
+ describe('pipeline CRUD', () => {
842
+ it('inserts and retrieves a pipeline record', () => {
843
+ store.insertPipeline(makePipeline())
844
+ const retrieved = store.getPipeline('pipeline-1')
845
+ expect(retrieved).toBeDefined()
846
+ expect(retrieved!.id).toBe('pipeline-1')
847
+ expect(retrieved!.name).toBe('Test Pipeline')
848
+ expect(retrieved!.status).toBe('pending')
849
+ expect(retrieved!.started_at).toBeNull()
850
+ expect(retrieved!.finished_at).toBeNull()
851
+ expect(retrieved!.total_tokens).toBeNull()
852
+ expect(retrieved!.total_cost_usd).toBeNull()
853
+ })
854
+
855
+ it('returns undefined for missing pipeline', () => {
856
+ expect(store.getPipeline('does-not-exist')).toBeUndefined()
857
+ })
858
+
859
+ it('getLatestPipeline returns most recent pipeline', () => {
860
+ store.insertPipeline(makePipeline({ id: 'pipeline-old', created_at: '2026-01-01T00:00:00.000Z' }))
861
+ store.insertPipeline(makePipeline({ id: 'pipeline-new', created_at: '2026-01-02T00:00:00.000Z' }))
862
+ const latest = store.getLatestPipeline()
863
+ expect(latest?.id).toBe('pipeline-new')
864
+ })
865
+
866
+ it('getLatestPipeline returns undefined when no pipelines exist', () => {
867
+ expect(store.getLatestPipeline()).toBeUndefined()
868
+ })
869
+
870
+ it('updatePipelineStatus updates status', () => {
871
+ store.insertPipeline(makePipeline())
872
+ store.updatePipelineStatus('pipeline-1', 'running')
873
+ expect(store.getPipeline('pipeline-1')!.status).toBe('running')
874
+ })
875
+
876
+ it('updatePipelineStatus sets started_at', () => {
877
+ const ts = '2026-01-01T00:00:00.000Z'
878
+ store.insertPipeline(makePipeline())
879
+ store.updatePipelineStatus('pipeline-1', 'running', { started_at: ts })
880
+ const p = store.getPipeline('pipeline-1')!
881
+ expect(p.status).toBe('running')
882
+ expect(p.started_at).toBe(ts)
883
+ })
884
+
885
+ it('updatePipelineStatus sets finished_at', () => {
886
+ const ts = '2026-01-01T01:00:00.000Z'
887
+ store.insertPipeline(makePipeline())
888
+ store.updatePipelineStatus('pipeline-1', 'done', { finished_at: ts })
889
+ const p = store.getPipeline('pipeline-1')!
890
+ expect(p.status).toBe('done')
891
+ expect(p.finished_at).toBe(ts)
892
+ })
893
+
894
+ it('updatePipelineStatus persists total_tokens and total_cost_usd', () => {
895
+ store.insertPipeline(makePipeline())
896
+ store.updatePipelineStatus('pipeline-1', 'done', {
897
+ finished_at: '2026-01-01T01:00:00.000Z',
898
+ total_tokens: 12000,
899
+ total_cost_usd: '0.036000',
900
+ })
901
+ const p = store.getPipeline('pipeline-1')!
902
+ expect(p.total_tokens).toBe(12000)
903
+ expect(p.total_cost_usd).toBe('0.036000')
904
+ })
905
+
906
+ it('pipeline status can transition through all states', () => {
907
+ store.insertPipeline(makePipeline())
908
+ const states = ['running', 'failed', 'done', 'pending'] as const
909
+ for (const s of states) {
910
+ store.updatePipelineStatus('pipeline-1', s)
911
+ expect(store.getPipeline('pipeline-1')!.status).toBe(s)
912
+ }
913
+ })
914
+
915
+ it('convoy_specs is stored and retrieved as a JSON string', () => {
916
+ const specs = ['convoys/build.yml', 'convoys/test.yml', 'convoys/deploy.yml']
917
+ store.insertPipeline(makePipeline({ convoy_specs: JSON.stringify(specs) }))
918
+ const p = store.getPipeline('pipeline-1')!
919
+ expect(JSON.parse(p.convoy_specs)).toEqual(specs)
920
+ })
921
+ })
922
+
923
+ // ── pipeline-convoy linking ───────────────────────────────────────────────────
924
+
925
+ describe('pipeline-convoy linking', () => {
926
+ it('insertConvoy accepts pipeline_id', () => {
927
+ store.insertPipeline(makePipeline())
928
+ store.insertConvoy(makeConvoy({ pipeline_id: 'pipeline-1' }))
929
+ const c = store.getConvoy('convoy-1')!
930
+ expect(c.pipeline_id).toBe('pipeline-1')
931
+ })
932
+
933
+ it('insertConvoy with null pipeline_id creates a standalone convoy', () => {
934
+ store.insertConvoy(makeConvoy({ pipeline_id: null }))
935
+ const c = store.getConvoy('convoy-1')!
936
+ expect(c.pipeline_id).toBeNull()
937
+ })
938
+
939
+ it('getConvoysByPipeline returns all convoys for a pipeline', () => {
940
+ store.insertPipeline(makePipeline())
941
+ store.insertConvoy(makeConvoy({ id: 'convoy-1', pipeline_id: 'pipeline-1', created_at: '2026-01-01T00:00:00.000Z' }))
942
+ store.insertConvoy(makeConvoy({ id: 'convoy-2', pipeline_id: 'pipeline-1', created_at: '2026-01-01T01:00:00.000Z' }))
943
+ const convoys = store.getConvoysByPipeline('pipeline-1')
944
+ expect(convoys).toHaveLength(2)
945
+ expect(convoys.map(c => c.id)).toEqual(['convoy-1', 'convoy-2'])
946
+ })
947
+
948
+ it('getConvoysByPipeline returns convoys ordered by created_at', () => {
949
+ store.insertPipeline(makePipeline())
950
+ store.insertConvoy(makeConvoy({ id: 'convoy-b', pipeline_id: 'pipeline-1', created_at: '2026-01-01T02:00:00.000Z' }))
951
+ store.insertConvoy(makeConvoy({ id: 'convoy-a', pipeline_id: 'pipeline-1', created_at: '2026-01-01T01:00:00.000Z' }))
952
+ const convoys = store.getConvoysByPipeline('pipeline-1')
953
+ expect(convoys[0].id).toBe('convoy-a')
954
+ expect(convoys[1].id).toBe('convoy-b')
955
+ })
956
+
957
+ it('getConvoysByPipeline returns empty array when no convoys are linked', () => {
958
+ store.insertPipeline(makePipeline())
959
+ expect(store.getConvoysByPipeline('pipeline-1')).toHaveLength(0)
960
+ })
961
+
962
+ it('getConvoysByPipeline does not return convoys from other pipelines', () => {
963
+ store.insertPipeline(makePipeline({ id: 'pipeline-1' }))
964
+ store.insertPipeline(makePipeline({ id: 'pipeline-2' }))
965
+ store.insertConvoy(makeConvoy({ id: 'convoy-1', pipeline_id: 'pipeline-1' }))
966
+ store.insertConvoy(makeConvoy({ id: 'convoy-2', pipeline_id: 'pipeline-2' }))
967
+ const p1Convoys = store.getConvoysByPipeline('pipeline-1')
968
+ expect(p1Convoys).toHaveLength(1)
969
+ expect(p1Convoys[0].id).toBe('convoy-1')
970
+ })
971
+ })
972
+
525
973
  // ── close ─────────────────────────────────────────────────────────────────────
526
974
 
527
975
  describe('close', () => {