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
@@ -0,0 +1,939 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
5
+ import { createPipelineOrchestrator } from './pipeline.js'
6
+ import { createConvoyStore } from './store.js'
7
+ import type { AgentAdapter, TaskSpec, ExecuteResult, Task } from '../types.js'
8
+ import type { ConvoyEngine, ConvoyResult, ConvoyEngineOptions } from './engine.js'
9
+ import type { ConvoyStatus } from './types.js'
10
+
11
+ // ── Suppress NDJSON log writes ────────────────────────────────────────────────
12
+
13
+ vi.mock('../log.js', () => ({
14
+ appendEvent: vi.fn().mockResolvedValue(undefined),
15
+ }))
16
+
17
+ // ── Mock fs/promises readFile ─────────────────────────────────────────────────
18
+
19
+ vi.mock('node:fs/promises', () => ({
20
+ readFile: vi.fn(),
21
+ }))
22
+
23
+ import { readFile } from 'node:fs/promises'
24
+
25
+ // ── Fixture helpers ───────────────────────────────────────────────────────────
26
+
27
+ function makeAdapter(): AgentAdapter {
28
+ return {
29
+ name: 'test-adapter',
30
+ isAvailable: vi.fn().mockResolvedValue(true),
31
+ execute: vi.fn().mockResolvedValue({
32
+ success: true,
33
+ output: 'ok',
34
+ exitCode: 0,
35
+ } satisfies ExecuteResult),
36
+ kill: vi.fn(),
37
+ } as unknown as AgentAdapter
38
+ }
39
+
40
+ function makeTask(overrides: Partial<Task> = {}): Task {
41
+ return {
42
+ id: 'task-1',
43
+ prompt: 'Do something',
44
+ agent: 'developer',
45
+ timeout: '30s',
46
+ depends_on: [],
47
+ files: [],
48
+ description: '',
49
+ max_retries: 0,
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ function makePipelineSpec(overrides: Partial<TaskSpec> = {}): TaskSpec {
55
+ return {
56
+ name: 'Test Pipeline',
57
+ concurrency: 1,
58
+ on_failure: 'continue',
59
+ adapter: 'test',
60
+ branch: 'main',
61
+ version: 2,
62
+ depends_on_convoy: ['./convoy-a.yaml'],
63
+ ...overrides,
64
+ }
65
+ }
66
+
67
+ /** Minimal convoy YAML — content doesn't matter since readFile is mocked. */
68
+ const CONVOY_YAML =
69
+ 'name: convoy\nconcurrency: 1\non_failure: continue\nadapter: test\nbranch: main\ntasks:\n - id: task-1\n prompt: do thing\n agent: developer\n timeout: 30s\n'
70
+
71
+ function makeConvoyResult(
72
+ overrides: Partial<ConvoyResult> = {},
73
+ status: ConvoyStatus = 'done',
74
+ ): ConvoyResult {
75
+ return {
76
+ convoyId: `convoy-${Date.now()}-${Math.random().toString(36).slice(2)}`,
77
+ status,
78
+ summary: { total: 1, done: 1, failed: 0, skipped: 0, timedOut: 0 },
79
+ duration: '100ms',
80
+ ...overrides,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Create an engine factory where each successive call to engine.run() consumes
86
+ * the next result from `runResults`.
87
+ */
88
+ function makeEngineFactory(runResults: ConvoyResult[]) {
89
+ let idx = 0
90
+ return vi.fn((_opts: ConvoyEngineOptions): ConvoyEngine => {
91
+ const result = runResults[idx++] ?? makeConvoyResult()
92
+ return {
93
+ run: vi.fn().mockResolvedValue(result),
94
+ resume: vi.fn().mockResolvedValue(makeConvoyResult()),
95
+ }
96
+ })
97
+ }
98
+
99
+ // ── Test lifecycle ────────────────────────────────────────────────────────────
100
+
101
+ let tmpDir: string
102
+ let dbPath: string
103
+
104
+ beforeEach(() => {
105
+ vi.mocked(readFile).mockResolvedValue(CONVOY_YAML as unknown as Awaited<ReturnType<typeof readFile>>)
106
+ tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-test-'))
107
+ dbPath = join(tmpDir, 'convoy.db')
108
+ })
109
+
110
+ afterEach(() => {
111
+ vi.clearAllMocks()
112
+ rmSync(tmpDir, { recursive: true, force: true })
113
+ })
114
+
115
+ // ── 1. Single convoy pipeline ─────────────────────────────────────────────────
116
+
117
+ describe('single convoy pipeline', () => {
118
+ it('returns status done when the single convoy succeeds', async () => {
119
+ const factory = makeEngineFactory([makeConvoyResult()])
120
+ const pipeline = createPipelineOrchestrator({
121
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
122
+ specYaml: 'name: pipeline',
123
+ adapter: makeAdapter(),
124
+ dbPath,
125
+ _createConvoyEngine: factory,
126
+ })
127
+
128
+ const result = await pipeline.run()
129
+
130
+ expect(result.status).toBe('done')
131
+ expect(result.convoyResults).toHaveLength(1)
132
+ expect(result.summary.totalConvoys).toBe(1)
133
+ expect(result.summary.completed).toBe(1)
134
+ expect(result.summary.failed).toBe(0)
135
+ expect(result.summary.skipped).toBe(0)
136
+ expect(typeof result.pipelineId).toBe('string')
137
+ expect(typeof result.duration).toBe('string')
138
+ })
139
+
140
+ it('creates a pipeline record in SQLite with correct final state', async () => {
141
+ const factory = makeEngineFactory([makeConvoyResult()])
142
+ const pipeline = createPipelineOrchestrator({
143
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
144
+ specYaml: 'name: pipe',
145
+ adapter: makeAdapter(),
146
+ dbPath,
147
+ _createConvoyEngine: factory,
148
+ })
149
+
150
+ const result = await pipeline.run()
151
+
152
+ const store = createConvoyStore(dbPath)
153
+ const record = store.getPipeline(result.pipelineId)
154
+ store.close()
155
+
156
+ expect(record).toBeDefined()
157
+ expect(record!.status).toBe('done')
158
+ expect(record!.name).toBe('Test Pipeline')
159
+ expect(record!.branch).toBe('main')
160
+ expect(record!.finished_at).not.toBeNull()
161
+ })
162
+ })
163
+
164
+ // ── 2. Two-convoy pipeline ────────────────────────────────────────────────────
165
+
166
+ describe('two-convoy pipeline', () => {
167
+ it('runs both convoys sequentially and returns done', async () => {
168
+ const r1 = makeConvoyResult({ convoyId: 'convoy-1' })
169
+ const r2 = makeConvoyResult({ convoyId: 'convoy-2' })
170
+ const factory = makeEngineFactory([r1, r2])
171
+
172
+ const result = await createPipelineOrchestrator({
173
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml', './b.yaml'] }),
174
+ specYaml: 'name: pipeline',
175
+ adapter: makeAdapter(),
176
+ dbPath,
177
+ _createConvoyEngine: factory,
178
+ }).run()
179
+
180
+ expect(result.status).toBe('done')
181
+ expect(result.convoyResults).toHaveLength(2)
182
+ expect(result.summary.completed).toBe(2)
183
+ expect(result.summary.failed).toBe(0)
184
+ expect(factory).toHaveBeenCalledTimes(2)
185
+ })
186
+
187
+ it('reads spec files in order', async () => {
188
+ const factory = makeEngineFactory([makeConvoyResult(), makeConvoyResult()])
189
+
190
+ await createPipelineOrchestrator({
191
+ spec: makePipelineSpec({ depends_on_convoy: ['./first.yaml', './second.yaml'] }),
192
+ specYaml: 'name: pipeline',
193
+ adapter: makeAdapter(),
194
+ dbPath,
195
+ _createConvoyEngine: factory,
196
+ }).run()
197
+
198
+ expect(vi.mocked(readFile)).toHaveBeenNthCalledWith(
199
+ 1,
200
+ expect.stringContaining('first.yaml'),
201
+ 'utf8',
202
+ )
203
+ expect(vi.mocked(readFile)).toHaveBeenNthCalledWith(
204
+ 2,
205
+ expect.stringContaining('second.yaml'),
206
+ 'utf8',
207
+ )
208
+ })
209
+ })
210
+
211
+ // ── 3. on_failure: 'stop' ─────────────────────────────────────────────────────
212
+
213
+ describe('on_failure: stop', () => {
214
+ it('skips remaining convoys when second of three fails', async () => {
215
+ const r1 = makeConvoyResult({ convoyId: 'c1' }, 'done')
216
+ const r2 = makeConvoyResult({ convoyId: 'c2' }, 'failed')
217
+ const factory = makeEngineFactory([r1, r2])
218
+
219
+ const result = await createPipelineOrchestrator({
220
+ spec: makePipelineSpec({
221
+ depends_on_convoy: ['./a.yaml', './b.yaml', './c.yaml'],
222
+ on_failure: 'stop',
223
+ }),
224
+ specYaml: 'name: pipeline',
225
+ adapter: makeAdapter(),
226
+ dbPath,
227
+ _createConvoyEngine: factory,
228
+ }).run()
229
+
230
+ expect(result.status).toBe('failed')
231
+ expect(result.summary.totalConvoys).toBe(3)
232
+ expect(result.summary.completed).toBe(1)
233
+ expect(result.summary.failed).toBe(1)
234
+ expect(result.summary.skipped).toBe(1)
235
+ // Engine only called twice (third skipped)
236
+ expect(factory).toHaveBeenCalledTimes(2)
237
+ })
238
+
239
+ it('halts immediately on gate-failed convoy', async () => {
240
+ const r1 = makeConvoyResult({ convoyId: 'c1' }, 'gate-failed')
241
+ const factory = makeEngineFactory([r1])
242
+
243
+ const result = await createPipelineOrchestrator({
244
+ spec: makePipelineSpec({
245
+ depends_on_convoy: ['./a.yaml', './b.yaml'],
246
+ on_failure: 'stop',
247
+ }),
248
+ specYaml: 'name: pipeline',
249
+ adapter: makeAdapter(),
250
+ dbPath,
251
+ _createConvoyEngine: factory,
252
+ }).run()
253
+
254
+ expect(result.status).toBe('failed')
255
+ expect(result.summary.skipped).toBe(1)
256
+ expect(factory).toHaveBeenCalledTimes(1)
257
+ })
258
+ })
259
+
260
+ // ── 4. on_failure: 'continue' ────────────────────────────────────────────────
261
+
262
+ describe('on_failure: continue', () => {
263
+ it('runs all three convoys even when second fails', async () => {
264
+ const r1 = makeConvoyResult({ convoyId: 'c1' }, 'done')
265
+ const r2 = makeConvoyResult({ convoyId: 'c2' }, 'failed')
266
+ const r3 = makeConvoyResult({ convoyId: 'c3' }, 'done')
267
+ const factory = makeEngineFactory([r1, r2, r3])
268
+
269
+ const result = await createPipelineOrchestrator({
270
+ spec: makePipelineSpec({
271
+ depends_on_convoy: ['./a.yaml', './b.yaml', './c.yaml'],
272
+ on_failure: 'continue',
273
+ }),
274
+ specYaml: 'name: pipeline',
275
+ adapter: makeAdapter(),
276
+ dbPath,
277
+ _createConvoyEngine: factory,
278
+ }).run()
279
+
280
+ expect(result.status).toBe('failed')
281
+ expect(result.summary.totalConvoys).toBe(3)
282
+ expect(result.summary.completed).toBe(2)
283
+ expect(result.summary.failed).toBe(1)
284
+ expect(result.summary.skipped).toBe(0)
285
+ expect(factory).toHaveBeenCalledTimes(3)
286
+ })
287
+ })
288
+
289
+ // ── 5. Hybrid pipeline ────────────────────────────────────────────────────────
290
+
291
+ describe('hybrid pipeline (chained + own tasks)', () => {
292
+ it('runs chained convoys then own tasks as a final convoy', async () => {
293
+ const r1 = makeConvoyResult({ convoyId: 'chained-1' })
294
+ const rHybrid = makeConvoyResult({ convoyId: 'hybrid-own' })
295
+ const factory = makeEngineFactory([r1, rHybrid])
296
+
297
+ const result = await createPipelineOrchestrator({
298
+ spec: makePipelineSpec({
299
+ depends_on_convoy: ['./a.yaml'],
300
+ tasks: [makeTask()],
301
+ }),
302
+ specYaml: 'name: hybrid',
303
+ adapter: makeAdapter(),
304
+ dbPath,
305
+ _createConvoyEngine: factory,
306
+ }).run()
307
+
308
+ expect(result.status).toBe('done')
309
+ expect(result.convoyResults).toHaveLength(2)
310
+ expect(result.summary.totalConvoys).toBe(2)
311
+ expect(factory).toHaveBeenCalledTimes(2)
312
+ })
313
+
314
+ it('does NOT run own tasks when pipeline is halted by on_failure: stop', async () => {
315
+ const r1 = makeConvoyResult({ convoyId: 'c1' }, 'failed')
316
+ const factory = makeEngineFactory([r1])
317
+
318
+ const result = await createPipelineOrchestrator({
319
+ spec: makePipelineSpec({
320
+ depends_on_convoy: ['./a.yaml'],
321
+ tasks: [makeTask()],
322
+ on_failure: 'stop',
323
+ }),
324
+ specYaml: 'name: hybrid',
325
+ adapter: makeAdapter(),
326
+ dbPath,
327
+ _createConvoyEngine: factory,
328
+ }).run()
329
+
330
+ expect(result.summary.totalConvoys).toBe(1)
331
+ expect(factory).toHaveBeenCalledTimes(1)
332
+ expect(result.status).toBe('failed')
333
+ })
334
+ })
335
+
336
+ // ── 6. Token aggregation ──────────────────────────────────────────────────────
337
+
338
+ describe('token aggregation', () => {
339
+ it('sums total_tokens across all convoy results', async () => {
340
+ const r1 = makeConvoyResult({ convoyId: 'c1', cost: { total_tokens: 100 } })
341
+ const r2 = makeConvoyResult({ convoyId: 'c2', cost: { total_tokens: 250 } })
342
+ const factory = makeEngineFactory([r1, r2])
343
+
344
+ const result = await createPipelineOrchestrator({
345
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml', './b.yaml'] }),
346
+ specYaml: 'name: pipeline',
347
+ adapter: makeAdapter(),
348
+ dbPath,
349
+ _createConvoyEngine: factory,
350
+ }).run()
351
+
352
+ expect(result.cost?.total_tokens).toBe(350)
353
+ })
354
+
355
+ it('omits cost when no convoy has token data', async () => {
356
+ const factory = makeEngineFactory([makeConvoyResult()]) // no cost field
357
+
358
+ const result = await createPipelineOrchestrator({
359
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
360
+ specYaml: 'name: pipeline',
361
+ adapter: makeAdapter(),
362
+ dbPath,
363
+ _createConvoyEngine: factory,
364
+ }).run()
365
+
366
+ expect(result.cost).toBeUndefined()
367
+ })
368
+
369
+ it('persists total_tokens in the pipeline SQLite record', async () => {
370
+ const factory = makeEngineFactory([
371
+ makeConvoyResult({ cost: { total_tokens: 42 } }),
372
+ ])
373
+
374
+ const result = await createPipelineOrchestrator({
375
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
376
+ specYaml: 'name: pipeline',
377
+ adapter: makeAdapter(),
378
+ dbPath,
379
+ _createConvoyEngine: factory,
380
+ }).run()
381
+
382
+ const store = createConvoyStore(dbPath)
383
+ const record = store.getPipeline(result.pipelineId)
384
+ store.close()
385
+
386
+ expect(record!.total_tokens).toBe(42)
387
+ })
388
+ })
389
+
390
+ // ── 7. Shared branch ──────────────────────────────────────────────────────────
391
+
392
+ describe('shared branch', () => {
393
+ it('passes the pipeline branch to all convoy engines', async () => {
394
+ const factory = makeEngineFactory([makeConvoyResult(), makeConvoyResult()])
395
+
396
+ await createPipelineOrchestrator({
397
+ spec: makePipelineSpec({
398
+ branch: 'feature/pipeline-test',
399
+ depends_on_convoy: ['./a.yaml', './b.yaml'],
400
+ }),
401
+ specYaml: 'name: pipeline',
402
+ adapter: makeAdapter(),
403
+ dbPath,
404
+ _createConvoyEngine: factory,
405
+ }).run()
406
+
407
+ const calls = factory.mock.calls as [ConvoyEngineOptions][]
408
+ expect(calls[0][0].spec.branch).toBe('feature/pipeline-test')
409
+ expect(calls[1][0].spec.branch).toBe('feature/pipeline-test')
410
+ })
411
+ })
412
+
413
+ // ── 8. Pipeline convoy linking ────────────────────────────────────────────────
414
+
415
+ describe('pipeline convoy linking', () => {
416
+ it('passes pipelineId to each convoy engine', async () => {
417
+ const factory = makeEngineFactory([makeConvoyResult(), makeConvoyResult()])
418
+
419
+ const result = await createPipelineOrchestrator({
420
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml', './b.yaml'] }),
421
+ specYaml: 'name: pipeline',
422
+ adapter: makeAdapter(),
423
+ dbPath,
424
+ _createConvoyEngine: factory,
425
+ }).run()
426
+
427
+ const calls = factory.mock.calls as [ConvoyEngineOptions][]
428
+ expect(calls[0][0].pipelineId).toBe(result.pipelineId)
429
+ expect(calls[1][0].pipelineId).toBe(result.pipelineId)
430
+ })
431
+ })
432
+
433
+ // ── 9. Pipeline record persistence transitions ────────────────────────────────
434
+
435
+ describe('pipeline record persistence', () => {
436
+ it('transitions: pending → running → done', async () => {
437
+ let statusDuringRun: string | undefined
438
+ const factory = vi.fn((_opts: ConvoyEngineOptions): ConvoyEngine => ({
439
+ run: vi.fn().mockImplementation(async () => {
440
+ const s = createConvoyStore(dbPath)
441
+ statusDuringRun = s.getPipeline(_opts.pipelineId!)?.status
442
+ s.close()
443
+ return makeConvoyResult()
444
+ }),
445
+ resume: vi.fn(),
446
+ }))
447
+
448
+ const result = await createPipelineOrchestrator({
449
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
450
+ specYaml: 'name: pipeline',
451
+ adapter: makeAdapter(),
452
+ dbPath,
453
+ _createConvoyEngine: factory,
454
+ }).run()
455
+
456
+ expect(statusDuringRun).toBe('running')
457
+
458
+ const store = createConvoyStore(dbPath)
459
+ const record = store.getPipeline(result.pipelineId)
460
+ store.close()
461
+
462
+ expect(record!.status).toBe('done')
463
+ expect(record!.started_at).not.toBeNull()
464
+ expect(record!.finished_at).not.toBeNull()
465
+ })
466
+
467
+ it('marks pipeline as failed when a convoy fails', async () => {
468
+ const factory = makeEngineFactory([makeConvoyResult({}, 'failed')])
469
+
470
+ const result = await createPipelineOrchestrator({
471
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
472
+ specYaml: 'name: pipeline',
473
+ adapter: makeAdapter(),
474
+ dbPath,
475
+ _createConvoyEngine: factory,
476
+ }).run()
477
+
478
+ const store = createConvoyStore(dbPath)
479
+ const record = store.getPipeline(result.pipelineId)
480
+ store.close()
481
+
482
+ expect(record!.status).toBe('failed')
483
+ })
484
+ })
485
+
486
+ // ── 10. Pipeline resume ───────────────────────────────────────────────────────
487
+
488
+ describe('pipeline resume', () => {
489
+ it('continues from the first non-completed convoy', async () => {
490
+ const pipelineId = 'pipeline-resume-continue-test'
491
+ const doneConvoyId = 'convoy-1-done'
492
+
493
+ // Pre-seed the pipeline and a completed convoy directly in the store
494
+ const store = createConvoyStore(dbPath)
495
+ store.insertPipeline({
496
+ id: pipelineId,
497
+ name: 'Test Pipeline',
498
+ status: 'running',
499
+ branch: 'main',
500
+ spec_yaml: 'name: pipeline',
501
+ convoy_specs: JSON.stringify(['./a.yaml', './b.yaml']),
502
+ created_at: new Date(Date.now() - 5000).toISOString(),
503
+ })
504
+ store.insertConvoy({
505
+ id: doneConvoyId,
506
+ name: 'Convoy A',
507
+ spec_hash: 'abc',
508
+ status: 'done',
509
+ branch: 'main',
510
+ created_at: new Date(Date.now() - 4000).toISOString(),
511
+ spec_yaml: CONVOY_YAML,
512
+ pipeline_id: pipelineId,
513
+ })
514
+ store.close()
515
+
516
+ // Resume: second convoy is fresh, factory called once for ./b.yaml
517
+ const secondResult = makeConvoyResult({ convoyId: 'convoy-2-fresh' }, 'done')
518
+ const resumeFactory = makeEngineFactory([secondResult])
519
+
520
+ const pipelineSpec = makePipelineSpec({ depends_on_convoy: ['./a.yaml', './b.yaml'] })
521
+ const resumeResult = await createPipelineOrchestrator({
522
+ spec: pipelineSpec,
523
+ specYaml: 'name: pipeline',
524
+ adapter: makeAdapter(),
525
+ dbPath,
526
+ _createConvoyEngine: resumeFactory,
527
+ }).resume(pipelineId)
528
+
529
+ expect(resumeResult.convoyResults).toHaveLength(2)
530
+ expect(resumeResult.convoyResults[0].convoyId).toBe(doneConvoyId)
531
+ expect(resumeResult.convoyResults[1].convoyId).toBe('convoy-2-fresh')
532
+ // Only second convoy ran on resume
533
+ expect(resumeFactory).toHaveBeenCalledTimes(1)
534
+ })
535
+
536
+ it('reconstructs token cost from done convoys during resume', async () => {
537
+ const pipelineId = 'pipeline-resume-tokens'
538
+ const doneConvoyId = 'convoy-done-with-tokens'
539
+
540
+ const store = createConvoyStore(dbPath)
541
+ store.insertPipeline({
542
+ id: pipelineId,
543
+ name: 'Token Pipeline',
544
+ status: 'running',
545
+ branch: 'main',
546
+ spec_yaml: 'name: pipeline',
547
+ convoy_specs: JSON.stringify(['./a.yaml']),
548
+ created_at: new Date().toISOString(),
549
+ })
550
+ store.insertConvoy({
551
+ id: doneConvoyId,
552
+ name: 'Done Convoy',
553
+ spec_hash: 'abc',
554
+ status: 'done',
555
+ branch: 'main',
556
+ created_at: new Date().toISOString(),
557
+ spec_yaml: CONVOY_YAML,
558
+ pipeline_id: pipelineId,
559
+ })
560
+ store.updateConvoyStatus(doneConvoyId, 'done', { total_tokens: 77 })
561
+ store.close()
562
+
563
+ const resumeFactory = makeEngineFactory([])
564
+ const result = await createPipelineOrchestrator({
565
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
566
+ specYaml: 'name: pipeline',
567
+ adapter: makeAdapter(),
568
+ dbPath,
569
+ _createConvoyEngine: resumeFactory,
570
+ }).resume(pipelineId)
571
+
572
+ expect(result.cost?.total_tokens).toBe(77)
573
+ expect(result.convoyResults[0].cost).toEqual({ total_tokens: 77 })
574
+ expect(resumeFactory).not.toHaveBeenCalled()
575
+ })
576
+
577
+ it('halts remaining convoys during resume when on_failure: stop', async () => {
578
+ const pipelineId = 'pipeline-resume-halt'
579
+
580
+ const store = createConvoyStore(dbPath)
581
+ store.insertPipeline({
582
+ id: pipelineId,
583
+ name: 'Halt Pipeline',
584
+ status: 'running',
585
+ branch: 'main',
586
+ spec_yaml: 'name: pipeline',
587
+ convoy_specs: JSON.stringify(['./a.yaml', './b.yaml', './c.yaml']),
588
+ created_at: new Date().toISOString(),
589
+ })
590
+ store.close()
591
+
592
+ // First call fails, second should be skipped
593
+ const failResult = makeConvoyResult({ convoyId: 'c1' }, 'failed')
594
+ const resumeFactory = makeEngineFactory([failResult])
595
+
596
+ const result = await createPipelineOrchestrator({
597
+ spec: makePipelineSpec({
598
+ depends_on_convoy: ['./a.yaml', './b.yaml', './c.yaml'],
599
+ on_failure: 'stop',
600
+ }),
601
+ specYaml: 'name: pipeline',
602
+ adapter: makeAdapter(),
603
+ dbPath,
604
+ _createConvoyEngine: resumeFactory,
605
+ }).resume(pipelineId)
606
+
607
+ expect(result.status).toBe('failed')
608
+ expect(result.summary.skipped).toBe(2)
609
+ expect(resumeFactory).toHaveBeenCalledTimes(1)
610
+ })
611
+
612
+ it('throws when pipelineId does not exist in store', async () => {
613
+ const pipeline = createPipelineOrchestrator({
614
+ spec: makePipelineSpec(),
615
+ specYaml: 'name: pipeline',
616
+ adapter: makeAdapter(),
617
+ dbPath,
618
+ _createConvoyEngine: makeEngineFactory([]),
619
+ })
620
+
621
+ await expect(pipeline.resume('nonexistent-id')).rejects.toThrow(
622
+ 'Pipeline "nonexistent-id" not found in store',
623
+ )
624
+ })
625
+
626
+ it('resumes a running convoy via engine.resume()', async () => {
627
+ const pipelineId = 'pipeline-resume-test'
628
+ const runningConvoyId = 'convoy-running-123'
629
+
630
+ const store = createConvoyStore(dbPath)
631
+ store.insertPipeline({
632
+ id: pipelineId,
633
+ name: 'Resume Test',
634
+ status: 'running',
635
+ branch: 'main',
636
+ spec_yaml: 'name: resume',
637
+ convoy_specs: JSON.stringify(['./a.yaml']),
638
+ created_at: new Date(Date.now() - 1000).toISOString(),
639
+ })
640
+ store.insertConvoy({
641
+ id: runningConvoyId,
642
+ name: 'Convoy A',
643
+ spec_hash: 'abc123',
644
+ status: 'running',
645
+ branch: 'main',
646
+ created_at: new Date().toISOString(),
647
+ spec_yaml: CONVOY_YAML,
648
+ pipeline_id: pipelineId,
649
+ })
650
+ store.close()
651
+
652
+ const resumedResult = makeConvoyResult({ convoyId: runningConvoyId }, 'done')
653
+ const mockEngine: ConvoyEngine = {
654
+ run: vi.fn().mockResolvedValue(makeConvoyResult()),
655
+ resume: vi.fn().mockResolvedValue(resumedResult),
656
+ }
657
+ const factory = vi.fn().mockReturnValue(mockEngine)
658
+
659
+ const result = await createPipelineOrchestrator({
660
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
661
+ specYaml: 'name: pipeline',
662
+ adapter: makeAdapter(),
663
+ dbPath,
664
+ _createConvoyEngine: factory,
665
+ }).resume(pipelineId)
666
+
667
+ expect(mockEngine.resume).toHaveBeenCalledWith(runningConvoyId)
668
+ expect(mockEngine.run).not.toHaveBeenCalled()
669
+ expect(result.convoyResults[0].convoyId).toBe(runningConvoyId)
670
+ })
671
+ })
672
+
673
+ // ── 12. getCurrentBranch fallback ─────────────────────────────────────────────
674
+
675
+ describe('getCurrentBranch fallback', () => {
676
+ it('run() uses getCurrentBranch when spec has no branch (falls back to main)', async () => {
677
+ const factory = makeEngineFactory([makeConvoyResult()])
678
+
679
+ // No branch on spec — forces getCurrentBranch call (git fails in tmpDir, returns 'main')
680
+ const specNoBranch: TaskSpec = {
681
+ name: 'No Branch Pipeline',
682
+ concurrency: 1,
683
+ on_failure: 'continue',
684
+ adapter: 'test',
685
+ version: 2,
686
+ depends_on_convoy: ['./a.yaml'],
687
+ }
688
+
689
+ const result = await createPipelineOrchestrator({
690
+ spec: specNoBranch,
691
+ specYaml: 'name: pipeline',
692
+ adapter: makeAdapter(),
693
+ basePath: tmpDir, // not a git repo → getCurrentBranch returns 'main'
694
+ dbPath,
695
+ _createConvoyEngine: factory,
696
+ }).run()
697
+
698
+ expect(result.status).toBe('done')
699
+ // branch should be whatever getCurrentBranch returns (likely 'main' or 'HEAD')
700
+ const store = createConvoyStore(dbPath)
701
+ const record = store.getPipeline(result.pipelineId)
702
+ store.close()
703
+ expect(typeof record!.branch).toBe('string')
704
+ })
705
+
706
+ it('resume() uses getCurrentBranch when pipeline branch is null and spec has no branch', async () => {
707
+ const pipelineId = 'pipeline-no-branch'
708
+ const store = createConvoyStore(dbPath)
709
+ store.insertPipeline({
710
+ id: pipelineId,
711
+ name: 'No Branch Resume',
712
+ status: 'running',
713
+ branch: null,
714
+ spec_yaml: 'name: pipeline',
715
+ convoy_specs: JSON.stringify(['./a.yaml']),
716
+ created_at: new Date().toISOString(),
717
+ })
718
+ store.close()
719
+
720
+ const resumeFactory = makeEngineFactory([makeConvoyResult()])
721
+ const specNoBranch: TaskSpec = {
722
+ name: 'No Branch Pipeline',
723
+ concurrency: 1,
724
+ on_failure: 'continue',
725
+ adapter: 'test',
726
+ version: 2,
727
+ depends_on_convoy: ['./a.yaml'],
728
+ }
729
+
730
+ const result = await createPipelineOrchestrator({
731
+ spec: specNoBranch,
732
+ specYaml: 'name: pipeline',
733
+ adapter: makeAdapter(),
734
+ basePath: tmpDir,
735
+ dbPath,
736
+ _createConvoyEngine: resumeFactory,
737
+ }).resume(pipelineId)
738
+
739
+ expect(result.status).toBe('done')
740
+ })
741
+ })
742
+
743
+
744
+ describe('NDJSON export', () => {
745
+ it('writes pipelines.ndjson to logsDir after run', async () => {
746
+ const factory = makeEngineFactory([makeConvoyResult()])
747
+ const logsDir = join(tmpDir, 'logs')
748
+
749
+ await createPipelineOrchestrator({
750
+ spec: makePipelineSpec({ depends_on_convoy: ['./a.yaml'] }),
751
+ specYaml: 'name: pipeline',
752
+ adapter: makeAdapter(),
753
+ dbPath,
754
+ logsDir,
755
+ _createConvoyEngine: factory,
756
+ }).run()
757
+
758
+ const { existsSync, readFileSync } = await import('node:fs')
759
+ const ndjsonPath = join(logsDir, 'pipelines.ndjson')
760
+ expect(existsSync(ndjsonPath)).toBe(true)
761
+
762
+ const parsed = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
763
+ expect(parsed.status).toBe('done')
764
+ expect(typeof parsed.id).toBe('string')
765
+ expect(Array.isArray(parsed.convoys)).toBe(true)
766
+ })
767
+ })
768
+
769
+ // ── 13. Path traversal protection ────────────────────────────────────────────
770
+
771
+ describe('path traversal protection', () => {
772
+ it('rejects absolute path in depends_on_convoy', async () => {
773
+ const factory = makeEngineFactory([])
774
+
775
+ const result = await createPipelineOrchestrator({
776
+ spec: makePipelineSpec({ depends_on_convoy: ['/etc/passwd'] }),
777
+ specYaml: 'name: pipeline',
778
+ adapter: makeAdapter(),
779
+ dbPath,
780
+ _createConvoyEngine: factory,
781
+ }).run()
782
+
783
+ expect(result.status).toBe('failed')
784
+ expect(result.summary.failed).toBe(1)
785
+ expect(result.convoyResults[0].status).toBe('failed')
786
+ // Engine should never be called — error happens before spec is loaded
787
+ expect(factory).not.toHaveBeenCalled()
788
+
789
+ // Pipeline record must be finalized (not stuck in 'running')
790
+ const store = createConvoyStore(dbPath)
791
+ const record = store.getPipeline(result.pipelineId)
792
+ store.close()
793
+ expect(record!.status).toBe('failed')
794
+ expect(record!.finished_at).not.toBeNull()
795
+ })
796
+
797
+ it('rejects path traversal via .. in depends_on_convoy', async () => {
798
+ const factory = makeEngineFactory([])
799
+
800
+ const result = await createPipelineOrchestrator({
801
+ spec: makePipelineSpec({ depends_on_convoy: ['../../etc/passwd'] }),
802
+ specYaml: 'name: pipeline',
803
+ adapter: makeAdapter(),
804
+ dbPath,
805
+ _createConvoyEngine: factory,
806
+ }).run()
807
+
808
+ expect(result.status).toBe('failed')
809
+ expect(result.summary.failed).toBe(1)
810
+ expect(result.convoyResults[0].status).toBe('failed')
811
+ expect(factory).not.toHaveBeenCalled()
812
+
813
+ // Pipeline record must be finalized
814
+ const store = createConvoyStore(dbPath)
815
+ const record = store.getPipeline(result.pipelineId)
816
+ store.close()
817
+ expect(record!.status).toBe('failed')
818
+ expect(record!.finished_at).not.toBeNull()
819
+ })
820
+
821
+ it('allows valid relative path like ./sub/convoy.yaml', async () => {
822
+ // readFile default mock returns CONVOY_YAML for any path
823
+ const factory = makeEngineFactory([makeConvoyResult()])
824
+
825
+ const result = await createPipelineOrchestrator({
826
+ spec: makePipelineSpec({ depends_on_convoy: ['./sub/convoy.yaml'] }),
827
+ specYaml: 'name: pipeline',
828
+ adapter: makeAdapter(),
829
+ dbPath,
830
+ _createConvoyEngine: factory,
831
+ }).run()
832
+
833
+ expect(result.status).toBe('done')
834
+ expect(factory).toHaveBeenCalledTimes(1)
835
+ })
836
+ })
837
+
838
+ // ── 14. Missing convoy spec file ──────────────────────────────────────────────
839
+
840
+ describe('missing convoy spec file', () => {
841
+ it('handles readFile ENOENT without crashing pipeline (on_failure: continue)', async () => {
842
+ const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), {
843
+ code: 'ENOENT',
844
+ })
845
+ // First call (./missing.yaml) rejects; second (./exists.yaml) falls back to default CONVOY_YAML
846
+ vi.mocked(readFile).mockRejectedValueOnce(enoentError)
847
+
848
+ const factory = makeEngineFactory([makeConvoyResult({ convoyId: 'exists-convoy' })])
849
+
850
+ const result = await createPipelineOrchestrator({
851
+ spec: makePipelineSpec({
852
+ depends_on_convoy: ['./missing.yaml', './exists.yaml'],
853
+ on_failure: 'continue',
854
+ }),
855
+ specYaml: 'name: pipeline',
856
+ adapter: makeAdapter(),
857
+ dbPath,
858
+ _createConvoyEngine: factory,
859
+ }).run()
860
+
861
+ expect(result.status).toBe('failed')
862
+ expect(result.summary.failed).toBeGreaterThanOrEqual(1)
863
+ expect(result.summary.completed).toBeGreaterThanOrEqual(1)
864
+ // Engine only called once — for the second spec (first failed before engine)
865
+ expect(factory).toHaveBeenCalledTimes(1)
866
+
867
+ // Pipeline record finalized (not stuck in 'running')
868
+ const store = createConvoyStore(dbPath)
869
+ const record = store.getPipeline(result.pipelineId)
870
+ store.close()
871
+ expect(record!.status).toBe('failed')
872
+ expect(record!.finished_at).not.toBeNull()
873
+ })
874
+
875
+ it('stops pipeline on missing file when on_failure: stop', async () => {
876
+ const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), {
877
+ code: 'ENOENT',
878
+ })
879
+ vi.mocked(readFile).mockRejectedValueOnce(enoentError)
880
+
881
+ const factory = makeEngineFactory([makeConvoyResult()])
882
+
883
+ const result = await createPipelineOrchestrator({
884
+ spec: makePipelineSpec({
885
+ depends_on_convoy: ['./missing.yaml', './exists.yaml'],
886
+ on_failure: 'stop',
887
+ }),
888
+ specYaml: 'name: pipeline',
889
+ adapter: makeAdapter(),
890
+ dbPath,
891
+ _createConvoyEngine: factory,
892
+ }).run()
893
+
894
+ expect(result.status).toBe('failed')
895
+ expect(result.summary.skipped).toBeGreaterThanOrEqual(1)
896
+ // Second spec skipped — factory never called
897
+ expect(factory).not.toHaveBeenCalled()
898
+
899
+ // Pipeline record finalized as failed
900
+ const store = createConvoyStore(dbPath)
901
+ const record = store.getPipeline(result.pipelineId)
902
+ store.close()
903
+ expect(record!.status).toBe('failed')
904
+ expect(record!.finished_at).not.toBeNull()
905
+ })
906
+ })
907
+
908
+ // ── 15. Invalid convoy YAML ───────────────────────────────────────────────────
909
+
910
+ describe('invalid convoy YAML', () => {
911
+ it('handles parse error without crashing pipeline', async () => {
912
+ // Return syntactically broken YAML (unclosed bracket triggers YAMLException)
913
+ vi.mocked(readFile).mockResolvedValueOnce(
914
+ 'name: [unclosed bracket' as unknown as Awaited<ReturnType<typeof readFile>>,
915
+ )
916
+
917
+ const factory = makeEngineFactory([])
918
+
919
+ const result = await createPipelineOrchestrator({
920
+ spec: makePipelineSpec({ depends_on_convoy: ['./bad.yaml'] }),
921
+ specYaml: 'name: pipeline',
922
+ adapter: makeAdapter(),
923
+ dbPath,
924
+ _createConvoyEngine: factory,
925
+ }).run()
926
+
927
+ expect(result.status).toBe('failed')
928
+ expect(result.summary.failed).toBeGreaterThanOrEqual(1)
929
+ // Engine never called — parse error happens before engine creation
930
+ expect(factory).not.toHaveBeenCalled()
931
+
932
+ // Pipeline record finalized (not stuck in 'running')
933
+ const store = createConvoyStore(dbPath)
934
+ const record = store.getPipeline(result.pipelineId)
935
+ store.close()
936
+ expect(record!.status).toBe('failed')
937
+ expect(record!.finished_at).not.toBeNull()
938
+ })
939
+ })