opencastle 0.31.3 → 0.31.4
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/spec-builder.d.ts.map +1 -1
- package/dist/cli/convoy/spec-builder.js +66 -1
- package/dist/cli/convoy/spec-builder.js.map +1 -1
- package/dist/cli/convoy/spec-builder.test.js +99 -2
- package/dist/cli/convoy/spec-builder.test.js.map +1 -1
- package/dist/cli/pipeline.d.ts +5 -0
- package/dist/cli/pipeline.d.ts.map +1 -1
- package/dist/cli/pipeline.js +204 -79
- package/dist/cli/pipeline.js.map +1 -1
- package/dist/cli/pipeline.test.js +122 -1
- package/dist/cli/pipeline.test.js.map +1 -1
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +27 -4
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +88 -18
- package/dist/cli/run.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/spec-builder.test.ts +108 -2
- package/src/cli/convoy/spec-builder.ts +66 -1
- package/src/cli/pipeline.test.ts +130 -1
- package/src/cli/pipeline.ts +224 -89
- package/src/cli/plan.ts +29 -4
- package/src/cli/run.ts +84 -18
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/prompts/validate-convoy.prompt.md +14 -11
- package/src/orchestrator/prompts/validate-prd.prompt.md +14 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { parse as yamlParse } from 'yaml'
|
|
3
3
|
import { parseYaml, validateSpec } from '../run/schema.js'
|
|
4
4
|
import {
|
|
@@ -271,12 +271,14 @@ describe('applyPatches', () => {
|
|
|
271
271
|
expect(patched.branch).toBe('feat/patched-branch')
|
|
272
272
|
})
|
|
273
273
|
|
|
274
|
-
it('
|
|
274
|
+
it('leaves task unchanged when task id does not exist', () => {
|
|
275
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
275
276
|
const plan = minimalPlan()
|
|
276
277
|
const patched = applyPatches(plan, [
|
|
277
278
|
{ task_id: 'nonexistent', field: 'prompt', value: 'x' },
|
|
278
279
|
])
|
|
279
280
|
expect(patched.tasks[0].prompt).toBe('Do something useful')
|
|
281
|
+
warnSpy.mockRestore()
|
|
280
282
|
})
|
|
281
283
|
|
|
282
284
|
it('returns a new object (original plan unchanged)', () => {
|
|
@@ -306,6 +308,54 @@ describe('applyPatches', () => {
|
|
|
306
308
|
expect(patched.tasks[1].agent).toBe('ui-expert')
|
|
307
309
|
expect(patched.on_failure).toBe('continue')
|
|
308
310
|
})
|
|
311
|
+
|
|
312
|
+
it('warns when patch targets unknown task_id', () => {
|
|
313
|
+
const plan: TaskPlan = {
|
|
314
|
+
name: 'test',
|
|
315
|
+
tasks: [{ id: 'task-1', prompt: 'do something' }],
|
|
316
|
+
}
|
|
317
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
318
|
+
const result = applyPatches(plan, [
|
|
319
|
+
{ task_id: 'nonexistent', field: 'prompt', value: 'new prompt' },
|
|
320
|
+
])
|
|
321
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
322
|
+
expect.stringContaining('nonexistent')
|
|
323
|
+
)
|
|
324
|
+
// Original task unchanged
|
|
325
|
+
expect(result.tasks[0].prompt).toBe('do something')
|
|
326
|
+
warnSpy.mockRestore()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('applies valid patches and warns for invalid ones', () => {
|
|
330
|
+
const plan: TaskPlan = {
|
|
331
|
+
name: 'test',
|
|
332
|
+
tasks: [
|
|
333
|
+
{ id: 'task-1', prompt: 'original' },
|
|
334
|
+
{ id: 'task-2', prompt: 'original2' },
|
|
335
|
+
],
|
|
336
|
+
}
|
|
337
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
338
|
+
const result = applyPatches(plan, [
|
|
339
|
+
{ task_id: 'task-1', field: 'prompt', value: 'updated' },
|
|
340
|
+
{ task_id: 'ghost', field: 'prompt', value: 'nope' },
|
|
341
|
+
])
|
|
342
|
+
expect(result.tasks[0].prompt).toBe('updated')
|
|
343
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ghost'))
|
|
344
|
+
warnSpy.mockRestore()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not warn when all patches target valid tasks', () => {
|
|
348
|
+
const plan: TaskPlan = {
|
|
349
|
+
name: 'test',
|
|
350
|
+
tasks: [{ id: 'task-1', prompt: 'original' }],
|
|
351
|
+
}
|
|
352
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
353
|
+
applyPatches(plan, [
|
|
354
|
+
{ task_id: 'task-1', field: 'prompt', value: 'updated' },
|
|
355
|
+
])
|
|
356
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
357
|
+
warnSpy.mockRestore()
|
|
358
|
+
})
|
|
309
359
|
})
|
|
310
360
|
|
|
311
361
|
// ── parseTaskPlan ──────────────────────────────────────────────────────────────
|
|
@@ -392,6 +442,62 @@ describe('parseTaskPlan', () => {
|
|
|
392
442
|
const withTrailing = json + '\n```\nSome extra text'
|
|
393
443
|
expect(parseTaskPlan(withTrailing)).toBeNull()
|
|
394
444
|
})
|
|
445
|
+
|
|
446
|
+
it('returns null for duplicate task IDs', () => {
|
|
447
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
448
|
+
const plan = JSON.stringify({
|
|
449
|
+
name: 'test',
|
|
450
|
+
tasks: [
|
|
451
|
+
{ id: 'setup', prompt: 'Do setup' },
|
|
452
|
+
{ id: 'setup', prompt: 'Do setup again' },
|
|
453
|
+
],
|
|
454
|
+
})
|
|
455
|
+
expect(parseTaskPlan(plan)).toBeNull()
|
|
456
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('setup'))
|
|
457
|
+
warnSpy.mockRestore()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('returns null for invalid depends_on reference', () => {
|
|
461
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
462
|
+
const plan = JSON.stringify({
|
|
463
|
+
name: 'test',
|
|
464
|
+
tasks: [
|
|
465
|
+
{ id: 'a', prompt: 'Do A' },
|
|
466
|
+
{ id: 'b', prompt: 'Do B', depends_on: ['nonexistent'] },
|
|
467
|
+
],
|
|
468
|
+
})
|
|
469
|
+
expect(parseTaskPlan(plan)).toBeNull()
|
|
470
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent'))
|
|
471
|
+
warnSpy.mockRestore()
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('returns null for dependency cycle', () => {
|
|
475
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
476
|
+
const plan = JSON.stringify({
|
|
477
|
+
name: 'test',
|
|
478
|
+
tasks: [
|
|
479
|
+
{ id: 'a', prompt: 'Do A', depends_on: ['b'] },
|
|
480
|
+
{ id: 'b', prompt: 'Do B', depends_on: ['a'] },
|
|
481
|
+
],
|
|
482
|
+
})
|
|
483
|
+
expect(parseTaskPlan(plan)).toBeNull()
|
|
484
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('cycle'))
|
|
485
|
+
warnSpy.mockRestore()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('accepts valid plan with correct dependencies', () => {
|
|
489
|
+
const plan = JSON.stringify({
|
|
490
|
+
name: 'test',
|
|
491
|
+
tasks: [
|
|
492
|
+
{ id: 'a', prompt: 'Do A' },
|
|
493
|
+
{ id: 'b', prompt: 'Do B', depends_on: ['a'] },
|
|
494
|
+
{ id: 'c', prompt: 'Do C', depends_on: ['a', 'b'] },
|
|
495
|
+
],
|
|
496
|
+
})
|
|
497
|
+
const result = parseTaskPlan(plan)
|
|
498
|
+
expect(result).not.toBeNull()
|
|
499
|
+
expect(result!.tasks).toHaveLength(3)
|
|
500
|
+
})
|
|
395
501
|
})
|
|
396
502
|
|
|
397
503
|
// ── parsePatches ──────────────────────────────────────────────────────────────
|
|
@@ -169,6 +169,7 @@ export function buildConvoyYaml(plan: TaskPlan, enrichment?: SpecEnrichment): st
|
|
|
169
169
|
*/
|
|
170
170
|
export function applyPatches(plan: TaskPlan, patches: TaskPatch[]): TaskPlan {
|
|
171
171
|
const clone = structuredClone(plan)
|
|
172
|
+
let skipped = 0
|
|
172
173
|
for (const patch of patches) {
|
|
173
174
|
if (patch.task_id === '_plan') {
|
|
174
175
|
;(clone as unknown as Record<string, unknown>)[patch.field] = patch.value
|
|
@@ -176,10 +177,15 @@ export function applyPatches(plan: TaskPlan, patches: TaskPatch[]): TaskPlan {
|
|
|
176
177
|
const task = clone.tasks.find((t) => t.id === patch.task_id)
|
|
177
178
|
if (task) {
|
|
178
179
|
;(task as unknown as Record<string, unknown>)[patch.field] = patch.value
|
|
180
|
+
} else {
|
|
181
|
+
console.warn(` ⚠ applyPatches: patch targets unknown task "${patch.task_id}" — skipping`)
|
|
182
|
+
skipped++
|
|
179
183
|
}
|
|
180
|
-
// task not found: skip silently
|
|
181
184
|
}
|
|
182
185
|
}
|
|
186
|
+
if (skipped > 0) {
|
|
187
|
+
console.warn(` ⚠ applyPatches: ${skipped} of ${patches.length} patches skipped (unknown task IDs)`)
|
|
188
|
+
}
|
|
183
189
|
return clone
|
|
184
190
|
}
|
|
185
191
|
|
|
@@ -194,7 +200,66 @@ export function parseTaskPlan(jsonText: string): TaskPlan | null {
|
|
|
194
200
|
for (const task of parsed.tasks as unknown[]) {
|
|
195
201
|
const t = task as Record<string, unknown>
|
|
196
202
|
if (typeof t.id !== 'string' || typeof t.prompt !== 'string') return null
|
|
203
|
+
if ((t.prompt as string).length === 0) return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate unique task IDs
|
|
207
|
+
const tasks = parsed.tasks as Array<Record<string, unknown>>
|
|
208
|
+
const ids = new Set<string>()
|
|
209
|
+
const duplicates: string[] = []
|
|
210
|
+
for (const task of tasks) {
|
|
211
|
+
const id = task.id as string
|
|
212
|
+
if (ids.has(id)) duplicates.push(id)
|
|
213
|
+
ids.add(id)
|
|
214
|
+
}
|
|
215
|
+
if (duplicates.length > 0) {
|
|
216
|
+
console.warn(` ⚠ parseTaskPlan: duplicate task IDs: ${duplicates.join(', ')}`)
|
|
217
|
+
return null
|
|
197
218
|
}
|
|
219
|
+
|
|
220
|
+
// Validate depends_on references
|
|
221
|
+
for (const task of tasks) {
|
|
222
|
+
if (Array.isArray(task.depends_on)) {
|
|
223
|
+
for (const dep of task.depends_on as string[]) {
|
|
224
|
+
if (!ids.has(dep)) {
|
|
225
|
+
console.warn(` ⚠ parseTaskPlan: task "${task.id as string}" depends on unknown task "${dep}"`)
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Cycle detection (Kahn's algorithm)
|
|
233
|
+
const inDegree = new Map<string, number>()
|
|
234
|
+
const adj = new Map<string, string[]>()
|
|
235
|
+
for (const id of ids) {
|
|
236
|
+
inDegree.set(id, 0)
|
|
237
|
+
adj.set(id, [])
|
|
238
|
+
}
|
|
239
|
+
for (const task of tasks) {
|
|
240
|
+
if (Array.isArray(task.depends_on)) {
|
|
241
|
+
for (const dep of task.depends_on as string[]) {
|
|
242
|
+
adj.get(dep)!.push(task.id as string)
|
|
243
|
+
inDegree.set(task.id as string, (inDegree.get(task.id as string) ?? 0) + 1)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const queue = [...ids].filter(id => inDegree.get(id) === 0)
|
|
248
|
+
let visited = 0
|
|
249
|
+
while (queue.length > 0) {
|
|
250
|
+
const node = queue.shift()!
|
|
251
|
+
visited++
|
|
252
|
+
for (const next of adj.get(node) ?? []) {
|
|
253
|
+
const deg = (inDegree.get(next) ?? 1) - 1
|
|
254
|
+
inDegree.set(next, deg)
|
|
255
|
+
if (deg === 0) queue.push(next)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (visited < ids.size) {
|
|
259
|
+
console.warn(` ⚠ parseTaskPlan: dependency cycle detected`)
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
198
263
|
return parsed as unknown as TaskPlan
|
|
199
264
|
} catch {
|
|
200
265
|
return null
|
package/src/cli/pipeline.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseComplexityAssessment, deriveComplexityPath } from './pipeline.js'
|
|
2
|
+
import { parseComplexityAssessment, deriveComplexityPath, validateComplexityGroups, topologicalSortGroups } from './pipeline.js'
|
|
3
3
|
|
|
4
4
|
const SINGLE_JSON = JSON.stringify({
|
|
5
5
|
original_prompt: 'Build a REST API with user auth',
|
|
@@ -151,3 +151,132 @@ describe('deriveComplexityPath', () => {
|
|
|
151
151
|
)
|
|
152
152
|
})
|
|
153
153
|
})
|
|
154
|
+
|
|
155
|
+
describe('validateComplexityGroups', () => {
|
|
156
|
+
it('accepts valid groups', () => {
|
|
157
|
+
const assessment = {
|
|
158
|
+
original_prompt: 'test',
|
|
159
|
+
total_tasks: 6,
|
|
160
|
+
total_phases: 3,
|
|
161
|
+
domains: ['frontend', 'api'],
|
|
162
|
+
complexity: 'medium' as const,
|
|
163
|
+
recommended_strategy: 'chain' as const,
|
|
164
|
+
convoy_groups: [
|
|
165
|
+
{ name: 'backend', description: 'API', phases: [1, 2], depends_on: [] },
|
|
166
|
+
{ name: 'frontend', description: 'UI', phases: [3], depends_on: ['backend'] },
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
expect(validateComplexityGroups(assessment)).toEqual({ valid: true, reason: '' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('rejects groups exceeding max count for small projects', () => {
|
|
173
|
+
const assessment = {
|
|
174
|
+
original_prompt: 'test',
|
|
175
|
+
total_tasks: 10,
|
|
176
|
+
total_phases: 4,
|
|
177
|
+
domains: ['a'],
|
|
178
|
+
complexity: 'medium' as const,
|
|
179
|
+
recommended_strategy: 'chain' as const,
|
|
180
|
+
convoy_groups: [
|
|
181
|
+
{ name: 'a', description: 'A', phases: [1], depends_on: [] },
|
|
182
|
+
{ name: 'b', description: 'B', phases: [2], depends_on: [] },
|
|
183
|
+
{ name: 'c-group', description: 'C', phases: [3], depends_on: [] },
|
|
184
|
+
{ name: 'd-group', description: 'D', phases: [4], depends_on: [] },
|
|
185
|
+
],
|
|
186
|
+
}
|
|
187
|
+
const result = validateComplexityGroups(assessment)
|
|
188
|
+
expect(result.valid).toBe(false)
|
|
189
|
+
expect(result.reason).toContain('maximum')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('rejects overlapping phases', () => {
|
|
193
|
+
const assessment = {
|
|
194
|
+
original_prompt: 'test',
|
|
195
|
+
total_tasks: 8,
|
|
196
|
+
total_phases: 3,
|
|
197
|
+
domains: ['a'],
|
|
198
|
+
complexity: 'medium' as const,
|
|
199
|
+
recommended_strategy: 'chain' as const,
|
|
200
|
+
convoy_groups: [
|
|
201
|
+
{ name: 'group-a', description: 'A', phases: [1, 2], depends_on: [] },
|
|
202
|
+
{ name: 'group-b', description: 'B', phases: [2, 3], depends_on: [] },
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
const result = validateComplexityGroups(assessment)
|
|
206
|
+
expect(result.valid).toBe(false)
|
|
207
|
+
expect(result.reason).toContain('overlap')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('rejects invalid depends_on references', () => {
|
|
211
|
+
const assessment = {
|
|
212
|
+
original_prompt: 'test',
|
|
213
|
+
total_tasks: 8,
|
|
214
|
+
total_phases: 2,
|
|
215
|
+
domains: ['a'],
|
|
216
|
+
complexity: 'medium' as const,
|
|
217
|
+
recommended_strategy: 'chain' as const,
|
|
218
|
+
convoy_groups: [
|
|
219
|
+
{ name: 'group-a', description: 'A', phases: [1], depends_on: ['nonexistent'] },
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
const result = validateComplexityGroups(assessment)
|
|
223
|
+
expect(result.valid).toBe(false)
|
|
224
|
+
expect(result.reason).toContain('nonexistent')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects dependency cycles', () => {
|
|
228
|
+
const assessment = {
|
|
229
|
+
original_prompt: 'test',
|
|
230
|
+
total_tasks: 8,
|
|
231
|
+
total_phases: 2,
|
|
232
|
+
domains: ['a'],
|
|
233
|
+
complexity: 'medium' as const,
|
|
234
|
+
recommended_strategy: 'chain' as const,
|
|
235
|
+
convoy_groups: [
|
|
236
|
+
{ name: 'group-a', description: 'A', phases: [1], depends_on: ['group-b'] },
|
|
237
|
+
{ name: 'group-b', description: 'B', phases: [2], depends_on: ['group-a'] },
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
const result = validateComplexityGroups(assessment)
|
|
241
|
+
expect(result.valid).toBe(false)
|
|
242
|
+
expect(result.reason).toContain('cycle')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('rejects non-kebab-case group names', () => {
|
|
246
|
+
const assessment = {
|
|
247
|
+
original_prompt: 'test',
|
|
248
|
+
total_tasks: 8,
|
|
249
|
+
total_phases: 2,
|
|
250
|
+
domains: ['a'],
|
|
251
|
+
complexity: 'medium' as const,
|
|
252
|
+
recommended_strategy: 'chain' as const,
|
|
253
|
+
convoy_groups: [
|
|
254
|
+
{ name: 'My Group', description: 'Bad name', phases: [1], depends_on: [] },
|
|
255
|
+
],
|
|
256
|
+
}
|
|
257
|
+
const result = validateComplexityGroups(assessment)
|
|
258
|
+
expect(result.valid).toBe(false)
|
|
259
|
+
expect(result.reason).toContain('kebab')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('topologicalSortGroups', () => {
|
|
264
|
+
it('sorts groups in dependency order', () => {
|
|
265
|
+
const groups = [
|
|
266
|
+
{ name: 'c', description: 'C', phases: [3], depends_on: ['b'] },
|
|
267
|
+
{ name: 'a', description: 'A', phases: [1], depends_on: [] },
|
|
268
|
+
{ name: 'b', description: 'B', phases: [2], depends_on: ['a'] },
|
|
269
|
+
]
|
|
270
|
+
const sorted = topologicalSortGroups(groups)
|
|
271
|
+
expect(sorted.map(g => g.name)).toEqual(['a', 'b', 'c'])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('preserves order for independent groups', () => {
|
|
275
|
+
const groups = [
|
|
276
|
+
{ name: 'x', description: 'X', phases: [1], depends_on: [] },
|
|
277
|
+
{ name: 'y', description: 'Y', phases: [2], depends_on: [] },
|
|
278
|
+
]
|
|
279
|
+
const sorted = topologicalSortGroups(groups)
|
|
280
|
+
expect(sorted.map(g => g.name)).toEqual(['x', 'y'])
|
|
281
|
+
})
|
|
282
|
+
})
|