opencastle 0.11.0 → 0.12.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/events.d.ts +10 -0
- package/dist/cli/convoy/events.d.ts.map +1 -0
- package/dist/cli/convoy/events.js +27 -0
- package/dist/cli/convoy/events.js.map +1 -0
- package/dist/cli/convoy/events.test.d.ts +2 -0
- package/dist/cli/convoy/events.test.d.ts.map +1 -0
- package/dist/cli/convoy/events.test.js +94 -0
- package/dist/cli/convoy/events.test.js.map +1 -0
- package/dist/cli/convoy/store.d.ts +23 -0
- package/dist/cli/convoy/store.d.ts.map +1 -0
- package/dist/cli/convoy/store.js +210 -0
- package/dist/cli/convoy/store.js.map +1 -0
- package/dist/cli/convoy/store.test.d.ts +2 -0
- package/dist/cli/convoy/store.test.d.ts.map +1 -0
- package/dist/cli/convoy/store.test.js +387 -0
- package/dist/cli/convoy/store.test.js.map +1 -0
- package/dist/cli/convoy/types.d.ts +56 -0
- package/dist/cli/convoy/types.d.ts.map +1 -0
- package/dist/cli/convoy/types.js +2 -0
- package/dist/cli/convoy/types.js.map +1 -0
- package/dist/cli/run/executor.test.js +1 -0
- package/dist/cli/run/executor.test.js.map +1 -1
- package/dist/cli/run/loop-executor.d.ts.map +1 -1
- package/dist/cli/run/loop-executor.js +1 -0
- package/dist/cli/run/loop-executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts +4 -0
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +78 -2
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +384 -1
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/types.d.ts +19 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/cli/convoy/events.test.ts +118 -0
- package/src/cli/convoy/events.ts +41 -0
- package/src/cli/convoy/store.test.ts +446 -0
- package/src/cli/convoy/store.ts +308 -0
- package/src/cli/convoy/types.ts +68 -0
- package/src/cli/run/executor.test.ts +1 -0
- package/src/cli/run/loop-executor.ts +1 -0
- package/src/cli/run/schema.test.ts +462 -1
- package/src/cli/run/schema.ts +96 -2
- package/src/cli/types.ts +20 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseYaml, parseTimeout, validateSpec, applyDefaults } from './schema.js'
|
|
2
|
+
import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec } from './schema.js'
|
|
3
3
|
|
|
4
4
|
// ── parseYaml ──────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -590,3 +590,464 @@ describe('applyDefaults — loop mode', () => {
|
|
|
590
590
|
expect(spec.mode).toBe('loop')
|
|
591
591
|
})
|
|
592
592
|
})
|
|
593
|
+
|
|
594
|
+
// ── validateSpec — version field ───────────────────────────────
|
|
595
|
+
|
|
596
|
+
describe('validateSpec — version field', () => {
|
|
597
|
+
const validSpec = {
|
|
598
|
+
name: 'test-run',
|
|
599
|
+
tasks: [{ id: 'task-1', prompt: 'Do something' }],
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
it('accepts version 1', () => {
|
|
603
|
+
const result = validateSpec({ ...validSpec, version: 1 })
|
|
604
|
+
expect(result.valid).toBe(true)
|
|
605
|
+
expect(result.errors).toHaveLength(0)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('rejects version 2', () => {
|
|
609
|
+
const result = validateSpec({ ...validSpec, version: 2 })
|
|
610
|
+
expect(result.valid).toBe(false)
|
|
611
|
+
expect(result.errors).toContainEqual(expect.stringContaining('version'))
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('rejects non-integer version', () => {
|
|
615
|
+
const result = validateSpec({ ...validSpec, version: 1.5 })
|
|
616
|
+
expect(result.valid).toBe(false)
|
|
617
|
+
expect(result.errors).toContainEqual(expect.stringContaining('version'))
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('rejects string version', () => {
|
|
621
|
+
const result = validateSpec({ ...validSpec, version: '1' })
|
|
622
|
+
expect(result.valid).toBe(false)
|
|
623
|
+
expect(result.errors).toContainEqual(expect.stringContaining('version'))
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('omitting version is valid (legacy spec)', () => {
|
|
627
|
+
const result = validateSpec(validSpec)
|
|
628
|
+
expect(result.valid).toBe(true)
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// ── validateSpec — defaults block ──────────────────────────────
|
|
633
|
+
|
|
634
|
+
describe('validateSpec — defaults block', () => {
|
|
635
|
+
const validSpec = {
|
|
636
|
+
name: 'test-run',
|
|
637
|
+
version: 1,
|
|
638
|
+
tasks: [{ id: 'task-1', prompt: 'Do something' }],
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
it('accepts a fully specified defaults block', () => {
|
|
642
|
+
const result = validateSpec({
|
|
643
|
+
...validSpec,
|
|
644
|
+
defaults: { timeout: '10m', model: 'gpt-4', max_retries: 2, agent: 'developer' },
|
|
645
|
+
})
|
|
646
|
+
expect(result.valid).toBe(true)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('accepts partial defaults block', () => {
|
|
650
|
+
const result = validateSpec({ ...validSpec, defaults: { timeout: '5m' } })
|
|
651
|
+
expect(result.valid).toBe(true)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('rejects defaults as a non-object', () => {
|
|
655
|
+
const result = validateSpec({ ...validSpec, defaults: 'invalid' })
|
|
656
|
+
expect(result.valid).toBe(false)
|
|
657
|
+
expect(result.errors).toContainEqual(expect.stringContaining('defaults'))
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('rejects defaults as an array', () => {
|
|
661
|
+
const result = validateSpec({ ...validSpec, defaults: ['timeout', '10m'] })
|
|
662
|
+
expect(result.valid).toBe(false)
|
|
663
|
+
expect(result.errors).toContainEqual(expect.stringContaining('defaults'))
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('rejects defaults with invalid timeout format', () => {
|
|
667
|
+
const result = validateSpec({ ...validSpec, defaults: { timeout: 'bad' } })
|
|
668
|
+
expect(result.valid).toBe(false)
|
|
669
|
+
expect(result.errors).toContainEqual(
|
|
670
|
+
expect.stringContaining('defaults.timeout')
|
|
671
|
+
)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('rejects defaults with non-string model', () => {
|
|
675
|
+
const result = validateSpec({ ...validSpec, defaults: { model: 42 } })
|
|
676
|
+
expect(result.valid).toBe(false)
|
|
677
|
+
expect(result.errors).toContainEqual(
|
|
678
|
+
expect.stringContaining('defaults.model')
|
|
679
|
+
)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('rejects defaults with negative max_retries', () => {
|
|
683
|
+
const result = validateSpec({ ...validSpec, defaults: { max_retries: -1 } })
|
|
684
|
+
expect(result.valid).toBe(false)
|
|
685
|
+
expect(result.errors).toContainEqual(
|
|
686
|
+
expect.stringContaining('defaults.max_retries')
|
|
687
|
+
)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('rejects defaults with non-integer max_retries', () => {
|
|
691
|
+
const result = validateSpec({ ...validSpec, defaults: { max_retries: 1.5 } })
|
|
692
|
+
expect(result.valid).toBe(false)
|
|
693
|
+
expect(result.errors).toContainEqual(
|
|
694
|
+
expect.stringContaining('defaults.max_retries')
|
|
695
|
+
)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('accepts defaults.max_retries of 0', () => {
|
|
699
|
+
const result = validateSpec({ ...validSpec, defaults: { max_retries: 0 } })
|
|
700
|
+
expect(result.valid).toBe(true)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('rejects defaults with non-string agent', () => {
|
|
704
|
+
const result = validateSpec({ ...validSpec, defaults: { agent: 99 } })
|
|
705
|
+
expect(result.valid).toBe(false)
|
|
706
|
+
expect(result.errors).toContainEqual(
|
|
707
|
+
expect.stringContaining('defaults.agent')
|
|
708
|
+
)
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
// ── validateSpec — gates field ─────────────────────────────────
|
|
713
|
+
|
|
714
|
+
describe('validateSpec — gates field', () => {
|
|
715
|
+
const validSpec = {
|
|
716
|
+
name: 'test-run',
|
|
717
|
+
tasks: [{ id: 'task-1', prompt: 'Do something' }],
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
it('accepts a valid gates array', () => {
|
|
721
|
+
const result = validateSpec({
|
|
722
|
+
...validSpec,
|
|
723
|
+
gates: ['npm test', 'npx tsc --noEmit'],
|
|
724
|
+
})
|
|
725
|
+
expect(result.valid).toBe(true)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('accepts an empty gates array', () => {
|
|
729
|
+
const result = validateSpec({ ...validSpec, gates: [] })
|
|
730
|
+
expect(result.valid).toBe(true)
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('rejects gates as a string', () => {
|
|
734
|
+
const result = validateSpec({ ...validSpec, gates: 'npm test' })
|
|
735
|
+
expect(result.valid).toBe(false)
|
|
736
|
+
expect(result.errors).toContainEqual(expect.stringContaining('gates'))
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('rejects gates with non-string items', () => {
|
|
740
|
+
const result = validateSpec({ ...validSpec, gates: ['npm test', 42] })
|
|
741
|
+
expect(result.valid).toBe(false)
|
|
742
|
+
expect(result.errors).toContainEqual(expect.stringContaining('gates'))
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
// ── validateSpec — branch field ────────────────────────────────
|
|
747
|
+
|
|
748
|
+
describe('validateSpec — branch field', () => {
|
|
749
|
+
const validSpec = {
|
|
750
|
+
name: 'test-run',
|
|
751
|
+
tasks: [{ id: 'task-1', prompt: 'Do something' }],
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
it('accepts a valid branch string', () => {
|
|
755
|
+
const result = validateSpec({ ...validSpec, branch: 'feat/my-feature' })
|
|
756
|
+
expect(result.valid).toBe(true)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it('rejects non-string branch', () => {
|
|
760
|
+
const result = validateSpec({ ...validSpec, branch: 123 })
|
|
761
|
+
expect(result.valid).toBe(false)
|
|
762
|
+
expect(result.errors).toContainEqual(expect.stringContaining('branch'))
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('rejects boolean branch', () => {
|
|
766
|
+
const result = validateSpec({ ...validSpec, branch: true })
|
|
767
|
+
expect(result.valid).toBe(false)
|
|
768
|
+
expect(result.errors).toContainEqual(expect.stringContaining('branch'))
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
// ── validateSpec — per-task model and max_retries ──────────────
|
|
773
|
+
|
|
774
|
+
describe('validateSpec — per-task model and max_retries', () => {
|
|
775
|
+
it('accepts valid task model string', () => {
|
|
776
|
+
const result = validateSpec({
|
|
777
|
+
name: 'test',
|
|
778
|
+
tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
|
|
779
|
+
})
|
|
780
|
+
expect(result.valid).toBe(true)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('rejects non-string task model', () => {
|
|
784
|
+
const result = validateSpec({
|
|
785
|
+
name: 'test',
|
|
786
|
+
tasks: [{ id: 'a', prompt: 'x', model: 123 }],
|
|
787
|
+
})
|
|
788
|
+
expect(result.valid).toBe(false)
|
|
789
|
+
expect(result.errors).toContainEqual(expect.stringContaining('model'))
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('accepts valid task max_retries', () => {
|
|
793
|
+
const result = validateSpec({
|
|
794
|
+
name: 'test',
|
|
795
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: 3 }],
|
|
796
|
+
})
|
|
797
|
+
expect(result.valid).toBe(true)
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('accepts zero max_retries', () => {
|
|
801
|
+
const result = validateSpec({
|
|
802
|
+
name: 'test',
|
|
803
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
|
|
804
|
+
})
|
|
805
|
+
expect(result.valid).toBe(true)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('rejects negative max_retries', () => {
|
|
809
|
+
const result = validateSpec({
|
|
810
|
+
name: 'test',
|
|
811
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: -1 }],
|
|
812
|
+
})
|
|
813
|
+
expect(result.valid).toBe(false)
|
|
814
|
+
expect(result.errors).toContainEqual(expect.stringContaining('max_retries'))
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('rejects non-integer max_retries', () => {
|
|
818
|
+
const result = validateSpec({
|
|
819
|
+
name: 'test',
|
|
820
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: 1.5 }],
|
|
821
|
+
})
|
|
822
|
+
expect(result.valid).toBe(false)
|
|
823
|
+
expect(result.errors).toContainEqual(expect.stringContaining('max_retries'))
|
|
824
|
+
})
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
// ── isConvoySpec ───────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
describe('isConvoySpec', () => {
|
|
830
|
+
it('returns true for version 1 spec', () => {
|
|
831
|
+
expect(
|
|
832
|
+
isConvoySpec({ name: 'test', version: 1, tasks: [{ id: 'a', prompt: 'x' }] })
|
|
833
|
+
).toBe(true)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('returns false for legacy spec without version', () => {
|
|
837
|
+
expect(
|
|
838
|
+
isConvoySpec({ name: 'test', tasks: [{ id: 'a', prompt: 'x' }] })
|
|
839
|
+
).toBe(false)
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('returns false for version 2', () => {
|
|
843
|
+
expect(isConvoySpec({ name: 'test', version: 2 })).toBe(false)
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('returns false for null input', () => {
|
|
847
|
+
expect(isConvoySpec(null)).toBe(false)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('returns false for non-object input', () => {
|
|
851
|
+
expect(isConvoySpec('string')).toBe(false)
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('returns false for undefined', () => {
|
|
855
|
+
expect(isConvoySpec(undefined)).toBe(false)
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
// ── applyDefaults — convoy spec (version: 1) ───────────────────
|
|
860
|
+
|
|
861
|
+
describe('applyDefaults — convoy spec (version: 1)', () => {
|
|
862
|
+
it('merges defaults.agent into tasks when not specified', () => {
|
|
863
|
+
const spec = applyDefaults({
|
|
864
|
+
name: 'test',
|
|
865
|
+
version: 1,
|
|
866
|
+
defaults: { agent: 'ui-ux-expert' },
|
|
867
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
868
|
+
})
|
|
869
|
+
expect(spec.tasks![0].agent).toBe('ui-ux-expert')
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('task-level agent overrides default', () => {
|
|
873
|
+
const spec = applyDefaults({
|
|
874
|
+
name: 'test',
|
|
875
|
+
version: 1,
|
|
876
|
+
defaults: { agent: 'ui-ux-expert' },
|
|
877
|
+
tasks: [{ id: 'a', prompt: 'x', agent: 'api-designer' }],
|
|
878
|
+
})
|
|
879
|
+
expect(spec.tasks![0].agent).toBe('api-designer')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('merges defaults.timeout into tasks', () => {
|
|
883
|
+
const spec = applyDefaults({
|
|
884
|
+
name: 'test',
|
|
885
|
+
version: 1,
|
|
886
|
+
defaults: { timeout: '15m' },
|
|
887
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
888
|
+
})
|
|
889
|
+
expect(spec.tasks![0].timeout).toBe('15m')
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('task-level timeout overrides defaults.timeout', () => {
|
|
893
|
+
const spec = applyDefaults({
|
|
894
|
+
name: 'test',
|
|
895
|
+
version: 1,
|
|
896
|
+
defaults: { timeout: '15m' },
|
|
897
|
+
tasks: [{ id: 'a', prompt: 'x', timeout: '5m' }],
|
|
898
|
+
})
|
|
899
|
+
expect(spec.tasks![0].timeout).toBe('5m')
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('merges defaults.model into tasks', () => {
|
|
903
|
+
const spec = applyDefaults({
|
|
904
|
+
name: 'test',
|
|
905
|
+
version: 1,
|
|
906
|
+
defaults: { model: 'gpt-4' },
|
|
907
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
908
|
+
})
|
|
909
|
+
expect(spec.tasks![0].model).toBe('gpt-4')
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('task-level model overrides defaults.model', () => {
|
|
913
|
+
const spec = applyDefaults({
|
|
914
|
+
name: 'test',
|
|
915
|
+
version: 1,
|
|
916
|
+
defaults: { model: 'gpt-4' },
|
|
917
|
+
tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
|
|
918
|
+
})
|
|
919
|
+
expect(spec.tasks![0].model).toBe('claude-3.5-sonnet')
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('no model set when no defaults.model and no task model', () => {
|
|
923
|
+
const spec = applyDefaults({
|
|
924
|
+
name: 'test',
|
|
925
|
+
version: 1,
|
|
926
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
927
|
+
})
|
|
928
|
+
expect(spec.tasks![0].model).toBeUndefined()
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
it('merges defaults.max_retries into tasks', () => {
|
|
932
|
+
const spec = applyDefaults({
|
|
933
|
+
name: 'test',
|
|
934
|
+
version: 1,
|
|
935
|
+
defaults: { max_retries: 3 },
|
|
936
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
937
|
+
})
|
|
938
|
+
expect(spec.tasks![0].max_retries).toBe(3)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('task-level max_retries overrides defaults.max_retries', () => {
|
|
942
|
+
const spec = applyDefaults({
|
|
943
|
+
name: 'test',
|
|
944
|
+
version: 1,
|
|
945
|
+
defaults: { max_retries: 3 },
|
|
946
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
|
|
947
|
+
})
|
|
948
|
+
expect(spec.tasks![0].max_retries).toBe(0)
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
it('propagates version and defaults fields through to spec', () => {
|
|
952
|
+
const spec = applyDefaults({
|
|
953
|
+
name: 'test',
|
|
954
|
+
version: 1,
|
|
955
|
+
defaults: { model: 'gpt-4' },
|
|
956
|
+
gates: ['npm test'],
|
|
957
|
+
branch: 'feat/convoy',
|
|
958
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
959
|
+
})
|
|
960
|
+
expect(spec.version).toBe(1)
|
|
961
|
+
expect(spec.gates).toEqual(['npm test'])
|
|
962
|
+
expect(spec.branch).toBe('feat/convoy')
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
// ── applyDefaults — max_retries default always applied ─────────
|
|
967
|
+
|
|
968
|
+
describe('applyDefaults — max_retries always applied', () => {
|
|
969
|
+
it('applies max_retries default of 1 for legacy spec', () => {
|
|
970
|
+
const spec = applyDefaults({
|
|
971
|
+
name: 'test',
|
|
972
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
973
|
+
})
|
|
974
|
+
expect(spec.tasks![0].max_retries).toBe(1)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('applies max_retries default of 1 when version:1 has no defaults block', () => {
|
|
978
|
+
const spec = applyDefaults({
|
|
979
|
+
name: 'test',
|
|
980
|
+
version: 1,
|
|
981
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
982
|
+
})
|
|
983
|
+
expect(spec.tasks![0].max_retries).toBe(1)
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('preserves explicit task max_retries in legacy spec', () => {
|
|
987
|
+
const spec = applyDefaults({
|
|
988
|
+
name: 'test',
|
|
989
|
+
tasks: [{ id: 'a', prompt: 'x', max_retries: 5 }],
|
|
990
|
+
})
|
|
991
|
+
expect(spec.tasks![0].max_retries).toBe(5)
|
|
992
|
+
})
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
// ── backward compatibility ─────────────────────────────────────
|
|
996
|
+
|
|
997
|
+
describe('backward compatibility — legacy specs', () => {
|
|
998
|
+
it('legacy spec validates identically without version field', () => {
|
|
999
|
+
const result = validateSpec({
|
|
1000
|
+
name: 'test-run',
|
|
1001
|
+
tasks: [
|
|
1002
|
+
{ id: 'task-1', prompt: 'Do something' },
|
|
1003
|
+
{ id: 'task-2', prompt: 'Do another thing', depends_on: ['task-1'] },
|
|
1004
|
+
],
|
|
1005
|
+
})
|
|
1006
|
+
expect(result.valid).toBe(true)
|
|
1007
|
+
expect(result.errors).toHaveLength(0)
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
it('legacy spec applyDefaults produces same agent/timeout/depends_on/files as before', () => {
|
|
1011
|
+
const spec = applyDefaults({
|
|
1012
|
+
name: 'test',
|
|
1013
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
1014
|
+
})
|
|
1015
|
+
const task = spec.tasks![0]
|
|
1016
|
+
expect(task.agent).toBe('developer')
|
|
1017
|
+
expect(task.timeout).toBe('30m')
|
|
1018
|
+
expect(task.depends_on).toEqual([])
|
|
1019
|
+
expect(task.files).toEqual([])
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
it('user-specified legacy task values are preserved', () => {
|
|
1023
|
+
const spec = applyDefaults({
|
|
1024
|
+
name: 'test',
|
|
1025
|
+
tasks: [{
|
|
1026
|
+
id: 'a',
|
|
1027
|
+
prompt: 'x',
|
|
1028
|
+
agent: 'ui-ux-expert',
|
|
1029
|
+
timeout: '5m',
|
|
1030
|
+
depends_on: ['b'],
|
|
1031
|
+
files: ['src/'],
|
|
1032
|
+
}],
|
|
1033
|
+
})
|
|
1034
|
+
const task = spec.tasks![0]
|
|
1035
|
+
expect(task.agent).toBe('ui-ux-expert')
|
|
1036
|
+
expect(task.timeout).toBe('5m')
|
|
1037
|
+
expect(task.depends_on).toEqual(['b'])
|
|
1038
|
+
expect(task.files).toEqual(['src/'])
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
it('defaults block is ignored without version:1', () => {
|
|
1042
|
+
// Without version:1, the defaults block should not be merged
|
|
1043
|
+
const spec = applyDefaults({
|
|
1044
|
+
name: 'test',
|
|
1045
|
+
defaults: { agent: 'ui-ux-expert', model: 'gpt-4' },
|
|
1046
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
1047
|
+
})
|
|
1048
|
+
// agent falls back to hardcoded 'developer', not defaults.agent
|
|
1049
|
+
expect(spec.tasks![0].agent).toBe('developer')
|
|
1050
|
+
// model is not set
|
|
1051
|
+
expect(spec.tasks![0].model).toBeUndefined()
|
|
1052
|
+
})
|
|
1053
|
+
})
|
package/src/cli/run/schema.ts
CHANGED
|
@@ -41,6 +41,10 @@ interface RawSpec {
|
|
|
41
41
|
tasks?: unknown
|
|
42
42
|
mode?: unknown
|
|
43
43
|
loop?: unknown
|
|
44
|
+
version?: unknown
|
|
45
|
+
defaults?: unknown
|
|
46
|
+
gates?: unknown
|
|
47
|
+
branch?: unknown
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
interface RawTask {
|
|
@@ -51,6 +55,8 @@ interface RawTask {
|
|
|
51
55
|
depends_on?: unknown
|
|
52
56
|
files?: unknown
|
|
53
57
|
description?: unknown
|
|
58
|
+
model?: unknown
|
|
59
|
+
max_retries?: unknown
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
/**
|
|
@@ -92,6 +98,54 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
92
98
|
errors.push('`adapter` must be a string')
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
// version
|
|
102
|
+
if (s.version !== undefined) {
|
|
103
|
+
if (typeof s.version !== 'number' || !Number.isInteger(s.version) || s.version !== 1) {
|
|
104
|
+
errors.push('`version` must be 1')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// defaults
|
|
109
|
+
if (s.defaults !== undefined) {
|
|
110
|
+
if (!s.defaults || typeof s.defaults !== 'object' || Array.isArray(s.defaults)) {
|
|
111
|
+
errors.push('`defaults` must be an object')
|
|
112
|
+
} else {
|
|
113
|
+
const d = s.defaults as Record<string, unknown>
|
|
114
|
+
if (d.timeout !== undefined && isNaN(parseTimeout(d.timeout as string))) {
|
|
115
|
+
errors.push(
|
|
116
|
+
'`defaults.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (d.model !== undefined && typeof d.model !== 'string') {
|
|
120
|
+
errors.push('`defaults.model` must be a string')
|
|
121
|
+
}
|
|
122
|
+
if (d.max_retries !== undefined) {
|
|
123
|
+
const mr = Number(d.max_retries)
|
|
124
|
+
if (!Number.isInteger(mr) || mr < 0) {
|
|
125
|
+
errors.push('`defaults.max_retries` must be a non-negative integer')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (d.agent !== undefined && typeof d.agent !== 'string') {
|
|
129
|
+
errors.push('`defaults.agent` must be a string')
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// gates
|
|
135
|
+
if (s.gates !== undefined) {
|
|
136
|
+
if (
|
|
137
|
+
!Array.isArray(s.gates) ||
|
|
138
|
+
!(s.gates as unknown[]).every((g) => typeof g === 'string')
|
|
139
|
+
) {
|
|
140
|
+
errors.push('`gates` must be an array of strings')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// branch
|
|
145
|
+
if (s.branch !== undefined && typeof s.branch !== 'string') {
|
|
146
|
+
errors.push('`branch` must be a string')
|
|
147
|
+
}
|
|
148
|
+
|
|
95
149
|
// mode
|
|
96
150
|
const mode = s.mode !== undefined ? s.mode : 'tasks'
|
|
97
151
|
if (mode !== 'tasks' && mode !== 'loop') {
|
|
@@ -196,6 +250,21 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
196
250
|
if (task.files !== undefined && !Array.isArray(task.files)) {
|
|
197
251
|
errors.push(`${prefix}: \`files\` must be an array`)
|
|
198
252
|
}
|
|
253
|
+
|
|
254
|
+
// model
|
|
255
|
+
if (task.model !== undefined && typeof task.model !== 'string') {
|
|
256
|
+
errors.push(`${prefix}: \`model\` must be a string`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// max_retries
|
|
260
|
+
if (task.max_retries !== undefined) {
|
|
261
|
+
const mr = Number(task.max_retries)
|
|
262
|
+
if (!Number.isInteger(mr) || mr < 0) {
|
|
263
|
+
errors.push(
|
|
264
|
+
`${prefix}: \`max_retries\` must be a non-negative integer`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
199
268
|
}
|
|
200
269
|
|
|
201
270
|
// DAG cycle detection
|
|
@@ -271,18 +340,43 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
|
|
|
271
340
|
s.loop = loop
|
|
272
341
|
} else {
|
|
273
342
|
const tasks = s.tasks as Array<Record<string, unknown>>
|
|
343
|
+
const d =
|
|
344
|
+
s.version === 1 && s.defaults
|
|
345
|
+
? (s.defaults as Record<string, unknown>)
|
|
346
|
+
: {}
|
|
274
347
|
for (const task of tasks) {
|
|
275
|
-
task.agent =
|
|
276
|
-
|
|
348
|
+
task.agent =
|
|
349
|
+
(task.agent as string) || (d.agent as string | undefined) || 'developer'
|
|
350
|
+
task.timeout =
|
|
351
|
+
(task.timeout as string) ||
|
|
352
|
+
(d.timeout as string | undefined) ||
|
|
353
|
+
'30m'
|
|
277
354
|
task.depends_on = (task.depends_on as string[]) || []
|
|
278
355
|
task.files = (task.files as string[]) || []
|
|
279
356
|
task.description = (task.description as string) || (task.id as string)
|
|
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
|
+
}
|
|
280
366
|
}
|
|
281
367
|
}
|
|
282
368
|
|
|
283
369
|
return s as unknown as TaskSpec
|
|
284
370
|
}
|
|
285
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Returns true if the spec uses the Convoy Engine enhanced format (version: 1).
|
|
374
|
+
*/
|
|
375
|
+
export function isConvoySpec(spec: unknown): boolean {
|
|
376
|
+
if (!spec || typeof spec !== 'object') return false
|
|
377
|
+
return (spec as Record<string, unknown>).version === 1
|
|
378
|
+
}
|
|
379
|
+
|
|
286
380
|
/**
|
|
287
381
|
* Read, parse, validate, and return a typed task spec from a YAML file.
|
|
288
382
|
* @throws If file cannot be read, parsed, or spec is invalid
|
package/src/cli/types.ts
CHANGED
|
@@ -126,6 +126,14 @@ export const IDE_LABELS: Record<IdeChoice, string> = {
|
|
|
126
126
|
|
|
127
127
|
// ── Run command types ──────────────────────────────────────────
|
|
128
128
|
|
|
129
|
+
/** Default values merged into each task for Convoy Engine (version: 1) specs. */
|
|
130
|
+
export interface TaskDefaults {
|
|
131
|
+
timeout?: string;
|
|
132
|
+
model?: string;
|
|
133
|
+
max_retries?: number;
|
|
134
|
+
agent?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
/** Loop execution configuration. */
|
|
130
138
|
export interface LoopConfig {
|
|
131
139
|
/** Maximum number of agent iterations (default 20). */
|
|
@@ -152,6 +160,14 @@ export interface TaskSpec {
|
|
|
152
160
|
mode?: 'tasks' | 'loop';
|
|
153
161
|
loop?: LoopConfig;
|
|
154
162
|
_verbose?: boolean;
|
|
163
|
+
/** Spec schema version (1 for Convoy Engine format). */
|
|
164
|
+
version?: number;
|
|
165
|
+
/** Worker defaults merged into each task (Convoy Engine). */
|
|
166
|
+
defaults?: TaskDefaults;
|
|
167
|
+
/** Shell commands run after all tasks complete; each must exit 0. */
|
|
168
|
+
gates?: string[];
|
|
169
|
+
/** Git feature branch name. */
|
|
170
|
+
branch?: string;
|
|
155
171
|
}
|
|
156
172
|
|
|
157
173
|
/** A single task in the spec. */
|
|
@@ -164,6 +180,10 @@ export interface Task {
|
|
|
164
180
|
files: string[];
|
|
165
181
|
description: string;
|
|
166
182
|
_process?: ChildProcess;
|
|
183
|
+
/** Model override for this task. */
|
|
184
|
+
model?: string;
|
|
185
|
+
/** Max retry attempts (default: 1). */
|
|
186
|
+
max_retries: number;
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
/** Task execution status. */
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
-
"hash": "
|
|
2
|
+
"hash": "7398c476",
|
|
3
3
|
"configHash": "30f8ea04",
|
|
4
|
-
"lockfileHash": "
|
|
5
|
-
"browserHash": "
|
|
4
|
+
"lockfileHash": "35ec1b3d",
|
|
5
|
+
"browserHash": "6a8b9ff7",
|
|
6
6
|
"optimized": {
|
|
7
7
|
"astro > cssesc": {
|
|
8
8
|
"src": "../../../../../node_modules/cssesc/cssesc.js",
|
|
9
9
|
"file": "astro___cssesc.js",
|
|
10
|
-
"fileHash": "
|
|
10
|
+
"fileHash": "ca3e0c44",
|
|
11
11
|
"needsInterop": true
|
|
12
12
|
},
|
|
13
13
|
"astro > aria-query": {
|
|
14
14
|
"src": "../../../../../node_modules/aria-query/lib/index.js",
|
|
15
15
|
"file": "astro___aria-query.js",
|
|
16
|
-
"fileHash": "
|
|
16
|
+
"fileHash": "9cd2129a",
|
|
17
17
|
"needsInterop": true
|
|
18
18
|
},
|
|
19
19
|
"astro > axobject-query": {
|
|
20
20
|
"src": "../../../../../node_modules/axobject-query/lib/index.js",
|
|
21
21
|
"file": "astro___axobject-query.js",
|
|
22
|
-
"fileHash": "
|
|
22
|
+
"fileHash": "cba29d71",
|
|
23
23
|
"needsInterop": true
|
|
24
24
|
}
|
|
25
25
|
},
|