opencastle 0.16.0 → 0.18.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.
Files changed (65) hide show
  1. package/dist/cli/convoy/engine.d.ts.map +1 -1
  2. package/dist/cli/convoy/engine.js +47 -11
  3. package/dist/cli/convoy/engine.js.map +1 -1
  4. package/dist/cli/convoy/engine.test.js +104 -1
  5. package/dist/cli/convoy/engine.test.js.map +1 -1
  6. package/dist/cli/convoy/export.d.ts +3 -0
  7. package/dist/cli/convoy/export.d.ts.map +1 -0
  8. package/dist/cli/convoy/export.js +46 -0
  9. package/dist/cli/convoy/export.js.map +1 -0
  10. package/dist/cli/convoy/export.test.d.ts +2 -0
  11. package/dist/cli/convoy/export.test.d.ts.map +1 -0
  12. package/dist/cli/convoy/export.test.js +157 -0
  13. package/dist/cli/convoy/export.test.js.map +1 -0
  14. package/dist/cli/convoy/health.test.js +1 -0
  15. package/dist/cli/convoy/health.test.js.map +1 -1
  16. package/dist/cli/convoy/store.d.ts.map +1 -1
  17. package/dist/cli/convoy/store.js +8 -3
  18. package/dist/cli/convoy/store.js.map +1 -1
  19. package/dist/cli/convoy/store.test.js +83 -3
  20. package/dist/cli/convoy/store.test.js.map +1 -1
  21. package/dist/cli/convoy/types.d.ts +1 -0
  22. package/dist/cli/convoy/types.d.ts.map +1 -1
  23. package/dist/cli/dashboard.d.ts +14 -0
  24. package/dist/cli/dashboard.d.ts.map +1 -1
  25. package/dist/cli/dashboard.js +73 -36
  26. package/dist/cli/dashboard.js.map +1 -1
  27. package/dist/cli/run/adapters/index.d.ts.map +1 -1
  28. package/dist/cli/run/adapters/index.js +2 -1
  29. package/dist/cli/run/adapters/index.js.map +1 -1
  30. package/dist/cli/run/adapters/opencode.d.ts +16 -0
  31. package/dist/cli/run/adapters/opencode.d.ts.map +1 -0
  32. package/dist/cli/run/adapters/opencode.js +75 -0
  33. package/dist/cli/run/adapters/opencode.js.map +1 -0
  34. package/dist/cli/run/schema.d.ts.map +1 -1
  35. package/dist/cli/run/schema.js +11 -0
  36. package/dist/cli/run/schema.js.map +1 -1
  37. package/dist/cli/run/schema.test.js +44 -0
  38. package/dist/cli/run/schema.test.js.map +1 -1
  39. package/dist/cli/run.d.ts +1 -1
  40. package/dist/cli/run.d.ts.map +1 -1
  41. package/dist/cli/run.js +18 -1
  42. package/dist/cli/run.js.map +1 -1
  43. package/dist/cli/types.d.ts +3 -0
  44. package/dist/cli/types.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/cli/convoy/engine.test.ts +126 -1
  47. package/src/cli/convoy/engine.ts +39 -9
  48. package/src/cli/convoy/export.test.ts +190 -0
  49. package/src/cli/convoy/export.ts +52 -0
  50. package/src/cli/convoy/health.test.ts +1 -0
  51. package/src/cli/convoy/store.test.ts +89 -3
  52. package/src/cli/convoy/store.ts +8 -3
  53. package/src/cli/convoy/types.ts +1 -0
  54. package/src/cli/dashboard.ts +94 -42
  55. package/src/cli/run/adapters/index.ts +2 -1
  56. package/src/cli/run/adapters/opencode.ts +88 -0
  57. package/src/cli/run/schema.test.ts +50 -0
  58. package/src/cli/run/schema.ts +13 -0
  59. package/src/cli/run.ts +19 -1
  60. package/src/cli/types.ts +3 -0
  61. package/src/dashboard/dist/_astro/{index.Bnq19_1M.css → index.DyyaCW8L.css} +1 -1
  62. package/src/dashboard/dist/index.html +145 -6
  63. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  64. package/src/dashboard/src/pages/index.astro +160 -4
  65. package/src/dashboard/src/styles/dashboard.css +60 -0
@@ -9,9 +9,11 @@ import { createEventEmitter, type ConvoyEventEmitter } from './events.js'
9
9
  import { createWorktreeManager, type WorktreeManager } from './worktree.js'
10
10
  import { createMergeQueue, type MergeQueue } from './merge.js'
11
11
  import { createHealthMonitor } from './health.js'
12
+ import { exportConvoyToNdjson } from './export.js'
12
13
  import type { TaskRecord, ConvoyStatus } from './types.js'
13
14
  import { buildPhases, formatDuration } from '../run/executor.js'
14
15
  import { parseTimeout } from '../run/schema.js'
16
+ import { getAdapter, detectAdapter } from '../run/adapters/index.js'
15
17
 
16
18
  const execFile = promisify(execFileCb)
17
19
 
@@ -61,6 +63,7 @@ function taskRecordToTask(record: TaskRecord): Task {
61
63
  description: '',
62
64
  model: record.model ?? undefined,
63
65
  max_retries: record.max_retries,
66
+ adapter: record.adapter ?? undefined,
64
67
  }
65
68
  }
66
69
 
@@ -91,6 +94,7 @@ async function runConvoy(
91
94
  startTime: number,
92
95
  ): Promise<ConvoyResult> {
93
96
  const activeTaskMap = new Map<string, Task>()
97
+ const taskAdapterMap = new Map<string, AgentAdapter>()
94
98
 
95
99
  const healthMonitor = createHealthMonitor({
96
100
  store,
@@ -98,10 +102,12 @@ async function runConvoy(
98
102
  convoyId,
99
103
  onKill: (workerId, taskId) => {
100
104
  const task = activeTaskMap.get(taskId)
101
- if (task && typeof adapter.kill === 'function') {
102
- adapter.kill(task)
105
+ const taskAdpt = taskAdapterMap.get(taskId) ?? adapter
106
+ if (task && typeof taskAdpt.kill === 'function') {
107
+ taskAdpt.kill(task)
103
108
  }
104
109
  activeTaskMap.delete(taskId)
110
+ taskAdapterMap.delete(taskId)
105
111
  },
106
112
  })
107
113
  healthMonitor.start()
@@ -148,9 +154,23 @@ async function runConvoy(
148
154
  const workerId = `worker-${taskRecord.id}-${Date.now()}`
149
155
  const now = () => new Date().toISOString()
150
156
 
157
+ // Resolve per-task adapter (fallback to convoy-level adapter)
158
+ let taskAdapter: AgentAdapter = adapter
159
+ if (taskRecord.adapter && taskRecord.adapter !== adapter.name) {
160
+ if (taskRecord.adapter === 'auto') {
161
+ const detected = await detectAdapter()
162
+ if (detected) {
163
+ taskAdapter = await getAdapter(detected)
164
+ }
165
+ } else {
166
+ taskAdapter = await getAdapter(taskRecord.adapter)
167
+ }
168
+ }
169
+ taskAdapterMap.set(taskRecord.id, taskAdapter)
170
+
151
171
  // Create worktree (skip for copilot adapter)
152
172
  let worktreePath: string | null = null
153
- if (adapter.name !== 'copilot') {
173
+ if (taskAdapter.name !== 'copilot') {
154
174
  try {
155
175
  worktreePath = await wtManager.create(workerId, baseBranch)
156
176
  } catch (err) {
@@ -165,7 +185,7 @@ async function runConvoy(
165
185
  store.insertWorker({
166
186
  id: workerId,
167
187
  task_id: taskRecord.id,
168
- adapter: adapter.name,
188
+ adapter: taskAdapter.name,
169
189
  pid: null,
170
190
  session_id: null,
171
191
  status: 'spawned',
@@ -196,7 +216,7 @@ async function runConvoy(
196
216
  let result: ExecuteResult
197
217
  try {
198
218
  result = await Promise.race([
199
- adapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
219
+ taskAdapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
200
220
  timeout.promise,
201
221
  ])
202
222
  timeout.clear()
@@ -217,7 +237,7 @@ async function runConvoy(
217
237
 
218
238
  // ── Timed out ───────────────────────────────────────────────────────────
219
239
  if (result._timedOut) {
220
- if (typeof adapter.kill === 'function') adapter.kill(task)
240
+ if (typeof taskAdapter.kill === 'function') taskAdapter.kill(task)
221
241
  await removeWorktree()
222
242
 
223
243
  const freshRecord = store.getTask(taskRecord.id, convoyId)!
@@ -251,6 +271,7 @@ async function runConvoy(
251
271
  )
252
272
  cascadeFailure(taskRecord.id)
253
273
  }
274
+ taskAdapterMap.delete(taskRecord.id)
254
275
  return
255
276
  }
256
277
 
@@ -283,11 +304,12 @@ async function runConvoy(
283
304
  { exit_code: result.exitCode, worker_id: workerId },
284
305
  { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
285
306
  )
307
+ taskAdapterMap.delete(taskRecord.id)
286
308
  return
287
309
  }
288
310
 
289
311
  // ── Failure ─────────────────────────────────────────────────────────────
290
- if (typeof adapter.kill === 'function') adapter.kill(task)
312
+ if (typeof taskAdapter.kill === 'function') taskAdapter.kill(task)
291
313
  await removeWorktree()
292
314
 
293
315
  const freshRecord = store.getTask(taskRecord.id, convoyId)!
@@ -322,6 +344,7 @@ async function runConvoy(
322
344
  )
323
345
  cascadeFailure(taskRecord.id)
324
346
  }
347
+ taskAdapterMap.delete(taskRecord.id)
325
348
  }
326
349
 
327
350
  // ── Main execution loop ───────────────────────────────────────────────────
@@ -416,6 +439,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
416
439
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
417
440
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
418
441
 
442
+ let result: ConvoyResult
419
443
  try {
420
444
  store.insertConvoy({
421
445
  id: convoyId,
@@ -437,6 +461,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
437
461
  phase: phaseIdx,
438
462
  prompt: task.prompt,
439
463
  agent: task.agent,
464
+ adapter: task.adapter ?? null,
440
465
  model: task.model ?? null,
441
466
  timeout_ms: parseTimeout(task.timeout),
442
467
  status: 'pending',
@@ -451,13 +476,15 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
451
476
  store.updateConvoyStatus(convoyId, 'running', { started_at: new Date().toISOString() })
452
477
  events.emit('convoy_started', { name: spec.name }, { convoy_id: convoyId })
453
478
 
454
- return await runConvoy(
479
+ result = await runConvoy(
455
480
  convoyId, spec, adapter, store, events,
456
481
  wtManager, mergeQueue, basePath, baseBranch, verbose, startTime,
457
482
  )
458
483
  } finally {
484
+ try { await exportConvoyToNdjson(store, convoyId, options.logsDir) } catch { /* silent */ }
459
485
  store.close()
460
486
  }
487
+ return result
461
488
  }
462
489
 
463
490
  async function resume(convoyId: string): Promise<ConvoyResult> {
@@ -469,6 +496,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
469
496
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
470
497
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
471
498
 
499
+ let result: ConvoyResult
472
500
  try {
473
501
  const convoy = store.getConvoy(convoyId)
474
502
  if (!convoy) {
@@ -508,13 +536,15 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
508
536
  { convoy_id: convoyId },
509
537
  )
510
538
 
511
- return await runConvoy(
539
+ result = await runConvoy(
512
540
  convoyId, spec, adapter, store, events,
513
541
  wtManager, mergeQueue, basePath, baseBranch, verbose, startTime,
514
542
  )
515
543
  } finally {
544
+ try { await exportConvoyToNdjson(store, convoyId, options.logsDir) } catch { /* silent */ }
516
545
  store.close()
517
546
  }
547
+ return result
518
548
  }
519
549
 
520
550
  return { run, resume }
@@ -0,0 +1,190 @@
1
+ import { mkdtempSync, rmSync, readFileSync, existsSync, realpathSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
5
+ import { exportConvoyToNdjson } from './export.js'
6
+ import { createConvoyStore } from './store.js'
7
+ import type { ConvoyStore } from './store.js'
8
+ import type { ConvoyTaskStatus } from './types.js'
9
+
10
+ vi.mock('../log.js', () => ({
11
+ appendEvent: vi.fn().mockResolvedValue(undefined),
12
+ }))
13
+
14
+ const NOW = '2026-03-08T10:00:00.000Z'
15
+
16
+ let tmpDir: string
17
+ let store: ConvoyStore
18
+
19
+ function insertConvoy(id: string, name = 'Test Convoy') {
20
+ store.insertConvoy({
21
+ id,
22
+ name,
23
+ spec_hash: 'abc123',
24
+ status: 'done',
25
+ branch: 'main',
26
+ created_at: NOW,
27
+ spec_yaml: 'name: test',
28
+ })
29
+ store.updateConvoyStatus(id, 'done', { started_at: NOW, finished_at: NOW })
30
+ }
31
+
32
+ function insertTask(
33
+ taskId: string,
34
+ convoyId: string,
35
+ phase = 1,
36
+ status: ConvoyTaskStatus = 'done',
37
+ ) {
38
+ store.insertTask({
39
+ id: taskId,
40
+ convoy_id: convoyId,
41
+ phase,
42
+ prompt: 'Do something',
43
+ agent: 'developer',
44
+ adapter: 'claude-code',
45
+ model: null,
46
+ timeout_ms: 1_800_000,
47
+ status,
48
+ retries: 0,
49
+ max_retries: 1,
50
+ files: null,
51
+ depends_on: null,
52
+ })
53
+ if (status !== 'pending') {
54
+ store.updateTaskStatus(taskId, convoyId, status, {
55
+ started_at: NOW,
56
+ finished_at: NOW,
57
+ retries: 0,
58
+ })
59
+ }
60
+ }
61
+
62
+ function insertEvent(convoyId: string) {
63
+ store.insertEvent({
64
+ convoy_id: convoyId,
65
+ task_id: null,
66
+ worker_id: null,
67
+ type: 'convoy.started',
68
+ data: null,
69
+ created_at: NOW,
70
+ })
71
+ }
72
+
73
+ beforeEach(() => {
74
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'convoy-export-test-')))
75
+ store = createConvoyStore(join(tmpDir, 'convoy.db'))
76
+ })
77
+
78
+ afterEach(() => {
79
+ store.close()
80
+ rmSync(tmpDir, { recursive: true, force: true })
81
+ })
82
+
83
+ describe('exportConvoyToNdjson', () => {
84
+ it('creates convoys.ndjson with valid NDJSON', async () => {
85
+ insertConvoy('c1')
86
+ insertTask('t1', 'c1')
87
+ const logsDir = join(tmpDir, 'logs')
88
+
89
+ await exportConvoyToNdjson(store, 'c1', logsDir)
90
+
91
+ const outFile = join(logsDir, 'convoys.ndjson')
92
+ expect(existsSync(outFile)).toBe(true)
93
+ const line = readFileSync(outFile, 'utf8').trimEnd()
94
+ expect(() => JSON.parse(line)).not.toThrow()
95
+ })
96
+
97
+ it('appends on multiple exports (2 convoys -> 2 lines)', async () => {
98
+ insertConvoy('c1')
99
+ insertConvoy('c2', 'Second Convoy')
100
+ const logsDir = join(tmpDir, 'logs')
101
+
102
+ await exportConvoyToNdjson(store, 'c1', logsDir)
103
+ await exportConvoyToNdjson(store, 'c2', logsDir)
104
+
105
+ const content = readFileSync(join(logsDir, 'convoys.ndjson'), 'utf8')
106
+ const lines = content.trim().split('\n').filter(Boolean)
107
+ expect(lines).toHaveLength(2)
108
+ expect(JSON.parse(lines[0]).id).toBe('c1')
109
+ expect(JSON.parse(lines[1]).id).toBe('c2')
110
+ })
111
+
112
+ it('required fields present', async () => {
113
+ insertConvoy('c1')
114
+ insertTask('t1', 'c1', 1, 'done')
115
+ insertTask('t2', 'c1', 1, 'failed')
116
+ insertEvent('c1')
117
+ const logsDir = join(tmpDir, 'logs')
118
+
119
+ await exportConvoyToNdjson(store, 'c1', logsDir)
120
+
121
+ const record = JSON.parse(readFileSync(join(logsDir, 'convoys.ndjson'), 'utf8').trim())
122
+ expect(record.id).toBe('c1')
123
+ expect(record.name).toBe('Test Convoy')
124
+ expect(record.status).toBe('done')
125
+ expect(Array.isArray(record.tasks)).toBe(true)
126
+ expect(record.tasks).toHaveLength(2)
127
+ expect(record.tasks[0]).toMatchObject({
128
+ id: 't1',
129
+ phase: 1,
130
+ agent: 'developer',
131
+ adapter: 'claude-code',
132
+ status: 'done',
133
+ retries: 0,
134
+ })
135
+ expect(record.summary).toMatchObject({ total: 2, done: 1, failed: 1, skipped: 0, timedOut: 0 })
136
+ expect(record.events_count).toBe(1)
137
+ })
138
+
139
+ it('missing convoy -> no error, no file', async () => {
140
+ const logsDir = join(tmpDir, 'logs')
141
+ await expect(exportConvoyToNdjson(store, 'nonexistent', logsDir)).resolves.toBeUndefined()
142
+ expect(existsSync(join(logsDir, 'convoys.ndjson'))).toBe(false)
143
+ })
144
+
145
+ it('creates directory if missing', async () => {
146
+ insertConvoy('c1')
147
+ const logsDir = join(tmpDir, 'deep', 'nested', 'logs')
148
+
149
+ await exportConvoyToNdjson(store, 'c1', logsDir)
150
+
151
+ expect(existsSync(join(logsDir, 'convoys.ndjson'))).toBe(true)
152
+ })
153
+
154
+ it('respects custom logsDir', async () => {
155
+ insertConvoy('c1')
156
+ const customDir = join(tmpDir, 'custom-output')
157
+
158
+ await exportConvoyToNdjson(store, 'c1', customDir)
159
+
160
+ const outFile = join(customDir, 'convoys.ndjson')
161
+ expect(existsSync(outFile)).toBe(true)
162
+ const record = JSON.parse(readFileSync(outFile, 'utf8').trim())
163
+ expect(record.id).toBe('c1')
164
+ })
165
+
166
+ it('never throws on store error — writes warning to stderr', async () => {
167
+ const broken = {
168
+ getConvoy: () => { throw new Error('db exploded') },
169
+ } as unknown as ConvoyStore
170
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
171
+
172
+ await expect(exportConvoyToNdjson(broken, 'c1', join(tmpDir, 'logs'))).resolves.toBeUndefined()
173
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('exportConvoyToNdjson warning'))
174
+
175
+ stderrSpy.mockRestore()
176
+ })
177
+
178
+ it('defaults to .opencastle/logs when logsDir is omitted', async () => {
179
+ insertConvoy('c1')
180
+ const originalCwd = process.cwd()
181
+ process.chdir(tmpDir)
182
+ try {
183
+ await exportConvoyToNdjson(store, 'c1')
184
+ const outFile = join(tmpDir, '.opencastle', 'logs', 'convoys.ndjson')
185
+ expect(existsSync(outFile)).toBe(true)
186
+ } finally {
187
+ process.chdir(originalCwd)
188
+ }
189
+ })
190
+ })
@@ -0,0 +1,52 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { ConvoyStore } from './store.js'
4
+ export async function exportConvoyToNdjson(
5
+ store: ConvoyStore,
6
+ convoyId: string,
7
+ logsDir?: string,
8
+ ): Promise<void> {
9
+ try {
10
+ const convoy = store.getConvoy(convoyId)
11
+ if (!convoy) return
12
+
13
+ const tasks = store.getTasksByConvoy(convoyId)
14
+ const eventsCount = store.getEvents(convoyId).length
15
+
16
+ const summary = {
17
+ total: tasks.length,
18
+ done: tasks.filter((t) => t.status === 'done').length,
19
+ failed: tasks.filter((t) => t.status === 'failed').length,
20
+ skipped: tasks.filter((t) => t.status === 'skipped').length,
21
+ timedOut: tasks.filter((t) => t.status === 'timed-out').length,
22
+ }
23
+
24
+ const record = {
25
+ id: convoy.id,
26
+ name: convoy.name,
27
+ status: convoy.status,
28
+ branch: convoy.branch,
29
+ created_at: convoy.created_at,
30
+ started_at: convoy.started_at,
31
+ finished_at: convoy.finished_at,
32
+ summary,
33
+ tasks: tasks.map((t) => ({
34
+ id: t.id,
35
+ phase: t.phase,
36
+ agent: t.agent,
37
+ adapter: t.adapter,
38
+ status: t.status,
39
+ started_at: t.started_at,
40
+ finished_at: t.finished_at,
41
+ retries: t.retries,
42
+ })),
43
+ events_count: eventsCount,
44
+ }
45
+
46
+ const dir = logsDir ?? resolve(process.cwd(), '.opencastle', 'logs')
47
+ mkdirSync(dir, { recursive: true })
48
+ appendFileSync(resolve(dir, 'convoys.ndjson'), JSON.stringify(record) + '\n', 'utf8')
49
+ } catch (err) {
50
+ process.stderr.write(`[opencastle] exportConvoyToNdjson warning: ${String(err)}\n`)
51
+ }
52
+ }
@@ -63,6 +63,7 @@ function makeTask(
63
63
  phase: 0,
64
64
  prompt: 'Do something',
65
65
  agent: 'developer',
66
+ adapter: null,
66
67
  model: null,
67
68
  timeout_ms: 60_000,
68
69
  status: 'running' as const,
@@ -43,6 +43,7 @@ function makeTask(overrides: Partial<Parameters<ConvoyStore['insertTask']>[0]> =
43
43
  phase: 0,
44
44
  prompt: 'Do something',
45
45
  agent: 'developer',
46
+ adapter: null as string | null,
46
47
  model: null,
47
48
  timeout_ms: 1_800_000,
48
49
  status: 'pending' as const,
@@ -83,11 +84,11 @@ describe('DB creation', () => {
83
84
  expect(row.journal_mode).toBe('wal')
84
85
  })
85
86
 
86
- it('sets schema version to 1', () => {
87
+ it('sets schema version to 2', () => {
87
88
  const db = new DatabaseSync(dbPath)
88
89
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
89
90
  db.close()
90
- expect(row.user_version).toBe(1)
91
+ expect(row.user_version).toBe(2)
91
92
  })
92
93
 
93
94
  it('creates all required tables', () => {
@@ -112,7 +113,86 @@ describe('DB creation', () => {
112
113
  store2.close()
113
114
  // Reassign so afterEach does not double-close
114
115
  store = createConvoyStore(dbPath)
115
- expect(row.user_version).toBe(1)
116
+ expect(row.user_version).toBe(2)
117
+ })
118
+ })
119
+
120
+ // ── schema migration ─────────────────────────────────────────────────────────
121
+
122
+ describe('schema migration', () => {
123
+ it('schema migration v1 to v2 adds adapter column', () => {
124
+ // Create a v1 database manually: task table without adapter column
125
+ const v1DbPath = join(tmpDir, 'v1.db')
126
+ const rawDb = new DatabaseSync(v1DbPath)
127
+ rawDb.exec(`
128
+ CREATE TABLE convoy (
129
+ id TEXT PRIMARY KEY,
130
+ name TEXT NOT NULL,
131
+ spec_hash TEXT NOT NULL,
132
+ status TEXT NOT NULL DEFAULT 'pending',
133
+ branch TEXT,
134
+ created_at TEXT NOT NULL,
135
+ started_at TEXT,
136
+ finished_at TEXT,
137
+ spec_yaml TEXT NOT NULL
138
+ );
139
+ CREATE TABLE task (
140
+ id TEXT PRIMARY KEY,
141
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
142
+ phase INTEGER NOT NULL,
143
+ prompt TEXT NOT NULL,
144
+ agent TEXT NOT NULL DEFAULT 'developer',
145
+ model TEXT,
146
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
147
+ status TEXT NOT NULL DEFAULT 'pending',
148
+ worker_id TEXT,
149
+ worktree TEXT,
150
+ output TEXT,
151
+ exit_code INTEGER,
152
+ started_at TEXT,
153
+ finished_at TEXT,
154
+ retries INTEGER NOT NULL DEFAULT 0,
155
+ max_retries INTEGER NOT NULL DEFAULT 1,
156
+ files TEXT,
157
+ depends_on TEXT
158
+ );
159
+ CREATE TABLE worker (
160
+ id TEXT PRIMARY KEY,
161
+ task_id TEXT REFERENCES task(id),
162
+ adapter TEXT NOT NULL,
163
+ pid INTEGER,
164
+ session_id TEXT,
165
+ status TEXT NOT NULL DEFAULT 'spawned',
166
+ worktree TEXT,
167
+ created_at TEXT NOT NULL,
168
+ finished_at TEXT,
169
+ last_heartbeat TEXT
170
+ );
171
+ CREATE TABLE event (
172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ convoy_id TEXT REFERENCES convoy(id),
174
+ task_id TEXT,
175
+ worker_id TEXT,
176
+ type TEXT NOT NULL,
177
+ data TEXT,
178
+ created_at TEXT NOT NULL
179
+ );
180
+ `)
181
+ rawDb.exec('PRAGMA user_version = 1')
182
+ rawDb.close()
183
+
184
+ // Open with createConvoyStore — should apply the v1→v2 migration
185
+ const v1Store = createConvoyStore(v1DbPath)
186
+ v1Store.close()
187
+
188
+ // Verify adapter column was added to task table
189
+ const verifyDb = new DatabaseSync(v1DbPath)
190
+ const cols = verifyDb.prepare('PRAGMA table_info(task)').all() as Array<{ name: string }>
191
+ const version = verifyDb.prepare('PRAGMA user_version').get() as { user_version: number }
192
+ verifyDb.close()
193
+
194
+ expect(cols.map(c => c.name)).toContain('adapter')
195
+ expect(version.user_version).toBe(2)
116
196
  })
117
197
  })
118
198
 
@@ -182,6 +262,12 @@ describe('task CRUD', () => {
182
262
  expect(store.getTask('does-not-exist', 'convoy-1')).toBeUndefined()
183
263
  })
184
264
 
265
+ it('insertTask stores adapter field', () => {
266
+ store.insertTask(makeTask({ adapter: 'opencode' }))
267
+ const retrieved = store.getTask('task-1', 'convoy-1')!
268
+ expect(retrieved.adapter).toBe('opencode')
269
+ })
270
+
185
271
  it('stores JSON fields as strings', () => {
186
272
  const task = makeTask({
187
273
  id: 'task-json',
@@ -9,7 +9,7 @@ import type {
9
9
  EventRecord,
10
10
  } from './types.js'
11
11
 
12
- const SCHEMA_VERSION = 1
12
+ const SCHEMA_VERSION = 2
13
13
 
14
14
  export interface ConvoyStore {
15
15
  insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
@@ -82,6 +82,7 @@ class ConvoyStoreImpl implements ConvoyStore {
82
82
  phase INTEGER NOT NULL,
83
83
  prompt TEXT NOT NULL,
84
84
  agent TEXT NOT NULL DEFAULT 'developer',
85
+ adapter TEXT,
85
86
  model TEXT,
86
87
  timeout_ms INTEGER NOT NULL DEFAULT 1800000,
87
88
  status TEXT NOT NULL DEFAULT 'pending',
@@ -122,6 +123,10 @@ class ConvoyStoreImpl implements ConvoyStore {
122
123
  `)
123
124
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
124
125
  }
126
+ if (row.user_version === 1) {
127
+ this.db.exec('ALTER TABLE task ADD COLUMN adapter TEXT')
128
+ this.db.exec('PRAGMA user_version = 2')
129
+ }
125
130
  }
126
131
 
127
132
  insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
@@ -174,11 +179,11 @@ class ConvoyStoreImpl implements ConvoyStore {
174
179
  this.db
175
180
  .prepare(
176
181
  `INSERT INTO task
177
- (id, convoy_id, phase, prompt, agent, model, timeout_ms, status,
182
+ (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
178
183
  worker_id, worktree, output, exit_code, started_at, finished_at,
179
184
  retries, max_retries, files, depends_on)
180
185
  VALUES
181
- (:id, :convoy_id, :phase, :prompt, :agent, :model, :timeout_ms, :status,
186
+ (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
182
187
  NULL, NULL, NULL, NULL, NULL, NULL,
183
188
  :retries, :max_retries, :files, :depends_on)`,
184
189
  )
@@ -29,6 +29,7 @@ export interface TaskRecord {
29
29
  phase: number
30
30
  prompt: string
31
31
  agent: string
32
+ adapter: string | null
32
33
  model: string | null
33
34
  timeout_ms: number
34
35
  status: ConvoyTaskStatus