opencastle 0.22.0 → 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 (57) hide show
  1. package/dist/cli/convoy/engine.d.ts +1 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +1 -0
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/export.d.ts +1 -0
  6. package/dist/cli/convoy/export.d.ts.map +1 -1
  7. package/dist/cli/convoy/export.js +34 -0
  8. package/dist/cli/convoy/export.js.map +1 -1
  9. package/dist/cli/convoy/pipeline.d.ts +35 -0
  10. package/dist/cli/convoy/pipeline.d.ts.map +1 -0
  11. package/dist/cli/convoy/pipeline.js +353 -0
  12. package/dist/cli/convoy/pipeline.js.map +1 -0
  13. package/dist/cli/convoy/pipeline.test.d.ts +2 -0
  14. package/dist/cli/convoy/pipeline.test.d.ts.map +1 -0
  15. package/dist/cli/convoy/pipeline.test.js +778 -0
  16. package/dist/cli/convoy/pipeline.test.js.map +1 -0
  17. package/dist/cli/convoy/store.d.ts +14 -2
  18. package/dist/cli/convoy/store.d.ts.map +1 -1
  19. package/dist/cli/convoy/store.js +84 -5
  20. package/dist/cli/convoy/store.js.map +1 -1
  21. package/dist/cli/convoy/store.test.js +216 -7
  22. package/dist/cli/convoy/store.test.js.map +1 -1
  23. package/dist/cli/convoy/types.d.ts +15 -0
  24. package/dist/cli/convoy/types.d.ts.map +1 -1
  25. package/dist/cli/dashboard.d.ts.map +1 -1
  26. package/dist/cli/dashboard.js +1 -0
  27. package/dist/cli/dashboard.js.map +1 -1
  28. package/dist/cli/run/schema.d.ts +5 -1
  29. package/dist/cli/run/schema.d.ts.map +1 -1
  30. package/dist/cli/run/schema.js +41 -8
  31. package/dist/cli/run/schema.js.map +1 -1
  32. package/dist/cli/run/schema.test.js +194 -5
  33. package/dist/cli/run/schema.test.js.map +1 -1
  34. package/dist/cli/run.d.ts.map +1 -1
  35. package/dist/cli/run.js +141 -2
  36. package/dist/cli/run.js.map +1 -1
  37. package/dist/cli/types.d.ts +3 -1
  38. package/dist/cli/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/convoy/engine.ts +2 -0
  41. package/src/cli/convoy/export.ts +41 -0
  42. package/src/cli/convoy/pipeline.test.ts +939 -0
  43. package/src/cli/convoy/pipeline.ts +430 -0
  44. package/src/cli/convoy/store.test.ts +239 -7
  45. package/src/cli/convoy/store.ts +110 -7
  46. package/src/cli/convoy/types.ts +17 -0
  47. package/src/cli/dashboard.ts +1 -0
  48. package/src/cli/run/schema.test.ts +244 -5
  49. package/src/cli/run/schema.ts +49 -8
  50. package/src/cli/run.ts +140 -2
  51. package/src/cli/types.ts +3 -1
  52. package/src/dashboard/dist/_astro/{index.DyyaCW8L.css → index.Cq68OHaZ.css} +1 -1
  53. package/src/dashboard/dist/index.html +214 -2
  54. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  55. package/src/dashboard/src/pages/index.astro +230 -1
  56. package/src/dashboard/src/styles/dashboard.css +116 -0
  57. 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 3', () => {
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(3)
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(3)
131
+ expect(row.user_version).toBe(4)
117
132
  })
118
133
  })
119
134
 
@@ -192,8 +207,8 @@ describe('schema migration', () => {
192
207
  verifyDb.close()
193
208
 
194
209
  expect(cols.map(c => c.name)).toContain('adapter')
195
- // v1 chains through v2→v3 in one init, so final version is 3
196
- expect(version.user_version).toBe(3)
210
+ // v1 chains through v2→v3→v4 in one init, so final version is 4
211
+ expect(version.user_version).toBe(4)
197
212
  })
198
213
 
199
214
  it('schema migration v2 to v3 adds cost columns', () => {
@@ -279,7 +294,7 @@ describe('schema migration', () => {
279
294
  expect(convoyColNames).toContain('total_tokens')
280
295
  expect(convoyColNames).toContain('total_cost_usd')
281
296
 
282
- expect(version.user_version).toBe(3)
297
+ expect(version.user_version).toBe(4)
283
298
  })
284
299
 
285
300
  it('schema migration v1 to v3 chains correctly in a single init', () => {
@@ -365,7 +380,90 @@ describe('schema migration', () => {
365
380
  expect(convoyColNames).toContain('total_tokens')
366
381
  expect(convoyColNames).toContain('total_cost_usd')
367
382
 
368
- expect(version.user_version).toBe(3)
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)
369
467
  })
370
468
  })
371
469
 
@@ -738,6 +836,140 @@ describe('withTransaction', () => {
738
836
  })
739
837
  })
740
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
+
741
973
  // ── close ─────────────────────────────────────────────────────────────────────
742
974
 
743
975
  describe('close', () => {
@@ -7,12 +7,14 @@ import type {
7
7
  WorkerRecord,
8
8
  WorkerStatus,
9
9
  EventRecord,
10
+ PipelineRecord,
11
+ PipelineStatus,
10
12
  } from './types.js'
11
13
 
12
- const SCHEMA_VERSION = 3
14
+ const SCHEMA_VERSION = 4
13
15
 
14
16
  export interface ConvoyStore {
15
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
17
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void
16
18
  getConvoy(id: string): ConvoyRecord | undefined
17
19
  getLatestConvoy(): ConvoyRecord | undefined
18
20
  updateConvoyStatus(
@@ -46,6 +48,15 @@ export interface ConvoyStore {
46
48
  ): void
47
49
  insertEvent(record: Omit<EventRecord, 'id'>): void
48
50
  getEvents(convoyId: string): EventRecord[]
51
+ insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
52
+ getPipeline(id: string): PipelineRecord | undefined
53
+ getLatestPipeline(): PipelineRecord | undefined
54
+ updatePipelineStatus(
55
+ id: string,
56
+ status: PipelineStatus,
57
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
58
+ ): void
59
+ getConvoysByPipeline(pipelineId: string): ConvoyRecord[]
49
60
  withTransaction<T>(fn: () => T): T
50
61
  close(): void
51
62
  }
@@ -75,7 +86,22 @@ class ConvoyStoreImpl implements ConvoyStore {
75
86
  finished_at TEXT,
76
87
  spec_yaml TEXT NOT NULL,
77
88
  total_tokens INTEGER,
78
- total_cost_usd TEXT
89
+ total_cost_usd TEXT,
90
+ pipeline_id TEXT
91
+ );
92
+
93
+ CREATE TABLE IF NOT EXISTS pipeline (
94
+ id TEXT PRIMARY KEY,
95
+ name TEXT NOT NULL,
96
+ status TEXT NOT NULL DEFAULT 'pending',
97
+ branch TEXT,
98
+ spec_yaml TEXT NOT NULL,
99
+ convoy_specs TEXT NOT NULL,
100
+ created_at TEXT NOT NULL,
101
+ started_at TEXT,
102
+ finished_at TEXT,
103
+ total_tokens INTEGER,
104
+ total_cost_usd TEXT
79
105
  );
80
106
 
81
107
  CREATE TABLE IF NOT EXISTS task (
@@ -145,15 +171,35 @@ class ConvoyStoreImpl implements ConvoyStore {
145
171
  this.db.exec('PRAGMA user_version = 3')
146
172
  version = 3
147
173
  }
174
+ if (version === 3) {
175
+ this.db.exec(`
176
+ CREATE TABLE IF NOT EXISTS pipeline (
177
+ id TEXT PRIMARY KEY,
178
+ name TEXT NOT NULL,
179
+ status TEXT NOT NULL DEFAULT 'pending',
180
+ branch TEXT,
181
+ spec_yaml TEXT NOT NULL,
182
+ convoy_specs TEXT NOT NULL,
183
+ created_at TEXT NOT NULL,
184
+ started_at TEXT,
185
+ finished_at TEXT,
186
+ total_tokens INTEGER,
187
+ total_cost_usd TEXT
188
+ )
189
+ `)
190
+ this.db.exec('ALTER TABLE convoy ADD COLUMN pipeline_id TEXT')
191
+ this.db.exec('PRAGMA user_version = 4')
192
+ version = 4
193
+ }
148
194
  }
149
195
 
150
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
196
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void {
151
197
  this.db
152
198
  .prepare(
153
- `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml)
154
- VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml)`,
199
+ `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml, pipeline_id)
200
+ VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml, :pipeline_id)`,
155
201
  )
156
- .run(record)
202
+ .run({ ...record, pipeline_id: record.pipeline_id ?? null })
157
203
  }
158
204
 
159
205
  getConvoy(id: string): ConvoyRecord | undefined {
@@ -324,6 +370,63 @@ class ConvoyStoreImpl implements ConvoyStore {
324
370
  .all({ convoy_id: convoyId }) as unknown as EventRecord[]
325
371
  }
326
372
 
373
+ insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
374
+ this.db
375
+ .prepare(
376
+ `INSERT INTO pipeline (id, name, status, branch, spec_yaml, convoy_specs, created_at,
377
+ started_at, finished_at, total_tokens, total_cost_usd)
378
+ VALUES (:id, :name, :status, :branch, :spec_yaml, :convoy_specs, :created_at,
379
+ NULL, NULL, NULL, NULL)`,
380
+ )
381
+ .run(record)
382
+ }
383
+
384
+ getPipeline(id: string): PipelineRecord | undefined {
385
+ return this.db
386
+ .prepare('SELECT * FROM pipeline WHERE id = :id')
387
+ .get({ id }) as PipelineRecord | undefined
388
+ }
389
+
390
+ getLatestPipeline(): PipelineRecord | undefined {
391
+ return this.db
392
+ .prepare('SELECT * FROM pipeline ORDER BY created_at DESC LIMIT 1')
393
+ .get() as PipelineRecord | undefined
394
+ }
395
+
396
+ updatePipelineStatus(
397
+ id: string,
398
+ status: PipelineStatus,
399
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
400
+ ): void {
401
+ const sets = ['status = :status']
402
+ const params: Record<string, string | number | null> = { id, status }
403
+
404
+ if (extra?.started_at !== undefined) {
405
+ sets.push('started_at = :started_at')
406
+ params.started_at = extra.started_at
407
+ }
408
+ if (extra?.finished_at !== undefined) {
409
+ sets.push('finished_at = :finished_at')
410
+ params.finished_at = extra.finished_at
411
+ }
412
+ if (extra?.total_tokens !== undefined) {
413
+ sets.push('total_tokens = :total_tokens')
414
+ params.total_tokens = extra.total_tokens
415
+ }
416
+ if (extra?.total_cost_usd !== undefined) {
417
+ sets.push('total_cost_usd = :total_cost_usd')
418
+ params.total_cost_usd = extra.total_cost_usd
419
+ }
420
+
421
+ this.db.prepare(`UPDATE pipeline SET ${sets.join(', ')} WHERE id = :id`).run(params)
422
+ }
423
+
424
+ getConvoysByPipeline(pipelineId: string): ConvoyRecord[] {
425
+ return this.db
426
+ .prepare('SELECT * FROM convoy WHERE pipeline_id = :pipeline_id ORDER BY created_at')
427
+ .all({ pipeline_id: pipelineId }) as unknown as ConvoyRecord[]
428
+ }
429
+
327
430
  withTransaction<T>(fn: () => T): T {
328
431
  this.db.exec('BEGIN')
329
432
  try {
@@ -11,6 +11,8 @@ export type ConvoyTaskStatus =
11
11
 
12
12
  export type WorkerStatus = 'spawned' | 'running' | 'done' | 'failed' | 'killed'
13
13
 
14
+ export type PipelineStatus = 'pending' | 'running' | 'done' | 'failed'
15
+
14
16
  export interface ConvoyRecord {
15
17
  id: string
16
18
  name: string
@@ -23,6 +25,7 @@ export interface ConvoyRecord {
23
25
  spec_yaml: string
24
26
  total_tokens: number | null
25
27
  total_cost_usd: string | null
28
+ pipeline_id: string | null
26
29
  }
27
30
 
28
31
  export interface TaskRecord {
@@ -73,3 +76,17 @@ export interface EventRecord {
73
76
  data: string | null
74
77
  created_at: string
75
78
  }
79
+
80
+ export interface PipelineRecord {
81
+ id: string
82
+ name: string
83
+ status: PipelineStatus
84
+ branch: string | null
85
+ spec_yaml: string
86
+ convoy_specs: string
87
+ created_at: string
88
+ started_at: string | null
89
+ finished_at: string | null
90
+ total_tokens: number | null
91
+ total_cost_usd: string | null
92
+ }
@@ -21,6 +21,7 @@ const MIME_TYPES: Record<string, string> = {
21
21
  const DATA_FILES = [
22
22
  'events.ndjson',
23
23
  'convoys.ndjson',
24
+ 'pipelines.ndjson',
24
25
  // Legacy individual files — kept for backwards compatibility
25
26
  'sessions.ndjson',
26
27
  'delegations.ndjson',