opencastle 0.33.5 → 0.33.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/convoy/engine.d.ts +5 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +85 -16
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +10 -12
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/convoy/pipeline.d.ts +3 -0
  8. package/dist/cli/convoy/pipeline.d.ts.map +1 -1
  9. package/dist/cli/convoy/pipeline.js +88 -18
  10. package/dist/cli/convoy/pipeline.js.map +1 -1
  11. package/dist/cli/convoy/store.d.ts.map +1 -1
  12. package/dist/cli/convoy/store.js +74 -3
  13. package/dist/cli/convoy/store.js.map +1 -1
  14. package/dist/cli/convoy/store.test.js +26 -14
  15. package/dist/cli/convoy/store.test.js.map +1 -1
  16. package/dist/cli/run.d.ts.map +1 -1
  17. package/dist/cli/run.js +1 -123
  18. package/dist/cli/run.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/convoy/engine.test.ts +10 -12
  21. package/src/cli/convoy/engine.ts +84 -16
  22. package/src/cli/convoy/pipeline.ts +81 -19
  23. package/src/cli/convoy/store.test.ts +28 -15
  24. package/src/cli/convoy/store.ts +74 -3
  25. package/src/cli/run.ts +0 -118
  26. package/src/dashboard/dist/data/convoys/demo-api-v2.json +3 -3
  27. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +10 -10
  28. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +18 -18
  29. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +3 -3
  30. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +1 -1
  31. package/src/dashboard/dist/data/convoys/demo-docs-update.json +3 -3
  32. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
  33. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  34. package/src/dashboard/public/data/convoys/demo-api-v2.json +3 -3
  35. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +10 -10
  36. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +18 -18
  37. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +3 -3
  38. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +1 -1
  39. package/src/dashboard/public/data/convoys/demo-docs-update.json +3 -3
  40. package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { mkdirSync } from 'node:fs'
3
- import { resolve, dirname, relative, isAbsolute, sep } from 'node:path'
3
+ import { resolve, join, dirname, relative, isAbsolute, sep } from 'node:path'
4
4
  import { execFile as execFileCb } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import type { TaskSpec, AgentAdapter } from '../types.js'
@@ -8,7 +8,6 @@ import { parseTaskSpecText } from '../run/schema.js'
8
8
  import { createConvoyStore } from './store.js'
9
9
  import {
10
10
  createConvoyEngine,
11
- ensureBranch,
12
11
  type ConvoyEngine,
13
12
  type ConvoyResult,
14
13
  type ConvoyEngineOptions,
@@ -51,6 +50,9 @@ export interface PipelineOrchestratorOptions {
51
50
  _createConvoyEngine?: (opts: ConvoyEngineOptions) => ConvoyEngine
52
51
  /** Injectable branch handler (used in tests). */
53
52
  _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
53
+ /** Pass `null` to skip pipeline-level worktree creation (test mode).
54
+ * Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
55
+ _convoyWorktreeDir?: string | null
54
56
  }
55
57
 
56
58
  // ── Internal helpers ──────────────────────────────────────────────────────────
@@ -87,7 +89,6 @@ export function createPipelineOrchestrator(
87
89
  const basePath = resolve(options.basePath ?? process.cwd())
88
90
  const dbPath = options.dbPath ?? resolve(basePath, '.opencastle', 'convoy.db')
89
91
  const engineFactory = options._createConvoyEngine ?? createConvoyEngine
90
- const branchFn = options._ensureBranch ?? ensureBranch
91
92
 
92
93
  async function getCurrentBranch(): Promise<string> {
93
94
  try {
@@ -118,7 +119,7 @@ export function createPipelineOrchestrator(
118
119
  specPath: string,
119
120
  pipelineId: string,
120
121
  branch: string,
121
- skipDirtyCheck = false,
122
+ effectiveBase: string = basePath,
122
123
  ): Promise<ConvoyResult> {
123
124
  const absPath = resolveSpecPath(specPath)
124
125
  const convoyYaml = await readFile(absPath, 'utf8')
@@ -129,16 +130,13 @@ export function createPipelineOrchestrator(
129
130
  spec: overriddenSpec,
130
131
  specYaml: convoyYaml,
131
132
  adapter,
132
- basePath,
133
+ basePath: effectiveBase,
133
134
  dbPath,
134
135
  logsDir: options.logsDir,
135
136
  verbose,
136
137
  pipelineId,
138
+ _convoyWorktreeDir: null,
137
139
  }
138
- if (skipDirtyCheck) {
139
- engineOpts._ensureBranch = (b, base) => ensureBranch(b, base, true)
140
- }
141
-
142
140
  const engine = engineFactory(engineOpts)
143
141
  return engine.run()
144
142
  }
@@ -149,10 +147,33 @@ export function createPipelineOrchestrator(
149
147
  const branch = spec.branch ?? (await getCurrentBranch())
150
148
  const convoySpecs = spec.depends_on_convoy ?? []
151
149
 
152
- // Switch branch BEFORE any DB writes — otherwise the convoy.db modification
153
- // from insertPipeline() causes ensureBranch's dirty check to fail.
150
+ // Create pipeline-level worktree for branch isolation.
151
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
152
+ let effectiveBasePath = basePath
153
+ let convoyWorktreeDir: string | undefined
154
154
  if (spec.branch !== undefined) {
155
- await branchFn(spec.branch, basePath)
155
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
156
+ if (!skipWorktree) {
157
+ if (typeof options._convoyWorktreeDir === 'string') {
158
+ effectiveBasePath = options._convoyWorktreeDir
159
+ convoyWorktreeDir = options._convoyWorktreeDir
160
+ } else {
161
+ const worktreeId = `convoy-root-${Date.now()}`
162
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
163
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
164
+ let branchExists = false
165
+ try {
166
+ await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
167
+ branchExists = true
168
+ } catch { /* branch doesn't exist */ }
169
+ if (branchExists) {
170
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
171
+ } else {
172
+ await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
173
+ }
174
+ effectiveBasePath = convoyWorktreeDir
175
+ }
176
+ }
156
177
  }
157
178
 
158
179
  mkdirSync(dirname(dbPath), { recursive: true })
@@ -187,10 +208,7 @@ export function createPipelineOrchestrator(
187
208
 
188
209
  let convoyResult: ConvoyResult
189
210
  try {
190
- // Always skip dirty check inside pipeline — run.ts pre-flight already
191
- // handled the stash prompt, and insertPipeline() writes to convoy.db
192
- // before the first convoy runs (which would cause a false dirty-check failure).
193
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
211
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
194
212
  } catch (err) {
195
213
  process.stderr.write(
196
214
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -218,11 +236,12 @@ export function createPipelineOrchestrator(
218
236
  spec: { ...spec, branch },
219
237
  specYaml,
220
238
  adapter,
221
- basePath,
239
+ basePath: effectiveBasePath,
222
240
  dbPath,
223
241
  logsDir: options.logsDir,
224
242
  verbose,
225
243
  pipelineId,
244
+ _convoyWorktreeDir: null,
226
245
  })
227
246
  const hybridResult = await hybridEngine.run()
228
247
  convoyResults.push(hybridResult)
@@ -238,6 +257,12 @@ export function createPipelineOrchestrator(
238
257
  failStore.close()
239
258
  }
240
259
  throw err
260
+ } finally {
261
+ if (convoyWorktreeDir) {
262
+ try {
263
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
264
+ } catch { /* ignore cleanup errors */ }
265
+ }
241
266
  }
242
267
 
243
268
  const totalTokens = aggregateTokens(convoyResults)
@@ -283,6 +308,36 @@ export function createPipelineOrchestrator(
283
308
  const convoySpecs: string[] = JSON.parse(pipeline.convoy_specs) as string[]
284
309
  const branch = pipeline.branch ?? spec.branch ?? (await getCurrentBranch())
285
310
 
311
+ // Create pipeline-level worktree for branch isolation.
312
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
313
+ let effectiveBasePath = basePath
314
+ let convoyWorktreeDir: string | undefined
315
+ const pipelineBranch = pipeline.branch ?? spec.branch
316
+ if (pipelineBranch !== undefined) {
317
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
318
+ if (!skipWorktree) {
319
+ if (typeof options._convoyWorktreeDir === 'string') {
320
+ effectiveBasePath = options._convoyWorktreeDir
321
+ convoyWorktreeDir = options._convoyWorktreeDir
322
+ } else {
323
+ const worktreeId = `convoy-root-${Date.now()}`
324
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
325
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
326
+ let branchExists = false
327
+ try {
328
+ await execFile('git', ['rev-parse', '--verify', pipelineBranch], { cwd: basePath })
329
+ branchExists = true
330
+ } catch { /* branch doesn't exist */ }
331
+ if (branchExists) {
332
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, pipelineBranch], { cwd: basePath })
333
+ } else {
334
+ await execFile('git', ['worktree', 'add', '-b', pipelineBranch, convoyWorktreeDir], { cwd: basePath })
335
+ }
336
+ effectiveBasePath = convoyWorktreeDir
337
+ }
338
+ }
339
+ }
340
+
286
341
  // Load all convoys linked to this pipeline, sorted by creation time
287
342
  const convoyStore = createConvoyStore(dbPath)
288
343
  let existingConvoys
@@ -350,11 +405,12 @@ export function createPipelineOrchestrator(
350
405
  spec: overriddenSpec,
351
406
  specYaml: convoyYaml,
352
407
  adapter,
353
- basePath,
408
+ basePath: effectiveBasePath,
354
409
  dbPath,
355
410
  logsDir: options.logsDir,
356
411
  verbose,
357
412
  pipelineId,
413
+ _convoyWorktreeDir: null,
358
414
  })
359
415
 
360
416
  if (existing.status === 'failed') {
@@ -366,7 +422,7 @@ export function createPipelineOrchestrator(
366
422
  } else {
367
423
  // Run fresh
368
424
  try {
369
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
425
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
370
426
  } catch (err) {
371
427
  process.stderr.write(
372
428
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -399,6 +455,12 @@ export function createPipelineOrchestrator(
399
455
  failStore.close()
400
456
  }
401
457
  throw err
458
+ } finally {
459
+ if (convoyWorktreeDir) {
460
+ try {
461
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
462
+ } catch { /* ignore cleanup errors */ }
463
+ }
402
464
  }
403
465
 
404
466
  const totalTokens = aggregateTokens(convoyResults)
@@ -99,11 +99,11 @@ describe('DB creation', () => {
99
99
  expect(row.journal_mode).toBe('wal')
100
100
  })
101
101
 
102
- it('sets schema version to 11', () => {
102
+ it('sets schema version to 12', () => {
103
103
  const db = new DatabaseSync(dbPath)
104
104
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
105
105
  db.close()
106
- expect(row.user_version).toBe(11)
106
+ expect(row.user_version).toBe(12)
107
107
  })
108
108
 
109
109
  it('creates all required tables', () => {
@@ -131,7 +131,7 @@ describe('DB creation', () => {
131
131
  store2.close()
132
132
  // Reassign so afterEach does not double-close
133
133
  store = createConvoyStore(dbPath)
134
- expect(row.user_version).toBe(11)
134
+ expect(row.user_version).toBe(12)
135
135
  })
136
136
  })
137
137
 
@@ -208,8 +208,8 @@ describe('schema migration', () => {
208
208
  verifyDb.close()
209
209
 
210
210
  expect(cols.map(c => c.name)).toContain('adapter')
211
- // v1 chains through v2→v3→v4→...→v7→v8→v9→v10→v11 in one init, so final version is 11
212
- expect(version.user_version).toBe(11)
211
+ // v1 chains through v2→v3→v4→...→v7→v8→v9→v10→v11→v12 in one init, so final version is 12
212
+ expect(version.user_version).toBe(12)
213
213
  })
214
214
 
215
215
  it('schema migration v2 to v3 adds cost columns', () => {
@@ -295,7 +295,7 @@ describe('schema migration', () => {
295
295
  expect(convoyColNames).toContain('total_tokens')
296
296
  expect(convoyColNames).toContain('total_cost_usd')
297
297
 
298
- expect(version.user_version).toBe(11)
298
+ expect(version.user_version).toBe(12)
299
299
  })
300
300
 
301
301
  it('schema migration v1 to v3 chains correctly in a single init', () => {
@@ -381,7 +381,7 @@ describe('schema migration', () => {
381
381
  expect(convoyColNames).toContain('total_tokens')
382
382
  expect(convoyColNames).toContain('total_cost_usd')
383
383
 
384
- expect(version.user_version).toBe(11)
384
+ expect(version.user_version).toBe(12)
385
385
  })
386
386
 
387
387
  it('schema migration v3 to v4 creates pipeline table and adds pipeline_id to convoy', () => {
@@ -464,7 +464,7 @@ describe('schema migration', () => {
464
464
 
465
465
  expect(convoyCols.map(c => c.name)).toContain('pipeline_id')
466
466
  expect(tables.map(t => t.name)).toContain('pipeline')
467
- expect(version.user_version).toBe(11)
467
+ expect(version.user_version).toBe(12)
468
468
  })
469
469
  })
470
470
 
@@ -580,6 +580,20 @@ describe('task CRUD', () => {
580
580
  expect(tasks[1].phase).toBe(1)
581
581
  })
582
582
 
583
+ it('allows same task ID in different convoys', () => {
584
+ store.insertConvoy(makeConvoy({ id: 'convoy-a' }))
585
+ store.insertConvoy(makeConvoy({ id: 'convoy-b' }))
586
+ store.insertTask(makeTask({ id: 'shared-task', convoy_id: 'convoy-a' }))
587
+ store.insertTask(makeTask({ id: 'shared-task', convoy_id: 'convoy-b' }))
588
+
589
+ const taskA = store.getTask('shared-task', 'convoy-a')
590
+ const taskB = store.getTask('shared-task', 'convoy-b')
591
+ expect(taskA).toBeDefined()
592
+ expect(taskB).toBeDefined()
593
+ expect(taskA!.convoy_id).toBe('convoy-a')
594
+ expect(taskB!.convoy_id).toBe('convoy-b')
595
+ })
596
+
583
597
  it('updates task status', () => {
584
598
  store.insertTask(makeTask())
585
599
  store.updateTaskStatus('task-1', 'convoy-1', 'running')
@@ -1444,7 +1458,7 @@ describe('schema migration v5 → v6', () => {
1444
1458
  v5Verify.close()
1445
1459
  migratedStore.close()
1446
1460
 
1447
- expect(row.user_version).toBe(11)
1461
+ expect(row.user_version).toBe(12)
1448
1462
  expect(taskStepTable?.name).toBe('task_step')
1449
1463
  expect(convoy?.id).toBe('convoy-auto')
1450
1464
  expect(task?.id).toBe('task-auto')
@@ -1614,7 +1628,7 @@ describe('schema migration v6→v7 (drift detection columns)', () => {
1614
1628
 
1615
1629
  expect(cols.map(c => c.name)).toContain('drift_score')
1616
1630
  expect(cols.map(c => c.name)).toContain('drift_retried')
1617
- expect(version.user_version).toBe(11)
1631
+ expect(version.user_version).toBe(12)
1618
1632
  })
1619
1633
 
1620
1634
  it('new databases include drift_score and drift_retried in CREATE TABLE', () => {
@@ -1846,10 +1860,9 @@ describe('migration full chain v4→v10', () => {
1846
1860
  migratedStore.close()
1847
1861
 
1848
1862
  const verifyDb = new DatabaseSync(chainDbPath)
1849
-
1850
- // Verify user_version = 11
1863
+ verifyDb.exec('PRAGMA foreign_keys = 0')
1851
1864
  const version = (verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
1852
- expect(version).toBe(11)
1865
+ expect(version).toBe(12)
1853
1866
 
1854
1867
  // Verify all new tables exist
1855
1868
  const tables = (verifyDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>).map(t => t.name)
@@ -1884,7 +1897,7 @@ describe('migration full chain v4→v10', () => {
1884
1897
  const eventCount = (verifyDb.prepare('SELECT COUNT(*) AS cnt FROM event WHERE convoy_id = :id').get({ id: 'convoy-chain' }) as { cnt: number }).cnt
1885
1898
  expect(eventCount).toBe(1)
1886
1899
 
1887
- // Verify FK constraints work: insert a task_step referencing the seeded task_id
1900
+ // Verify task_step table accepts inserts
1888
1901
  expect(() => {
1889
1902
  verifyDb.prepare(
1890
1903
  `INSERT INTO task_step (task_id, step_index, prompt, gates, status)
@@ -2528,7 +2541,7 @@ describe('v9→v10 migration', () => {
2528
2541
  // Verify version = 11
2529
2542
  const verifyDb = new DatabaseSync(migDb)
2530
2543
  const version = (verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
2531
- expect(version).toBe(11)
2544
+ expect(version).toBe(12)
2532
2545
 
2533
2546
  // Verify new REAL columns exist
2534
2547
  const convoyCols = (verifyDb.prepare('PRAGMA table_info(convoy)').all() as Array<{ name: string }>).map(c => c.name)
@@ -17,7 +17,7 @@ import type {
17
17
  TaskStepRecord,
18
18
  } from './types.js'
19
19
 
20
- const SCHEMA_VERSION = 11
20
+ const SCHEMA_VERSION = 12
21
21
 
22
22
  // ── Size limits (bytes) ────────────────────────────────────────────────────────
23
23
  const LIMIT_SPEC_YAML = 256 * 1024 // 256 KB
@@ -178,6 +178,7 @@ class ConvoyStoreImpl implements ConvoyStore {
178
178
  constructor(dbPath: string) {
179
179
  this.dbPath = dbPath
180
180
  this.db = new DatabaseSync(dbPath)
181
+ this.db.exec('PRAGMA foreign_keys = 0')
181
182
  this.db.exec('PRAGMA journal_mode = WAL')
182
183
  this.db.exec('PRAGMA synchronous = NORMAL')
183
184
  this.initSchema()
@@ -222,7 +223,7 @@ class ConvoyStoreImpl implements ConvoyStore {
222
223
  );
223
224
 
224
225
  CREATE TABLE IF NOT EXISTS task (
225
- id TEXT PRIMARY KEY,
226
+ id TEXT NOT NULL,
226
227
  convoy_id TEXT NOT NULL REFERENCES convoy(id),
227
228
  phase INTEGER NOT NULL,
228
229
  prompt TEXT NOT NULL,
@@ -265,7 +266,8 @@ class ConvoyStoreImpl implements ConvoyStore {
265
266
  outputs TEXT,
266
267
  inputs TEXT,
267
268
  discovered_issues TEXT,
268
- contract_result TEXT
269
+ contract_result TEXT,
270
+ PRIMARY KEY (id, convoy_id)
269
271
  );
270
272
 
271
273
  CREATE UNIQUE INDEX IF NOT EXISTS idx_task_idempotency ON task(convoy_id, idempotency_key)
@@ -424,6 +426,10 @@ class ConvoyStoreImpl implements ConvoyStore {
424
426
  migrateSchema(this.db, this.dbPath, 10, 11)
425
427
  version = 11
426
428
  }
429
+ if (version === 11) {
430
+ migrateSchema(this.db, this.dbPath, 11, 12)
431
+ version = 12
432
+ }
427
433
  }
428
434
 
429
435
  insertConvoy(
@@ -1356,6 +1362,71 @@ export function migrateSchema(db: DatabaseSync, dbPath: string, fromVersion: num
1356
1362
  ALTER TABLE task ADD COLUMN compaction_count INTEGER NOT NULL DEFAULT 0;
1357
1363
  `)
1358
1364
  }
1365
+ if (v === 11) {
1366
+ db.exec(`
1367
+ CREATE TABLE task_new (
1368
+ id TEXT NOT NULL,
1369
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1370
+ phase INTEGER NOT NULL,
1371
+ prompt TEXT NOT NULL,
1372
+ agent TEXT NOT NULL DEFAULT 'developer',
1373
+ adapter TEXT,
1374
+ model TEXT,
1375
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
1376
+ status TEXT NOT NULL DEFAULT 'pending',
1377
+ worker_id TEXT,
1378
+ worktree TEXT,
1379
+ output TEXT,
1380
+ exit_code INTEGER,
1381
+ started_at TEXT,
1382
+ finished_at TEXT,
1383
+ retries INTEGER NOT NULL DEFAULT 0,
1384
+ max_retries INTEGER NOT NULL DEFAULT 1,
1385
+ files TEXT,
1386
+ depends_on TEXT,
1387
+ prompt_tokens INTEGER,
1388
+ completion_tokens INTEGER,
1389
+ total_tokens INTEGER,
1390
+ cost_usd TEXT,
1391
+ cost_usd_num REAL,
1392
+ gates TEXT,
1393
+ on_exhausted TEXT NOT NULL DEFAULT 'dlq',
1394
+ injected INTEGER NOT NULL DEFAULT 0,
1395
+ provenance TEXT,
1396
+ idempotency_key TEXT,
1397
+ current_step INTEGER,
1398
+ total_steps INTEGER,
1399
+ review_level TEXT,
1400
+ review_verdict TEXT,
1401
+ review_tokens INTEGER,
1402
+ review_model TEXT,
1403
+ panel_attempts INTEGER NOT NULL DEFAULT 0,
1404
+ dispute_id TEXT,
1405
+ drift_score REAL,
1406
+ drift_retried INTEGER NOT NULL DEFAULT 0,
1407
+ compaction_count INTEGER NOT NULL DEFAULT 0,
1408
+ outputs TEXT,
1409
+ inputs TEXT,
1410
+ discovered_issues TEXT,
1411
+ contract_result TEXT,
1412
+ PRIMARY KEY (id, convoy_id)
1413
+ );
1414
+ INSERT INTO task_new SELECT
1415
+ id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms,
1416
+ status, worker_id, worktree, output, exit_code, started_at, finished_at,
1417
+ retries, max_retries, files, depends_on, prompt_tokens, completion_tokens,
1418
+ total_tokens, cost_usd, cost_usd_num, gates, on_exhausted, injected,
1419
+ provenance, idempotency_key, current_step, total_steps, review_level,
1420
+ review_verdict, review_tokens, review_model, panel_attempts, dispute_id,
1421
+ drift_score, drift_retried, compaction_count, outputs, inputs,
1422
+ discovered_issues, contract_result
1423
+ FROM task;
1424
+ DROP TABLE task;
1425
+ ALTER TABLE task_new RENAME TO task;
1426
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_idempotency ON task(convoy_id, idempotency_key)
1427
+ WHERE idempotency_key IS NOT NULL;
1428
+ `)
1429
+ }
1359
1430
  db.exec('COMMIT')
1360
1431
  } catch (err) {
1361
1432
  try { db.exec('ROLLBACK') } catch { /* ignore */ }
package/src/cli/run.ts CHANGED
@@ -871,54 +871,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
871
871
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
872
872
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
873
873
 
874
- // ── Pre-flight: handle uncommitted changes before branch switch ──
875
- let pipelineDidStash = false
876
- if (spec.branch) {
877
- const { execFile: execFileCb } = await import('node:child_process')
878
- const { promisify } = await import('node:util')
879
- const execFile = promisify(execFileCb)
880
- let statusOut: string
881
- try {
882
- const result = await execFile('git', ['status', '--porcelain'], {
883
- cwd: process.cwd(),
884
- })
885
- statusOut = result.stdout
886
- } catch {
887
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
888
- console.error(` Run \`git init\` to initialize a repository.`)
889
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
890
- process.exit(1)
891
- }
892
- // Untracked files (??) don't block branch checkout — ignore them
893
- const trackedChanges = statusOut
894
- .split('\n')
895
- .filter(line => line.trim() && !line.startsWith('??'))
896
- .join('\n')
897
- if (trackedChanges) {
898
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
899
- const shouldStash = await confirm('Stash changes and continue?', true)
900
- if (!shouldStash) {
901
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
902
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
903
- closePrompts()
904
- process.exit(1)
905
- }
906
- try {
907
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before pipeline'], {
908
- cwd: process.cwd(),
909
- })
910
- pipelineDidStash = true
911
- console.log(` ${c.green('✓')} Changes stashed.`)
912
- } catch {
913
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
914
- console.log(` Commit or stash your changes manually, then resume:\n`)
915
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
916
- closePrompts()
917
- process.exit(1)
918
- }
919
- }
920
- }
921
-
922
874
  const { startDashboardServer } = await import('./dashboard.js')
923
875
  let pipelineDashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
924
876
  try {
@@ -953,17 +905,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
953
905
  throw err
954
906
  }
955
907
  printPipelineResult(pipelineResult)
956
- if (pipelineDidStash) {
957
- const { execFile: execFileCb } = await import('node:child_process')
958
- const { promisify } = await import('node:util')
959
- const execFile = promisify(execFileCb)
960
- try {
961
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
962
- console.log(` ${c.green('✓')} Stashed changes restored.`)
963
- } catch {
964
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
965
- }
966
- }
967
908
  if (pipelineDashboardResult) {
968
909
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
969
910
  console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
@@ -996,54 +937,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
996
937
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
997
938
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
998
939
 
999
- // ── Pre-flight: handle uncommitted changes before branch switch ──
1000
- let didStash = false
1001
- if (spec.branch) {
1002
- const { execFile: execFileCb } = await import('node:child_process')
1003
- const { promisify } = await import('node:util')
1004
- const execFile = promisify(execFileCb)
1005
- let statusOut: string
1006
- try {
1007
- const result = await execFile('git', ['status', '--porcelain'], {
1008
- cwd: process.cwd(),
1009
- })
1010
- statusOut = result.stdout
1011
- } catch {
1012
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
1013
- console.error(` Run \`git init\` to initialize a repository.`)
1014
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1015
- process.exit(1)
1016
- }
1017
- // Untracked files (??) don't block branch checkout — ignore them
1018
- const trackedChanges = statusOut
1019
- .split('\n')
1020
- .filter(line => line.trim() && !line.startsWith('??'))
1021
- .join('\n')
1022
- if (trackedChanges) {
1023
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
1024
- const shouldStash = await confirm('Stash changes and continue?', true)
1025
- if (!shouldStash) {
1026
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
1027
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1028
- closePrompts()
1029
- process.exit(1)
1030
- }
1031
- try {
1032
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before convoy'], {
1033
- cwd: process.cwd(),
1034
- })
1035
- didStash = true
1036
- console.log(` ${c.green('✓')} Changes stashed.`)
1037
- } catch {
1038
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
1039
- console.log(` Commit or stash your changes manually, then resume:\n`)
1040
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1041
- closePrompts()
1042
- process.exit(1)
1043
- }
1044
- }
1045
- }
1046
-
1047
940
  const { startDashboardServer } = await import('./dashboard.js')
1048
941
  let dashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
1049
942
  try {
@@ -1095,17 +988,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
1095
988
  throw err
1096
989
  }
1097
990
  printConvoyResult(result)
1098
- if (didStash) {
1099
- const { execFile: execFileCb } = await import('node:child_process')
1100
- const { promisify } = await import('node:util')
1101
- const execFile = promisify(execFileCb)
1102
- try {
1103
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
1104
- console.log(` ${c.green('✓')} Stashed changes restored.`)
1105
- } catch {
1106
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
1107
- }
1108
- }
1109
991
  if (dashboardResult) {
1110
992
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
1111
993
  console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
@@ -51,21 +51,21 @@
51
51
  "name": "docs/api-v2-contract.json",
52
52
  "type": "json",
53
53
  "task_id": "api-t1",
54
- "created_at": "2026-04-06T09:01:29.678Z"
54
+ "created_at": "2026-04-07T12:58:13.324Z"
55
55
  },
56
56
  {
57
57
  "id": "artifact-demo-api-v2-reports-security-gate-failure-md",
58
58
  "name": "reports/security-gate-failure.md",
59
59
  "type": "summary",
60
60
  "task_id": "api-t3",
61
- "created_at": "2026-04-06T09:01:29.678Z"
61
+ "created_at": "2026-04-07T12:58:13.324Z"
62
62
  },
63
63
  {
64
64
  "id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
65
65
  "name": "src/api/rate-limiter.ts",
66
66
  "type": "file",
67
67
  "task_id": "api-t2",
68
- "created_at": "2026-04-06T09:01:29.678Z"
68
+ "created_at": "2026-04-07T12:58:13.324Z"
69
69
  }
70
70
  ],
71
71
  "has_more_events": false,
@@ -42,28 +42,28 @@
42
42
  "name": "libs/auth/src/jwt-middleware.ts",
43
43
  "type": "file",
44
44
  "task_id": "auth-t2",
45
- "created_at": "2026-04-06T09:01:29.677Z"
45
+ "created_at": "2026-04-07T12:58:13.323Z"
46
46
  },
47
47
  {
48
48
  "id": "artifact-demo-auth-revamp-libs-auth-src-rls-policies-sql",
49
49
  "name": "libs/auth/src/rls-policies.sql",
50
50
  "type": "file",
51
51
  "task_id": "auth-t3",
52
- "created_at": "2026-04-06T09:01:29.677Z"
53
- },
54
- {
55
- "id": "artifact-demo-auth-revamp-reports-auth-review-summary-md",
56
- "name": "reports/auth-review-summary.md",
57
- "type": "summary",
58
- "task_id": "auth-t5",
59
- "created_at": "2026-04-06T09:01:29.677Z"
52
+ "created_at": "2026-04-07T12:58:13.323Z"
60
53
  },
61
54
  {
62
55
  "id": "artifact-demo-auth-revamp-tests-auth-integration-test-ts",
63
56
  "name": "tests/auth/integration.test.ts",
64
57
  "type": "file",
65
58
  "task_id": "auth-t4",
66
- "created_at": "2026-04-06T09:01:29.677Z"
59
+ "created_at": "2026-04-07T12:58:13.323Z"
60
+ },
61
+ {
62
+ "id": "artifact-demo-auth-revamp-reports-auth-review-summary-md",
63
+ "name": "reports/auth-review-summary.md",
64
+ "type": "summary",
65
+ "task_id": "auth-t5",
66
+ "created_at": "2026-04-07T12:58:13.324Z"
67
67
  }
68
68
  ],
69
69
  "has_more_events": false,