opencastle 0.22.0 → 0.23.1
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/engine.d.ts +1 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +1 -0
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/export.d.ts +1 -0
- package/dist/cli/convoy/export.d.ts.map +1 -1
- package/dist/cli/convoy/export.js +34 -0
- package/dist/cli/convoy/export.js.map +1 -1
- package/dist/cli/convoy/pipeline.d.ts +35 -0
- package/dist/cli/convoy/pipeline.d.ts.map +1 -0
- package/dist/cli/convoy/pipeline.js +353 -0
- package/dist/cli/convoy/pipeline.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.d.ts +2 -0
- package/dist/cli/convoy/pipeline.test.d.ts.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +778 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -0
- package/dist/cli/convoy/store.d.ts +14 -2
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +84 -5
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +216 -7
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +15 -0
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +1 -0
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +8 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/run/schema.d.ts +5 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +41 -8
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +194 -5
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +143 -3
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +3 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/engine.ts +2 -0
- package/src/cli/convoy/export.ts +41 -0
- package/src/cli/convoy/pipeline.test.ts +939 -0
- package/src/cli/convoy/pipeline.ts +430 -0
- package/src/cli/convoy/store.test.ts +239 -7
- package/src/cli/convoy/store.ts +110 -7
- package/src/cli/convoy/types.ts +17 -0
- package/src/cli/dashboard.ts +1 -0
- package/src/cli/init.ts +9 -1
- package/src/cli/run/schema.test.ts +244 -5
- package/src/cli/run/schema.ts +49 -8
- package/src/cli/run.ts +142 -3
- package/src/cli/types.ts +3 -1
- package/src/dashboard/dist/_astro/{index.DyyaCW8L.css → index.Cq68OHaZ.css} +1 -1
- package/src/dashboard/dist/index.html +214 -2
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/src/pages/index.astro +230 -1
- package/src/dashboard/src/styles/dashboard.css +116 -0
- package/src/orchestrator/customizations/KNOWN-ISSUES.md +1 -1
- package/src/orchestrator/skills/decomposition/SKILL.md +1 -0
- package/src/orchestrator/skills/orchestration-protocols/SKILL.md +32 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, parseTaskSpecText } from './schema.js'
|
|
2
|
+
import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, isPipelineSpec, parseTaskSpecText } from './schema.js'
|
|
3
3
|
|
|
4
4
|
// ── parseYaml ──────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -365,12 +365,18 @@ describe('validateSpec — version field', () => {
|
|
|
365
365
|
expect(result.errors).toHaveLength(0)
|
|
366
366
|
})
|
|
367
367
|
|
|
368
|
-
it('rejects version
|
|
369
|
-
const result = validateSpec({ ...validSpec, version:
|
|
368
|
+
it('rejects version 3', () => {
|
|
369
|
+
const result = validateSpec({ ...validSpec, version: 3 })
|
|
370
370
|
expect(result.valid).toBe(false)
|
|
371
371
|
expect(result.errors).toContainEqual(expect.stringContaining('version'))
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
+
it('accepts version 2', () => {
|
|
375
|
+
const result = validateSpec({ ...validSpec, version: 2 })
|
|
376
|
+
expect(result.valid).toBe(true)
|
|
377
|
+
expect(result.errors).toHaveLength(0)
|
|
378
|
+
})
|
|
379
|
+
|
|
374
380
|
it('rejects non-integer version', () => {
|
|
375
381
|
const result = validateSpec({ ...validSpec, version: 1.5 })
|
|
376
382
|
expect(result.valid).toBe(false)
|
|
@@ -614,14 +620,20 @@ describe('isConvoySpec', () => {
|
|
|
614
620
|
).toBe(true)
|
|
615
621
|
})
|
|
616
622
|
|
|
623
|
+
it('returns true for version 2 spec', () => {
|
|
624
|
+
expect(
|
|
625
|
+
isConvoySpec({ name: 'test', version: 2, depends_on_convoy: ['other-convoy'] })
|
|
626
|
+
).toBe(true)
|
|
627
|
+
})
|
|
628
|
+
|
|
617
629
|
it('returns false for legacy spec without version', () => {
|
|
618
630
|
expect(
|
|
619
631
|
isConvoySpec({ name: 'test', tasks: [{ id: 'a', prompt: 'x' }] })
|
|
620
632
|
).toBe(false)
|
|
621
633
|
})
|
|
622
634
|
|
|
623
|
-
it('returns false for version
|
|
624
|
-
expect(isConvoySpec({ name: 'test', version:
|
|
635
|
+
it('returns false for version 3', () => {
|
|
636
|
+
expect(isConvoySpec({ name: 'test', version: 3 })).toBe(false)
|
|
625
637
|
})
|
|
626
638
|
|
|
627
639
|
it('returns false for null input', () => {
|
|
@@ -921,3 +933,230 @@ tasks:
|
|
|
921
933
|
expect(isConvoySpec(spec)).toBe(true)
|
|
922
934
|
})
|
|
923
935
|
})
|
|
936
|
+
|
|
937
|
+
// ── validateSpec — depends_on_convoy / pipeline specs ──────────
|
|
938
|
+
|
|
939
|
+
describe('validateSpec — depends_on_convoy field', () => {
|
|
940
|
+
it('accepts depends_on_convoy as an array of strings', () => {
|
|
941
|
+
const result = validateSpec({
|
|
942
|
+
name: 'pipeline',
|
|
943
|
+
version: 2,
|
|
944
|
+
depends_on_convoy: ['phase-1', 'phase-2'],
|
|
945
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
946
|
+
})
|
|
947
|
+
expect(result.valid).toBe(true)
|
|
948
|
+
expect(result.errors).toHaveLength(0)
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
it('rejects depends_on_convoy as a string (not array)', () => {
|
|
952
|
+
const result = validateSpec({
|
|
953
|
+
name: 'pipeline',
|
|
954
|
+
version: 2,
|
|
955
|
+
depends_on_convoy: 'phase-1',
|
|
956
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
957
|
+
})
|
|
958
|
+
expect(result.valid).toBe(false)
|
|
959
|
+
expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it('rejects depends_on_convoy with non-string elements', () => {
|
|
963
|
+
const result = validateSpec({
|
|
964
|
+
name: 'pipeline',
|
|
965
|
+
version: 2,
|
|
966
|
+
depends_on_convoy: ['phase-1', 42],
|
|
967
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
968
|
+
})
|
|
969
|
+
expect(result.valid).toBe(false)
|
|
970
|
+
expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
it('rejects depends_on_convoy as a non-array object', () => {
|
|
974
|
+
const result = validateSpec({
|
|
975
|
+
name: 'pipeline',
|
|
976
|
+
version: 2,
|
|
977
|
+
depends_on_convoy: { convoy: 'phase-1' },
|
|
978
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
979
|
+
})
|
|
980
|
+
expect(result.valid).toBe(false)
|
|
981
|
+
expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
it('accepts omitting depends_on_convoy entirely (optional field)', () => {
|
|
985
|
+
const result = validateSpec({
|
|
986
|
+
name: 'convoy-run',
|
|
987
|
+
version: 1,
|
|
988
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
989
|
+
})
|
|
990
|
+
expect(result.valid).toBe(true)
|
|
991
|
+
})
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
// ── validateSpec — pipeline spec (v2 tasks-optional) ──────────
|
|
995
|
+
|
|
996
|
+
describe('validateSpec — pipeline spec (version:2, no tasks)', () => {
|
|
997
|
+
it('pipeline spec with no tasks is valid when depends_on_convoy is set', () => {
|
|
998
|
+
const result = validateSpec({
|
|
999
|
+
name: 'pipeline',
|
|
1000
|
+
version: 2,
|
|
1001
|
+
depends_on_convoy: ['phase-1'],
|
|
1002
|
+
})
|
|
1003
|
+
expect(result.valid).toBe(true)
|
|
1004
|
+
expect(result.errors).toHaveLength(0)
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
it('pipeline spec with tasks AND depends_on_convoy is valid', () => {
|
|
1008
|
+
const result = validateSpec({
|
|
1009
|
+
name: 'pipeline',
|
|
1010
|
+
version: 2,
|
|
1011
|
+
depends_on_convoy: ['phase-1'],
|
|
1012
|
+
tasks: [{ id: 'a', prompt: 'do local work' }],
|
|
1013
|
+
})
|
|
1014
|
+
expect(result.valid).toBe(true)
|
|
1015
|
+
expect(result.errors).toHaveLength(0)
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('version:2 without depends_on_convoy still requires tasks', () => {
|
|
1019
|
+
const result = validateSpec({
|
|
1020
|
+
name: 'convoy-v2',
|
|
1021
|
+
version: 2,
|
|
1022
|
+
})
|
|
1023
|
+
expect(result.valid).toBe(false)
|
|
1024
|
+
expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('pipeline spec with empty depends_on_convoy still requires tasks', () => {
|
|
1028
|
+
const result = validateSpec({
|
|
1029
|
+
name: 'pipeline',
|
|
1030
|
+
version: 2,
|
|
1031
|
+
depends_on_convoy: [],
|
|
1032
|
+
})
|
|
1033
|
+
expect(result.valid).toBe(false)
|
|
1034
|
+
expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
it('pipeline spec with explicitly empty tasks array is invalid', () => {
|
|
1038
|
+
const result = validateSpec({
|
|
1039
|
+
name: 'pipeline',
|
|
1040
|
+
version: 2,
|
|
1041
|
+
depends_on_convoy: ['phase-1'],
|
|
1042
|
+
tasks: [],
|
|
1043
|
+
})
|
|
1044
|
+
expect(result.valid).toBe(false)
|
|
1045
|
+
expect(result.errors).toContainEqual(expect.stringContaining('non-empty'))
|
|
1046
|
+
})
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
// ── isPipelineSpec ─────────────────────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
describe('isPipelineSpec', () => {
|
|
1052
|
+
it('returns true for version:2 spec with non-empty depends_on_convoy', () => {
|
|
1053
|
+
expect(
|
|
1054
|
+
isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: ['phase-1'] })
|
|
1055
|
+
).toBe(true)
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('returns true when depends_on_convoy has multiple entries', () => {
|
|
1059
|
+
expect(
|
|
1060
|
+
isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: ['a', 'b', 'c'] })
|
|
1061
|
+
).toBe(true)
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it('returns false for v1 spec without depends_on_convoy', () => {
|
|
1065
|
+
expect(
|
|
1066
|
+
isPipelineSpec({ name: 'convoy', version: 1, tasks: [{ id: 'a', prompt: 'x' }] })
|
|
1067
|
+
).toBe(false)
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
it('returns false for v1 spec even with depends_on_convoy (wrong version)', () => {
|
|
1071
|
+
expect(
|
|
1072
|
+
isPipelineSpec({ name: 'convoy', version: 1, depends_on_convoy: ['phase-1'] })
|
|
1073
|
+
).toBe(false)
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
it('returns false for version:2 with empty depends_on_convoy', () => {
|
|
1077
|
+
expect(
|
|
1078
|
+
isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: [] })
|
|
1079
|
+
).toBe(false)
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('returns false for version:2 without depends_on_convoy', () => {
|
|
1083
|
+
expect(
|
|
1084
|
+
isPipelineSpec({ name: 'convoy', version: 2, tasks: [{ id: 'a', prompt: 'x' }] })
|
|
1085
|
+
).toBe(false)
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
it('returns false for legacy spec (no version)', () => {
|
|
1089
|
+
expect(
|
|
1090
|
+
isPipelineSpec({ name: 'legacy', tasks: [{ id: 'a', prompt: 'x' }] })
|
|
1091
|
+
).toBe(false)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
it('returns false for null input', () => {
|
|
1095
|
+
expect(isPipelineSpec(null)).toBe(false)
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('returns false for non-object input', () => {
|
|
1099
|
+
expect(isPipelineSpec('string')).toBe(false)
|
|
1100
|
+
})
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
// ── isConvoySpec — version 1 and 2 ────────────────────────────
|
|
1104
|
+
|
|
1105
|
+
describe('isConvoySpec — version 1 and 2', () => {
|
|
1106
|
+
it('returns true for version 1 spec', () => {
|
|
1107
|
+
expect(isConvoySpec({ version: 1, tasks: [] })).toBe(true)
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
it('returns true for version 2 pipeline spec', () => {
|
|
1111
|
+
expect(isConvoySpec({ version: 2, depends_on_convoy: ['phase-1'] })).toBe(true)
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
it('returns true for version 2 spec with tasks', () => {
|
|
1115
|
+
expect(isConvoySpec({ version: 2, tasks: [{ id: 'a', prompt: 'x' }] })).toBe(true)
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
it('returns false for legacy spec (no version)', () => {
|
|
1119
|
+
expect(isConvoySpec({ name: 'legacy', tasks: [] })).toBe(false)
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
it('returns false for version 3', () => {
|
|
1123
|
+
expect(isConvoySpec({ version: 3 })).toBe(false)
|
|
1124
|
+
})
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
// ── applyDefaults — pipeline spec (version:2, no tasks) ────────
|
|
1128
|
+
|
|
1129
|
+
describe('applyDefaults — pipeline spec (version:2, no tasks)', () => {
|
|
1130
|
+
it('pipeline spec with no tasks produces empty tasks array', () => {
|
|
1131
|
+
const spec = applyDefaults({
|
|
1132
|
+
name: 'pipeline',
|
|
1133
|
+
version: 2,
|
|
1134
|
+
depends_on_convoy: ['phase-1'],
|
|
1135
|
+
})
|
|
1136
|
+
expect(spec.tasks).toBeUndefined()
|
|
1137
|
+
expect(spec.name).toBe('pipeline')
|
|
1138
|
+
expect(spec.version).toBe(2)
|
|
1139
|
+
expect(spec.depends_on_convoy).toEqual(['phase-1'])
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('pipeline spec with tasks applies defaults normally', () => {
|
|
1143
|
+
const spec = applyDefaults({
|
|
1144
|
+
name: 'pipeline',
|
|
1145
|
+
version: 2,
|
|
1146
|
+
depends_on_convoy: ['phase-1'],
|
|
1147
|
+
tasks: [{ id: 'a', prompt: 'x' }],
|
|
1148
|
+
})
|
|
1149
|
+
expect(spec.tasks).toHaveLength(1)
|
|
1150
|
+
expect(spec.tasks![0].agent).toBe('developer')
|
|
1151
|
+
expect(spec.tasks![0].timeout).toBe('30m')
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
it('preserves depends_on_convoy through applyDefaults', () => {
|
|
1155
|
+
const spec = applyDefaults({
|
|
1156
|
+
name: 'pipeline',
|
|
1157
|
+
version: 2,
|
|
1158
|
+
depends_on_convoy: ['phase-1', 'phase-2'],
|
|
1159
|
+
})
|
|
1160
|
+
expect(spec.depends_on_convoy).toEqual(['phase-1', 'phase-2'])
|
|
1161
|
+
})
|
|
1162
|
+
})
|
package/src/cli/run/schema.ts
CHANGED
|
@@ -43,6 +43,7 @@ interface RawSpec {
|
|
|
43
43
|
defaults?: unknown
|
|
44
44
|
gates?: unknown
|
|
45
45
|
branch?: unknown
|
|
46
|
+
depends_on_convoy?: unknown
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
interface RawTask {
|
|
@@ -99,8 +100,18 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
99
100
|
|
|
100
101
|
// version
|
|
101
102
|
if (s.version !== undefined) {
|
|
102
|
-
if (typeof s.version !== 'number' || !Number.isInteger(s.version) || s.version !== 1) {
|
|
103
|
-
errors.push('`version` must be 1')
|
|
103
|
+
if (typeof s.version !== 'number' || !Number.isInteger(s.version) || (s.version !== 1 && s.version !== 2)) {
|
|
104
|
+
errors.push('`version` must be 1 or 2')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// depends_on_convoy
|
|
109
|
+
if (s.depends_on_convoy !== undefined) {
|
|
110
|
+
if (
|
|
111
|
+
!Array.isArray(s.depends_on_convoy) ||
|
|
112
|
+
!(s.depends_on_convoy as unknown[]).every((c) => typeof c === 'string')
|
|
113
|
+
) {
|
|
114
|
+
errors.push('`depends_on_convoy` must be an array of strings')
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -148,12 +159,28 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
148
159
|
errors.push('`branch` must be a string')
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
// Tasks
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
// Tasks: required unless this is a version:2 pipeline spec with depends_on_convoy
|
|
163
|
+
const isPipeline =
|
|
164
|
+
s.version === 2 &&
|
|
165
|
+
Array.isArray(s.depends_on_convoy) &&
|
|
166
|
+
(s.depends_on_convoy as unknown[]).length > 0
|
|
167
|
+
|
|
168
|
+
if (!isPipeline) {
|
|
169
|
+
if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
|
|
170
|
+
errors.push('`tasks` is required and must be a non-empty array')
|
|
171
|
+
return { valid: false, errors }
|
|
172
|
+
}
|
|
173
|
+
} else if (s.tasks !== undefined && (!Array.isArray(s.tasks) || s.tasks.length === 0)) {
|
|
174
|
+
// Pipeline spec may omit tasks entirely, but if present they must be non-empty
|
|
175
|
+
errors.push('`tasks`, when provided, must be a non-empty array')
|
|
154
176
|
return { valid: false, errors }
|
|
155
177
|
}
|
|
156
178
|
|
|
179
|
+
// Skip per-task validation when pipeline spec has no tasks
|
|
180
|
+
if (isPipeline && !s.tasks) {
|
|
181
|
+
return { valid: errors.length === 0, errors }
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
const taskIds = new Set<string>()
|
|
158
185
|
const tasks = s.tasks as RawTask[]
|
|
159
186
|
|
|
@@ -293,7 +320,7 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
|
|
|
293
320
|
// Leave adapter empty so run.ts can auto-detect the best available CLI
|
|
294
321
|
s.adapter = (s.adapter as string) || ''
|
|
295
322
|
|
|
296
|
-
const tasks = s.tasks as Array<Record<string, unknown>>
|
|
323
|
+
const tasks = (s.tasks as Array<Record<string, unknown>> | undefined) ?? []
|
|
297
324
|
const d =
|
|
298
325
|
s.version === 1 && s.defaults
|
|
299
326
|
? (s.defaults as Record<string, unknown>)
|
|
@@ -327,11 +354,25 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
|
|
|
327
354
|
}
|
|
328
355
|
|
|
329
356
|
/**
|
|
330
|
-
* Returns true if the spec uses the Convoy Engine enhanced format (version: 1).
|
|
357
|
+
* Returns true if the spec uses the Convoy Engine enhanced format (version: 1 or 2).
|
|
331
358
|
*/
|
|
332
359
|
export function isConvoySpec(spec: unknown): boolean {
|
|
333
360
|
if (!spec || typeof spec !== 'object') return false
|
|
334
|
-
|
|
361
|
+
const v = (spec as Record<string, unknown>).version
|
|
362
|
+
return v === 1 || v === 2
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Returns true if the spec is a pipeline spec (version: 2 + non-empty depends_on_convoy).
|
|
367
|
+
*/
|
|
368
|
+
export function isPipelineSpec(spec: unknown): boolean {
|
|
369
|
+
if (!spec || typeof spec !== 'object') return false
|
|
370
|
+
const s = spec as Record<string, unknown>
|
|
371
|
+
return (
|
|
372
|
+
s.version === 2 &&
|
|
373
|
+
Array.isArray(s.depends_on_convoy) &&
|
|
374
|
+
(s.depends_on_convoy as unknown[]).length > 0
|
|
375
|
+
)
|
|
335
376
|
}
|
|
336
377
|
|
|
337
378
|
/**
|
package/src/cli/run.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
-
import { parseTaskSpecText, isConvoySpec } from './run/schema.js'
|
|
4
|
+
import { parseTaskSpecText, isConvoySpec, isPipelineSpec } from './run/schema.js'
|
|
5
5
|
import { createExecutor, buildPhases } from './run/executor.js'
|
|
6
6
|
import { getAdapter, detectAdapter } from './run/adapters/index.js'
|
|
7
7
|
import { createReporter, printExecutionPlan } from './run/reporter.js'
|
|
8
8
|
import type { CliContext, RunOptions } from './types.js'
|
|
9
9
|
import type { ConvoyResult } from './convoy/engine.js'
|
|
10
|
+
import type { PipelineResult } from './convoy/pipeline.js'
|
|
10
11
|
|
|
11
12
|
function formatTokens(n: number): string {
|
|
12
13
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
|
@@ -114,6 +115,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
|
|
|
114
115
|
` • copilot — https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli\n` +
|
|
115
116
|
` • claude — npm install -g @anthropic-ai/claude-code\n` +
|
|
116
117
|
` • cursor — https://cursor.com (Cursor > Install CLI)\n` +
|
|
118
|
+
` • opencode — https://opencode.ai\n` +
|
|
117
119
|
`\n` +
|
|
118
120
|
` Or specify an adapter explicitly: opencastle run --adapter <name>`
|
|
119
121
|
)
|
|
@@ -134,7 +136,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
|
|
|
134
136
|
' Install OpenCode from https://opencode.ai\n' +
|
|
135
137
|
' Ensure the "opencode" command is on your PATH.',
|
|
136
138
|
}
|
|
137
|
-
const cliName = adapterName === 'claude-code' ? 'claude' : adapterName
|
|
139
|
+
const cliName = adapterName === 'claude-code' ? 'claude' : adapterName === 'cursor' ? 'agent' : adapterName
|
|
138
140
|
const hint = hints[adapterName] ?? ''
|
|
139
141
|
console.error(
|
|
140
142
|
` ✗ Adapter "${adapterName}" is not available.\n` +
|
|
@@ -164,6 +166,26 @@ function printConvoyResult(result: ConvoyResult): void {
|
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Print a pipeline result summary.
|
|
171
|
+
*/
|
|
172
|
+
function printPipelineResult(result: PipelineResult): void {
|
|
173
|
+
console.log(`\n ──────────────────────────────────────`)
|
|
174
|
+
console.log(` Pipeline ${result.status}: ${result.duration}`)
|
|
175
|
+
console.log(
|
|
176
|
+
` Convoys: ${result.summary.completed}/${result.summary.totalConvoys} completed` +
|
|
177
|
+
(result.summary.failed > 0 ? ` | ${result.summary.failed} failed` : '') +
|
|
178
|
+
(result.summary.skipped > 0 ? ` | ${result.summary.skipped} skipped` : '')
|
|
179
|
+
)
|
|
180
|
+
for (const cr of result.convoyResults) {
|
|
181
|
+
const icon = cr.status === 'done' ? '✓' : cr.status === 'failed' ? '✗' : '⊘'
|
|
182
|
+
console.log(` ${icon} ${cr.convoyId}: ${cr.status} (${cr.duration})`)
|
|
183
|
+
}
|
|
184
|
+
if (result.cost) {
|
|
185
|
+
console.log(` Tokens: ${formatTokens(result.cost.total_tokens)}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
/**
|
|
168
190
|
* CLI entry point for the `run` command.
|
|
169
191
|
*/
|
|
@@ -186,6 +208,38 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
186
208
|
const { createConvoyStore } = await import('./convoy/store.js')
|
|
187
209
|
const store = createConvoyStore(dbPath)
|
|
188
210
|
try {
|
|
211
|
+
const pipeline = store.getLatestPipeline()
|
|
212
|
+
if (pipeline) {
|
|
213
|
+
const pipelineConvoys = store.getConvoysByPipeline(pipeline.id)
|
|
214
|
+
console.log(`\n Pipeline: ${pipeline.name}`)
|
|
215
|
+
console.log(` ID: ${pipeline.id}`)
|
|
216
|
+
console.log(` Status: ${pipeline.status}`)
|
|
217
|
+
console.log(` Branch: ${pipeline.branch ?? '(none)'}`)
|
|
218
|
+
console.log(` Created: ${pipeline.created_at}`)
|
|
219
|
+
if (pipeline.started_at) console.log(` Started: ${pipeline.started_at}`)
|
|
220
|
+
if (pipeline.finished_at) console.log(` Finished: ${pipeline.finished_at}`)
|
|
221
|
+
if (pipelineConvoys.length > 0) {
|
|
222
|
+
console.log(`\n Convoys:`)
|
|
223
|
+
let totalTasks = 0
|
|
224
|
+
let totalDone = 0
|
|
225
|
+
let totalFailed = 0
|
|
226
|
+
let totalTokens = 0
|
|
227
|
+
for (const c of pipelineConvoys) {
|
|
228
|
+
const tasks = store.getTasksByConvoy(c.id)
|
|
229
|
+
const done = tasks.filter(t => t.status === 'done').length
|
|
230
|
+
const failed = tasks.filter(t => t.status === 'failed').length
|
|
231
|
+
totalTasks += tasks.length
|
|
232
|
+
totalDone += done
|
|
233
|
+
totalFailed += failed
|
|
234
|
+
totalTokens += tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0)
|
|
235
|
+
console.log(` ${c.name} [${c.status}] — ${done}/${tasks.length} tasks done`)
|
|
236
|
+
}
|
|
237
|
+
console.log(`\n Tasks: ${totalDone} done | ${totalFailed} failed | ${totalTasks} total`)
|
|
238
|
+
if (totalTokens > 0) console.log(` Tokens: ${formatTokens(totalTokens)}`)
|
|
239
|
+
}
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
189
243
|
const convoy = store.getLatestConvoy()
|
|
190
244
|
if (!convoy) {
|
|
191
245
|
console.log(' No convoy records found.')
|
|
@@ -237,6 +291,47 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
237
291
|
}
|
|
238
292
|
const { createConvoyStore } = await import('./convoy/store.js')
|
|
239
293
|
const store = createConvoyStore(dbPath)
|
|
294
|
+
const latestPipeline = store.getLatestPipeline()
|
|
295
|
+
if (latestPipeline && (latestPipeline.status === 'pending' || latestPipeline.status === 'running')) {
|
|
296
|
+
store.close()
|
|
297
|
+
const resumePipelineSpec = parseTaskSpecText(latestPipeline.spec_yaml)
|
|
298
|
+
if (opts.concurrency !== null) resumePipelineSpec.concurrency = opts.concurrency
|
|
299
|
+
if (opts.adapter !== null) resumePipelineSpec.adapter = opts.adapter
|
|
300
|
+
if (opts.verbose) resumePipelineSpec._verbose = true
|
|
301
|
+
|
|
302
|
+
let resumePipelineDetectionFailed = false
|
|
303
|
+
if (!resumePipelineSpec.adapter) {
|
|
304
|
+
const detected = await detectAdapter()
|
|
305
|
+
if (detected) {
|
|
306
|
+
resumePipelineSpec.adapter = detected
|
|
307
|
+
console.log(` ℹ Auto-detected adapter: ${detected}`)
|
|
308
|
+
} else {
|
|
309
|
+
resumePipelineDetectionFailed = true
|
|
310
|
+
resumePipelineSpec.adapter = 'claude-code'
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const resumePipelineAdapter = await getAdapter(resumePipelineSpec.adapter)
|
|
315
|
+
const resumePipelineAvailable = await resumePipelineAdapter.isAvailable()
|
|
316
|
+
if (!resumePipelineAvailable) {
|
|
317
|
+
printAdapterError(resumePipelineDetectionFailed, resumePipelineSpec.adapter)
|
|
318
|
+
process.exit(1)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log(`\n 🏰 OpenCastle Pipeline (Resume): ${latestPipeline.name}`)
|
|
322
|
+
console.log(` Pipeline ID: ${latestPipeline.id}`)
|
|
323
|
+
const { createPipelineOrchestrator } = await import('./convoy/pipeline.js')
|
|
324
|
+
const resumePipelineOrchestrator = createPipelineOrchestrator({
|
|
325
|
+
spec: resumePipelineSpec,
|
|
326
|
+
specYaml: latestPipeline.spec_yaml,
|
|
327
|
+
adapter: resumePipelineAdapter,
|
|
328
|
+
verbose: opts.verbose,
|
|
329
|
+
})
|
|
330
|
+
const resumePipelineResult = await resumePipelineOrchestrator.resume(latestPipeline.id)
|
|
331
|
+
printPipelineResult(resumePipelineResult)
|
|
332
|
+
process.exit(resumePipelineResult.status !== 'done' ? 1 : 0)
|
|
333
|
+
}
|
|
334
|
+
|
|
240
335
|
const convoy = store.getLatestConvoy()
|
|
241
336
|
store.close()
|
|
242
337
|
if (!convoy) {
|
|
@@ -332,7 +427,16 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
332
427
|
|
|
333
428
|
// ── Dry run ──────────────────────────────────────────────────
|
|
334
429
|
if (opts.dryRun) {
|
|
335
|
-
if (
|
|
430
|
+
if (isPipelineSpec(spec)) {
|
|
431
|
+
console.log(`\n 🏰 Pipeline Plan: ${spec.name}`)
|
|
432
|
+
console.log(` Convoy chain: ${(spec.depends_on_convoy as string[]).join(' → ')}`)
|
|
433
|
+
if (spec.tasks?.length) {
|
|
434
|
+
console.log(` Plus ${spec.tasks.length} local tasks after chain completes`)
|
|
435
|
+
}
|
|
436
|
+
if (spec.branch) console.log(` Branch: ${spec.branch}`)
|
|
437
|
+
if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
|
|
438
|
+
if (!spec.tasks?.length) return
|
|
439
|
+
} else if (isConvoySpec(spec)) {
|
|
336
440
|
console.log(`\n \uD83C\uDFF0 Convoy Plan: ${spec.name}`)
|
|
337
441
|
console.log(
|
|
338
442
|
` Adapter: ${spec.adapter} | Concurrency: ${spec.concurrency} | Tasks: ${spec.tasks!.length}`
|
|
@@ -353,6 +457,41 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
353
457
|
process.exit(1)
|
|
354
458
|
}
|
|
355
459
|
|
|
460
|
+
// ── Pipeline orchestrator path (version: 2 specs with depends_on_convoy) ──
|
|
461
|
+
if (isPipelineSpec(spec)) {
|
|
462
|
+
const { createPipelineOrchestrator } = await import('./convoy/pipeline.js')
|
|
463
|
+
console.log(`\n 🏰 OpenCastle Pipeline: ${spec.name}`)
|
|
464
|
+
console.log(` Convoy chain: ${(spec.depends_on_convoy as string[]).join(' → ')}`)
|
|
465
|
+
if (spec.branch) console.log(` Branch: ${spec.branch}`)
|
|
466
|
+
if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
|
|
467
|
+
|
|
468
|
+
const { startDashboardServer } = await import('./dashboard.js')
|
|
469
|
+
let pipelineDashboardResult: { server: import('node:http').Server } | null = null
|
|
470
|
+
try {
|
|
471
|
+
pipelineDashboardResult = await startDashboardServer({
|
|
472
|
+
pkgRoot,
|
|
473
|
+
openBrowser: true,
|
|
474
|
+
convoyId: 'active',
|
|
475
|
+
})
|
|
476
|
+
} catch {
|
|
477
|
+
// Dashboard failure must not block pipeline
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const pipelineOrchestrator = createPipelineOrchestrator({
|
|
481
|
+
spec,
|
|
482
|
+
specYaml: specText,
|
|
483
|
+
adapter,
|
|
484
|
+
verbose: opts.verbose,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const pipelineResult = await pipelineOrchestrator.run()
|
|
488
|
+
printPipelineResult(pipelineResult)
|
|
489
|
+
if (pipelineDashboardResult) {
|
|
490
|
+
pipelineDashboardResult.server.close()
|
|
491
|
+
}
|
|
492
|
+
process.exit(pipelineResult.status !== 'done' ? 1 : 0)
|
|
493
|
+
}
|
|
494
|
+
|
|
356
495
|
// ── Convoy engine path (version: 1 specs) ────────────────────
|
|
357
496
|
if (isConvoySpec(spec)) {
|
|
358
497
|
const { createConvoyEngine } = await import('./convoy/engine.js')
|
package/src/cli/types.ts
CHANGED
|
@@ -158,7 +158,7 @@ export interface TaskSpec {
|
|
|
158
158
|
adapter: string;
|
|
159
159
|
tasks?: Task[];
|
|
160
160
|
_verbose?: boolean;
|
|
161
|
-
/** Spec schema version (1 for Convoy Engine format). */
|
|
161
|
+
/** Spec schema version (1 for Convoy Engine format, 2 for pipeline chaining). */
|
|
162
162
|
version?: number;
|
|
163
163
|
/** Worker defaults merged into each task (Convoy Engine). */
|
|
164
164
|
defaults?: TaskDefaults;
|
|
@@ -166,6 +166,8 @@ export interface TaskSpec {
|
|
|
166
166
|
gates?: string[];
|
|
167
167
|
/** Git feature branch name. */
|
|
168
168
|
branch?: string;
|
|
169
|
+
/** Other convoy spec names to run before this one (version: 2 pipeline specs). */
|
|
170
|
+
depends_on_convoy?: string[];
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
/** A single task in the spec. */
|