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.
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +47 -11
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +104 -1
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/export.d.ts +3 -0
- package/dist/cli/convoy/export.d.ts.map +1 -0
- package/dist/cli/convoy/export.js +46 -0
- package/dist/cli/convoy/export.js.map +1 -0
- package/dist/cli/convoy/export.test.d.ts +2 -0
- package/dist/cli/convoy/export.test.d.ts.map +1 -0
- package/dist/cli/convoy/export.test.js +157 -0
- package/dist/cli/convoy/export.test.js.map +1 -0
- package/dist/cli/convoy/health.test.js +1 -0
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +8 -3
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +83 -3
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +1 -0
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/dashboard.d.ts +14 -0
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +73 -36
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/run/adapters/index.d.ts.map +1 -1
- package/dist/cli/run/adapters/index.js +2 -1
- package/dist/cli/run/adapters/index.js.map +1 -1
- package/dist/cli/run/adapters/opencode.d.ts +16 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.js +75 -0
- package/dist/cli/run/adapters/opencode.js.map +1 -0
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +11 -0
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +44 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +18 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +3 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/engine.test.ts +126 -1
- package/src/cli/convoy/engine.ts +39 -9
- package/src/cli/convoy/export.test.ts +190 -0
- package/src/cli/convoy/export.ts +52 -0
- package/src/cli/convoy/health.test.ts +1 -0
- package/src/cli/convoy/store.test.ts +89 -3
- package/src/cli/convoy/store.ts +8 -3
- package/src/cli/convoy/types.ts +1 -0
- package/src/cli/dashboard.ts +94 -42
- package/src/cli/run/adapters/index.ts +2 -1
- package/src/cli/run/adapters/opencode.ts +88 -0
- package/src/cli/run/schema.test.ts +50 -0
- package/src/cli/run/schema.ts +13 -0
- package/src/cli/run.ts +19 -1
- package/src/cli/types.ts +3 -0
- package/src/dashboard/dist/_astro/{index.Bnq19_1M.css → index.DyyaCW8L.css} +1 -1
- package/src/dashboard/dist/index.html +145 -6
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/src/pages/index.astro +160 -4
- package/src/dashboard/src/styles/dashboard.css +60 -0
package/src/cli/convoy/engine.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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',
|
package/src/cli/convoy/store.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
EventRecord,
|
|
10
10
|
} from './types.js'
|
|
11
11
|
|
|
12
|
-
const SCHEMA_VERSION =
|
|
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
|
)
|