opencastle 0.14.0 → 0.16.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/store.d.ts +1 -0
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +5 -0
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/run/schema.d.ts +5 -0
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +98 -143
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +53 -215
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +202 -104
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +2 -58
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/store.ts +7 -0
- package/src/cli/run/schema.test.ts +61 -241
- package/src/cli/run/schema.ts +105 -153
- package/src/cli/run.ts +216 -105
- package/src/cli/types.ts +2 -66
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/agents/team-lead.agent.md +2 -2
- package/src/orchestrator/prompts/generate-task-spec.prompt.md +26 -5
- package/dist/cli/run/loop-executor.d.ts +0 -3
- package/dist/cli/run/loop-executor.d.ts.map +0 -1
- package/dist/cli/run/loop-executor.js +0 -155
- package/dist/cli/run/loop-executor.js.map +0 -1
- package/dist/cli/run/loop-reporter.d.ts +0 -6
- package/dist/cli/run/loop-reporter.d.ts.map +0 -1
- package/dist/cli/run/loop-reporter.js +0 -112
- package/dist/cli/run/loop-reporter.js.map +0 -1
- package/src/cli/run/loop-executor.ts +0 -199
- package/src/cli/run/loop-reporter.ts +0 -125
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec } from './schema.js'
|
|
2
|
+
import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, parseTaskSpecText } from './schema.js'
|
|
3
3
|
|
|
4
4
|
// ── parseYaml ──────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -351,246 +351,6 @@ describe('applyDefaults', () => {
|
|
|
351
351
|
})
|
|
352
352
|
})
|
|
353
353
|
|
|
354
|
-
// ── loop mode — validateSpec ───────────────────────────────────
|
|
355
|
-
|
|
356
|
-
describe('validateSpec — loop mode', () => {
|
|
357
|
-
const validLoopSpec = {
|
|
358
|
-
name: 'build-auth',
|
|
359
|
-
mode: 'loop',
|
|
360
|
-
adapter: 'copilot',
|
|
361
|
-
loop: {
|
|
362
|
-
prompt: 'PROMPT_build.md',
|
|
363
|
-
plan_file: 'IMPLEMENTATION_PLAN.md',
|
|
364
|
-
max_iterations: 20,
|
|
365
|
-
timeout: '10m',
|
|
366
|
-
model: 'gpt-5.1',
|
|
367
|
-
backpressure: ['npm test', 'npx tsc --noEmit'],
|
|
368
|
-
},
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
it('accepts a valid minimal loop spec (only prompt required)', () => {
|
|
372
|
-
const result = validateSpec({
|
|
373
|
-
name: 'build-auth',
|
|
374
|
-
mode: 'loop',
|
|
375
|
-
loop: { prompt: 'PROMPT_build.md' },
|
|
376
|
-
})
|
|
377
|
-
expect(result.valid).toBe(true)
|
|
378
|
-
expect(result.errors).toHaveLength(0)
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
it('accepts a full loop spec', () => {
|
|
382
|
-
const result = validateSpec(validLoopSpec)
|
|
383
|
-
expect(result.valid).toBe(true)
|
|
384
|
-
expect(result.errors).toHaveLength(0)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
it('does not require tasks array in loop mode', () => {
|
|
388
|
-
const result = validateSpec({
|
|
389
|
-
name: 'build-auth',
|
|
390
|
-
mode: 'loop',
|
|
391
|
-
loop: { prompt: 'PROMPT_build.md' },
|
|
392
|
-
})
|
|
393
|
-
expect(result.valid).toBe(true)
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('fails when loop object is missing', () => {
|
|
397
|
-
const result = validateSpec({ name: 'build-auth', mode: 'loop' })
|
|
398
|
-
expect(result.valid).toBe(false)
|
|
399
|
-
expect(result.errors).toContainEqual(expect.stringContaining('`loop` is required'))
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
it('fails when loop.prompt is missing', () => {
|
|
403
|
-
const result = validateSpec({
|
|
404
|
-
name: 'build-auth',
|
|
405
|
-
mode: 'loop',
|
|
406
|
-
loop: { max_iterations: 10 },
|
|
407
|
-
})
|
|
408
|
-
expect(result.valid).toBe(false)
|
|
409
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
it('fails when loop.prompt is not a string', () => {
|
|
413
|
-
const result = validateSpec({
|
|
414
|
-
name: 'build-auth',
|
|
415
|
-
mode: 'loop',
|
|
416
|
-
loop: { prompt: 123 },
|
|
417
|
-
})
|
|
418
|
-
expect(result.valid).toBe(false)
|
|
419
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
|
|
420
|
-
})
|
|
421
|
-
|
|
422
|
-
it('fails when loop.max_iterations is 0', () => {
|
|
423
|
-
const result = validateSpec({
|
|
424
|
-
name: 'build-auth',
|
|
425
|
-
mode: 'loop',
|
|
426
|
-
loop: { prompt: 'PROMPT.md', max_iterations: 0 },
|
|
427
|
-
})
|
|
428
|
-
expect(result.valid).toBe(false)
|
|
429
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
it('fails when loop.max_iterations is a float', () => {
|
|
433
|
-
const result = validateSpec({
|
|
434
|
-
name: 'build-auth',
|
|
435
|
-
mode: 'loop',
|
|
436
|
-
loop: { prompt: 'PROMPT.md', max_iterations: 1.5 },
|
|
437
|
-
})
|
|
438
|
-
expect(result.valid).toBe(false)
|
|
439
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
it('fails when loop.timeout has invalid format', () => {
|
|
443
|
-
const result = validateSpec({
|
|
444
|
-
name: 'build-auth',
|
|
445
|
-
mode: 'loop',
|
|
446
|
-
loop: { prompt: 'PROMPT.md', timeout: 'bad' },
|
|
447
|
-
})
|
|
448
|
-
expect(result.valid).toBe(false)
|
|
449
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.timeout'))
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('accepts valid loop.timeout formats', () => {
|
|
453
|
-
for (const t of ['5s', '10m', '2h']) {
|
|
454
|
-
const result = validateSpec({
|
|
455
|
-
name: 'build-auth',
|
|
456
|
-
mode: 'loop',
|
|
457
|
-
loop: { prompt: 'PROMPT.md', timeout: t },
|
|
458
|
-
})
|
|
459
|
-
expect(result.valid).toBe(true)
|
|
460
|
-
}
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
it('fails when loop.backpressure is not an array', () => {
|
|
464
|
-
const result = validateSpec({
|
|
465
|
-
name: 'build-auth',
|
|
466
|
-
mode: 'loop',
|
|
467
|
-
loop: { prompt: 'PROMPT.md', backpressure: 'npm test' },
|
|
468
|
-
})
|
|
469
|
-
expect(result.valid).toBe(false)
|
|
470
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
it('fails when loop.backpressure contains non-strings', () => {
|
|
474
|
-
const result = validateSpec({
|
|
475
|
-
name: 'build-auth',
|
|
476
|
-
mode: 'loop',
|
|
477
|
-
loop: { prompt: 'PROMPT.md', backpressure: ['npm test', 42] },
|
|
478
|
-
})
|
|
479
|
-
expect(result.valid).toBe(false)
|
|
480
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
it('fails when loop.plan_file is not a string', () => {
|
|
484
|
-
const result = validateSpec({
|
|
485
|
-
name: 'build-auth',
|
|
486
|
-
mode: 'loop',
|
|
487
|
-
loop: { prompt: 'PROMPT.md', plan_file: true },
|
|
488
|
-
})
|
|
489
|
-
expect(result.valid).toBe(false)
|
|
490
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.plan_file'))
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
it('fails when loop.model is not a string', () => {
|
|
494
|
-
const result = validateSpec({
|
|
495
|
-
name: 'build-auth',
|
|
496
|
-
mode: 'loop',
|
|
497
|
-
loop: { prompt: 'PROMPT.md', model: 99 },
|
|
498
|
-
})
|
|
499
|
-
expect(result.valid).toBe(false)
|
|
500
|
-
expect(result.errors).toContainEqual(expect.stringContaining('loop.model'))
|
|
501
|
-
})
|
|
502
|
-
|
|
503
|
-
it('rejects unknown mode value', () => {
|
|
504
|
-
const result = validateSpec({
|
|
505
|
-
name: 'build-auth',
|
|
506
|
-
mode: 'parallel',
|
|
507
|
-
tasks: [{ id: 'a', prompt: 'x' }],
|
|
508
|
-
})
|
|
509
|
-
expect(result.valid).toBe(false)
|
|
510
|
-
expect(result.errors).toContainEqual(expect.stringContaining('mode'))
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
it('mode: tasks still requires tasks array', () => {
|
|
514
|
-
const result = validateSpec({ name: 'build-auth', mode: 'tasks' })
|
|
515
|
-
expect(result.valid).toBe(false)
|
|
516
|
-
expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
it('spec without mode field defaults to tasks behavior (requires tasks)', () => {
|
|
520
|
-
const result = validateSpec({ name: 'build-auth' })
|
|
521
|
-
expect(result.valid).toBe(false)
|
|
522
|
-
expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
|
|
523
|
-
})
|
|
524
|
-
})
|
|
525
|
-
|
|
526
|
-
// ── loop mode — applyDefaults ──────────────────────────────────
|
|
527
|
-
|
|
528
|
-
describe('applyDefaults — loop mode', () => {
|
|
529
|
-
it('applies default max_iterations', () => {
|
|
530
|
-
const spec = applyDefaults({
|
|
531
|
-
name: 'build-auth',
|
|
532
|
-
mode: 'loop',
|
|
533
|
-
loop: { prompt: 'PROMPT.md' },
|
|
534
|
-
})
|
|
535
|
-
expect(spec.loop?.max_iterations).toBe(20)
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
it('applies default plan_file', () => {
|
|
539
|
-
const spec = applyDefaults({
|
|
540
|
-
name: 'build-auth',
|
|
541
|
-
mode: 'loop',
|
|
542
|
-
loop: { prompt: 'PROMPT.md' },
|
|
543
|
-
})
|
|
544
|
-
expect(spec.loop?.plan_file).toBe('IMPLEMENTATION_PLAN.md')
|
|
545
|
-
})
|
|
546
|
-
|
|
547
|
-
it('applies default timeout', () => {
|
|
548
|
-
const spec = applyDefaults({
|
|
549
|
-
name: 'build-auth',
|
|
550
|
-
mode: 'loop',
|
|
551
|
-
loop: { prompt: 'PROMPT.md' },
|
|
552
|
-
})
|
|
553
|
-
expect(spec.loop?.timeout).toBe('10m')
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
it('preserves user-specified loop values', () => {
|
|
557
|
-
const spec = applyDefaults({
|
|
558
|
-
name: 'build-auth',
|
|
559
|
-
mode: 'loop',
|
|
560
|
-
loop: {
|
|
561
|
-
prompt: 'PROMPT.md',
|
|
562
|
-
max_iterations: 5,
|
|
563
|
-
plan_file: 'MY_PLAN.md',
|
|
564
|
-
timeout: '30m',
|
|
565
|
-
model: 'gpt-5.1',
|
|
566
|
-
backpressure: ['npm test'],
|
|
567
|
-
},
|
|
568
|
-
})
|
|
569
|
-
expect(spec.loop?.max_iterations).toBe(5)
|
|
570
|
-
expect(spec.loop?.plan_file).toBe('MY_PLAN.md')
|
|
571
|
-
expect(spec.loop?.timeout).toBe('30m')
|
|
572
|
-
expect(spec.loop?.model).toBe('gpt-5.1')
|
|
573
|
-
expect(spec.loop?.backpressure).toEqual(['npm test'])
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
it('sets mode to tasks when not specified', () => {
|
|
577
|
-
const spec = applyDefaults({
|
|
578
|
-
name: 'test',
|
|
579
|
-
tasks: [{ id: 'a', prompt: 'x' }],
|
|
580
|
-
})
|
|
581
|
-
expect(spec.mode).toBe('tasks')
|
|
582
|
-
})
|
|
583
|
-
|
|
584
|
-
it('preserves mode: loop', () => {
|
|
585
|
-
const spec = applyDefaults({
|
|
586
|
-
name: 'build-auth',
|
|
587
|
-
mode: 'loop',
|
|
588
|
-
loop: { prompt: 'PROMPT.md' },
|
|
589
|
-
})
|
|
590
|
-
expect(spec.mode).toBe('loop')
|
|
591
|
-
})
|
|
592
|
-
})
|
|
593
|
-
|
|
594
354
|
// ── validateSpec — version field ───────────────────────────────
|
|
595
355
|
|
|
596
356
|
describe('validateSpec — version field', () => {
|
|
@@ -1051,3 +811,63 @@ describe('backward compatibility — legacy specs', () => {
|
|
|
1051
811
|
expect(spec.tasks![0].model).toBeUndefined()
|
|
1052
812
|
})
|
|
1053
813
|
})
|
|
814
|
+
|
|
815
|
+
// ── parseTaskSpecText ──────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
describe('parseTaskSpecText', () => {
|
|
818
|
+
it('parses a valid YAML string and returns a TaskSpec', () => {
|
|
819
|
+
const yaml = `
|
|
820
|
+
name: test-run
|
|
821
|
+
tasks:
|
|
822
|
+
- id: task-1
|
|
823
|
+
prompt: Do something
|
|
824
|
+
`
|
|
825
|
+
const spec = parseTaskSpecText(yaml)
|
|
826
|
+
expect(spec.name).toBe('test-run')
|
|
827
|
+
expect(spec.tasks).toHaveLength(1)
|
|
828
|
+
expect(spec.tasks![0].id).toBe('task-1')
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('throws on empty string', () => {
|
|
832
|
+
expect(() => parseTaskSpecText('')).toThrow('empty')
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
it('throws on whitespace-only string', () => {
|
|
836
|
+
expect(() => parseTaskSpecText(' \n ')).toThrow('empty')
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('throws on invalid YAML', () => {
|
|
840
|
+
expect(() => parseTaskSpecText(': invalid: yaml: {')).toThrow(/YAML parse error/)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('throws on invalid spec (missing tasks)', () => {
|
|
844
|
+
expect(() => parseTaskSpecText('name: test')).toThrow(/Invalid task spec/)
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('applies defaults when parsing', () => {
|
|
848
|
+
const yaml = `
|
|
849
|
+
name: test-run
|
|
850
|
+
tasks:
|
|
851
|
+
- id: task-1
|
|
852
|
+
prompt: Do something
|
|
853
|
+
`
|
|
854
|
+
const spec = parseTaskSpecText(yaml)
|
|
855
|
+
expect(spec.concurrency).toBe(1)
|
|
856
|
+
expect(spec.on_failure).toBe('continue')
|
|
857
|
+
expect(spec.tasks![0].agent).toBe('developer')
|
|
858
|
+
expect(spec.tasks![0].timeout).toBe('30m')
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
it('parses a convoy spec (version: 1)', () => {
|
|
862
|
+
const yaml = `
|
|
863
|
+
name: convoy-run
|
|
864
|
+
version: 1
|
|
865
|
+
tasks:
|
|
866
|
+
- id: task-1
|
|
867
|
+
prompt: Do something
|
|
868
|
+
`
|
|
869
|
+
const spec = parseTaskSpecText(yaml)
|
|
870
|
+
expect(spec.version).toBe(1)
|
|
871
|
+
expect(isConvoySpec(spec)).toBe(true)
|
|
872
|
+
})
|
|
873
|
+
})
|
package/src/cli/run/schema.ts
CHANGED
|
@@ -39,8 +39,6 @@ interface RawSpec {
|
|
|
39
39
|
on_failure?: unknown
|
|
40
40
|
adapter?: unknown
|
|
41
41
|
tasks?: unknown
|
|
42
|
-
mode?: unknown
|
|
43
|
-
loop?: unknown
|
|
44
42
|
version?: unknown
|
|
45
43
|
defaults?: unknown
|
|
46
44
|
gates?: unknown
|
|
@@ -146,132 +144,87 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
146
144
|
errors.push('`branch` must be a string')
|
|
147
145
|
}
|
|
148
146
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
// Tasks are always required
|
|
148
|
+
if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
|
|
149
|
+
errors.push('`tasks` is required and must be a non-empty array')
|
|
150
|
+
return { valid: false, errors }
|
|
153
151
|
}
|
|
154
152
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const loop = s.loop as Record<string, unknown> | undefined
|
|
158
|
-
if (!loop || typeof loop !== 'object') {
|
|
159
|
-
errors.push('`loop` is required when mode is "loop"')
|
|
160
|
-
} else {
|
|
161
|
-
if (!loop.prompt || typeof loop.prompt !== 'string') {
|
|
162
|
-
errors.push('`loop.prompt` is required and must be a string')
|
|
163
|
-
}
|
|
164
|
-
if (loop.max_iterations !== undefined) {
|
|
165
|
-
const mi = Number(loop.max_iterations)
|
|
166
|
-
if (!Number.isInteger(mi) || mi < 1) {
|
|
167
|
-
errors.push('`loop.max_iterations` must be an integer >= 1')
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (loop.timeout !== undefined) {
|
|
171
|
-
if (isNaN(parseTimeout(loop.timeout as string))) {
|
|
172
|
-
errors.push(
|
|
173
|
-
'`loop.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
if (loop.backpressure !== undefined) {
|
|
178
|
-
if (
|
|
179
|
-
!Array.isArray(loop.backpressure) ||
|
|
180
|
-
!(loop.backpressure as unknown[]).every((b) => typeof b === 'string')
|
|
181
|
-
) {
|
|
182
|
-
errors.push('`loop.backpressure` must be an array of strings')
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (loop.plan_file !== undefined && typeof loop.plan_file !== 'string') {
|
|
186
|
-
errors.push('`loop.plan_file` must be a string')
|
|
187
|
-
}
|
|
188
|
-
if (loop.model !== undefined && typeof loop.model !== 'string') {
|
|
189
|
-
errors.push('`loop.model` must be a string')
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
// Tasks mode — tasks array is required
|
|
194
|
-
if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
|
|
195
|
-
errors.push('`tasks` is required and must be a non-empty array')
|
|
196
|
-
return { valid: false, errors }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const taskIds = new Set<string>()
|
|
200
|
-
const tasks = s.tasks as RawTask[]
|
|
153
|
+
const taskIds = new Set<string>()
|
|
154
|
+
const tasks = s.tasks as RawTask[]
|
|
201
155
|
|
|
202
|
-
|
|
156
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
203
157
|
const task = tasks[i]
|
|
204
158
|
const prefix = `tasks[${i}]`
|
|
205
159
|
|
|
206
160
|
if (!task || typeof task !== 'object') {
|
|
207
161
|
errors.push(`${prefix}: must be an object`)
|
|
208
|
-
|
|
209
|
-
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
210
164
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
165
|
+
// id
|
|
166
|
+
if (!task.id || typeof task.id !== 'string') {
|
|
167
|
+
errors.push(`${prefix}: \`id\` is required and must be a string`)
|
|
168
|
+
} else if (taskIds.has(task.id)) {
|
|
169
|
+
errors.push(`${prefix}: duplicate task id "${task.id}"`)
|
|
170
|
+
} else {
|
|
171
|
+
taskIds.add(task.id)
|
|
172
|
+
}
|
|
219
173
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
174
|
+
// prompt
|
|
175
|
+
if (!task.prompt || typeof task.prompt !== 'string') {
|
|
176
|
+
errors.push(`${prefix}: \`prompt\` is required and must be a string`)
|
|
177
|
+
}
|
|
224
178
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
179
|
+
// timeout
|
|
180
|
+
if (task.timeout !== undefined) {
|
|
181
|
+
if (isNaN(parseTimeout(task.timeout as string))) {
|
|
182
|
+
errors.push(
|
|
183
|
+
`${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
|
|
184
|
+
)
|
|
232
185
|
}
|
|
186
|
+
}
|
|
233
187
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
188
|
+
// depends_on
|
|
189
|
+
if (task.depends_on !== undefined) {
|
|
190
|
+
if (!Array.isArray(task.depends_on)) {
|
|
191
|
+
errors.push(`${prefix}: \`depends_on\` must be an array`)
|
|
192
|
+
} else {
|
|
193
|
+
for (const dep of task.depends_on as string[]) {
|
|
194
|
+
if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
|
|
195
|
+
errors.push(
|
|
196
|
+
`${prefix}: \`depends_on\` references unknown task "${dep}"`
|
|
197
|
+
)
|
|
245
198
|
}
|
|
246
199
|
}
|
|
247
200
|
}
|
|
201
|
+
}
|
|
248
202
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
203
|
+
// files
|
|
204
|
+
if (task.files !== undefined && !Array.isArray(task.files)) {
|
|
205
|
+
errors.push(`${prefix}: \`files\` must be an array`)
|
|
206
|
+
}
|
|
253
207
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
208
|
+
// model
|
|
209
|
+
if (task.model !== undefined && typeof task.model !== 'string') {
|
|
210
|
+
errors.push(`${prefix}: \`model\` must be a string`)
|
|
211
|
+
}
|
|
258
212
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
213
|
+
// max_retries
|
|
214
|
+
if (task.max_retries !== undefined) {
|
|
215
|
+
const mr = Number(task.max_retries)
|
|
216
|
+
if (!Number.isInteger(mr) || mr < 0) {
|
|
217
|
+
errors.push(
|
|
218
|
+
`${prefix}: \`max_retries\` must be a non-negative integer`
|
|
219
|
+
)
|
|
267
220
|
}
|
|
268
221
|
}
|
|
222
|
+
}
|
|
269
223
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
224
|
+
// DAG cycle detection
|
|
225
|
+
if (errors.length === 0) {
|
|
226
|
+
const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
|
|
227
|
+
if (cycleErr) errors.push(cycleErr)
|
|
275
228
|
}
|
|
276
229
|
|
|
277
230
|
return { valid: errors.length === 0, errors }
|
|
@@ -330,39 +283,30 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
|
|
|
330
283
|
s.on_failure = (s.on_failure as string) || 'continue'
|
|
331
284
|
// Leave adapter empty so run.ts can auto-detect the best available CLI
|
|
332
285
|
s.adapter = (s.adapter as string) || ''
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
task.
|
|
356
|
-
|
|
357
|
-
// model: task-level overrides defaults (no hardcoded fallback)
|
|
358
|
-
if (task.model === undefined && d.model !== undefined) {
|
|
359
|
-
task.model = d.model
|
|
360
|
-
}
|
|
361
|
-
// max_retries: task-level overrides defaults, fallback to 1
|
|
362
|
-
if (task.max_retries === undefined) {
|
|
363
|
-
task.max_retries =
|
|
364
|
-
d.max_retries !== undefined ? Number(d.max_retries) : 1
|
|
365
|
-
}
|
|
286
|
+
|
|
287
|
+
const tasks = s.tasks as Array<Record<string, unknown>>
|
|
288
|
+
const d =
|
|
289
|
+
s.version === 1 && s.defaults
|
|
290
|
+
? (s.defaults as Record<string, unknown>)
|
|
291
|
+
: {}
|
|
292
|
+
for (const task of tasks) {
|
|
293
|
+
task.agent =
|
|
294
|
+
(task.agent as string) || (d.agent as string | undefined) || 'developer'
|
|
295
|
+
task.timeout =
|
|
296
|
+
(task.timeout as string) ||
|
|
297
|
+
(d.timeout as string | undefined) ||
|
|
298
|
+
'30m'
|
|
299
|
+
task.depends_on = (task.depends_on as string[]) || []
|
|
300
|
+
task.files = (task.files as string[]) || []
|
|
301
|
+
task.description = (task.description as string) || (task.id as string)
|
|
302
|
+
// model: task-level overrides defaults (no hardcoded fallback)
|
|
303
|
+
if (task.model === undefined && d.model !== undefined) {
|
|
304
|
+
task.model = d.model
|
|
305
|
+
}
|
|
306
|
+
// max_retries: task-level overrides defaults, fallback to 1
|
|
307
|
+
if (task.max_retries === undefined) {
|
|
308
|
+
task.max_retries =
|
|
309
|
+
d.max_retries !== undefined ? Number(d.max_retries) : 1
|
|
366
310
|
}
|
|
367
311
|
}
|
|
368
312
|
|
|
@@ -378,21 +322,10 @@ export function isConvoySpec(spec: unknown): boolean {
|
|
|
378
322
|
}
|
|
379
323
|
|
|
380
324
|
/**
|
|
381
|
-
*
|
|
382
|
-
* @throws If
|
|
325
|
+
* Parse, validate, and return a typed task spec from a YAML string.
|
|
326
|
+
* @throws If the text is empty, cannot be parsed, or spec is invalid
|
|
383
327
|
*/
|
|
384
|
-
export
|
|
385
|
-
let text: string
|
|
386
|
-
try {
|
|
387
|
-
text = await readFile(filePath, 'utf8')
|
|
388
|
-
} catch (err: unknown) {
|
|
389
|
-
const e = err as Error & { code?: string }
|
|
390
|
-
if (e.code === 'ENOENT') {
|
|
391
|
-
throw new Error(`Task spec file not found: ${filePath}`)
|
|
392
|
-
}
|
|
393
|
-
throw new Error(`Cannot read task spec file: ${e.message}`)
|
|
394
|
-
}
|
|
395
|
-
|
|
328
|
+
export function parseTaskSpecText(text: string): TaskSpec {
|
|
396
329
|
if (!text.trim()) {
|
|
397
330
|
throw new Error('Task spec file is empty')
|
|
398
331
|
}
|
|
@@ -411,3 +344,22 @@ export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
|
|
|
411
344
|
|
|
412
345
|
return applyDefaults(spec)
|
|
413
346
|
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Read, parse, validate, and return a typed task spec from a YAML file.
|
|
350
|
+
* @throws If file cannot be read, parsed, or spec is invalid
|
|
351
|
+
*/
|
|
352
|
+
export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
|
|
353
|
+
let text: string
|
|
354
|
+
try {
|
|
355
|
+
text = await readFile(filePath, 'utf8')
|
|
356
|
+
} catch (err: unknown) {
|
|
357
|
+
const e = err as Error & { code?: string }
|
|
358
|
+
if (e.code === 'ENOENT') {
|
|
359
|
+
throw new Error(`Task spec file not found: ${filePath}`)
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`Cannot read task spec file: ${e.message}`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return parseTaskSpecText(text)
|
|
365
|
+
}
|