opencastle 0.11.0 → 0.13.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/events.d.ts +10 -0
- package/dist/cli/convoy/events.d.ts.map +1 -0
- package/dist/cli/convoy/events.js +27 -0
- package/dist/cli/convoy/events.js.map +1 -0
- package/dist/cli/convoy/events.test.d.ts +2 -0
- package/dist/cli/convoy/events.test.d.ts.map +1 -0
- package/dist/cli/convoy/events.test.js +94 -0
- package/dist/cli/convoy/events.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +15 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -0
- package/dist/cli/convoy/merge.js +62 -0
- package/dist/cli/convoy/merge.js.map +1 -0
- package/dist/cli/convoy/merge.test.d.ts +2 -0
- package/dist/cli/convoy/merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/merge.test.js +134 -0
- package/dist/cli/convoy/merge.test.js.map +1 -0
- package/dist/cli/convoy/store.d.ts +23 -0
- package/dist/cli/convoy/store.d.ts.map +1 -0
- package/dist/cli/convoy/store.js +210 -0
- package/dist/cli/convoy/store.js.map +1 -0
- package/dist/cli/convoy/store.test.d.ts +2 -0
- package/dist/cli/convoy/store.test.d.ts.map +1 -0
- package/dist/cli/convoy/store.test.js +387 -0
- package/dist/cli/convoy/store.test.js.map +1 -0
- package/dist/cli/convoy/types.d.ts +56 -0
- package/dist/cli/convoy/types.d.ts.map +1 -0
- package/dist/cli/convoy/types.js +2 -0
- package/dist/cli/convoy/types.js.map +1 -0
- package/dist/cli/convoy/worktree.d.ts +13 -0
- package/dist/cli/convoy/worktree.d.ts.map +1 -0
- package/dist/cli/convoy/worktree.js +90 -0
- package/dist/cli/convoy/worktree.js.map +1 -0
- package/dist/cli/convoy/worktree.test.d.ts +2 -0
- package/dist/cli/convoy/worktree.test.d.ts.map +1 -0
- package/dist/cli/convoy/worktree.test.js +146 -0
- package/dist/cli/convoy/worktree.test.js.map +1 -0
- package/dist/cli/run/adapters/claude-code.js +1 -1
- package/dist/cli/run/adapters/claude-code.js.map +1 -1
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +5 -0
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/cursor.js +1 -1
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/executor.test.js +1 -0
- package/dist/cli/run/executor.test.js.map +1 -1
- package/dist/cli/run/loop-executor.d.ts.map +1 -1
- package/dist/cli/run/loop-executor.js +1 -0
- package/dist/cli/run/loop-executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts +4 -0
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +78 -2
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +384 -1
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/types.d.ts +21 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/cli/convoy/events.test.ts +118 -0
- package/src/cli/convoy/events.ts +41 -0
- package/src/cli/convoy/merge.test.ts +184 -0
- package/src/cli/convoy/merge.ts +89 -0
- package/src/cli/convoy/store.test.ts +446 -0
- package/src/cli/convoy/store.ts +308 -0
- package/src/cli/convoy/types.ts +68 -0
- package/src/cli/convoy/worktree.test.ts +177 -0
- package/src/cli/convoy/worktree.ts +116 -0
- package/src/cli/run/adapters/claude-code.ts +1 -1
- package/src/cli/run/adapters/copilot.ts +5 -0
- package/src/cli/run/adapters/cursor.ts +1 -1
- package/src/cli/run/executor.test.ts +1 -0
- package/src/cli/run/loop-executor.ts +1 -0
- package/src/cli/run/schema.test.ts +462 -1
- package/src/cli/run/schema.ts +96 -2
- package/src/cli/types.ts +22 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import type {
|
|
3
|
+
ConvoyRecord,
|
|
4
|
+
ConvoyStatus,
|
|
5
|
+
TaskRecord,
|
|
6
|
+
ConvoyTaskStatus,
|
|
7
|
+
WorkerRecord,
|
|
8
|
+
WorkerStatus,
|
|
9
|
+
EventRecord,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
const SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
export interface ConvoyStore {
|
|
15
|
+
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
|
|
16
|
+
getConvoy(id: string): ConvoyRecord | undefined
|
|
17
|
+
updateConvoyStatus(
|
|
18
|
+
id: string,
|
|
19
|
+
status: ConvoyStatus,
|
|
20
|
+
extra?: { started_at?: string; finished_at?: string },
|
|
21
|
+
): void
|
|
22
|
+
insertTask(
|
|
23
|
+
record: Omit<
|
|
24
|
+
TaskRecord,
|
|
25
|
+
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
|
|
26
|
+
>,
|
|
27
|
+
): void
|
|
28
|
+
getTask(id: string, convoyId: string): TaskRecord | undefined
|
|
29
|
+
getTasksByConvoy(convoyId: string): TaskRecord[]
|
|
30
|
+
updateTaskStatus(
|
|
31
|
+
id: string,
|
|
32
|
+
convoyId: string,
|
|
33
|
+
status: ConvoyTaskStatus,
|
|
34
|
+
extra?: Partial<
|
|
35
|
+
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
|
|
36
|
+
>,
|
|
37
|
+
): void
|
|
38
|
+
getReadyTasks(convoyId: string): TaskRecord[]
|
|
39
|
+
insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void
|
|
40
|
+
getWorker(id: string): WorkerRecord | undefined
|
|
41
|
+
updateWorkerStatus(
|
|
42
|
+
id: string,
|
|
43
|
+
status: WorkerStatus,
|
|
44
|
+
extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
|
|
45
|
+
): void
|
|
46
|
+
insertEvent(record: Omit<EventRecord, 'id'>): void
|
|
47
|
+
getEvents(convoyId: string): EventRecord[]
|
|
48
|
+
withTransaction<T>(fn: () => T): T
|
|
49
|
+
close(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class ConvoyStoreImpl implements ConvoyStore {
|
|
53
|
+
private db: DatabaseSync
|
|
54
|
+
|
|
55
|
+
constructor(dbPath: string) {
|
|
56
|
+
this.db = new DatabaseSync(dbPath)
|
|
57
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
58
|
+
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
59
|
+
this.initSchema()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private initSchema(): void {
|
|
63
|
+
const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number }
|
|
64
|
+
if (row.user_version === 0) {
|
|
65
|
+
this.db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS convoy (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
name TEXT NOT NULL,
|
|
69
|
+
spec_hash TEXT NOT NULL,
|
|
70
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
71
|
+
branch TEXT,
|
|
72
|
+
created_at TEXT NOT NULL,
|
|
73
|
+
started_at TEXT,
|
|
74
|
+
finished_at TEXT,
|
|
75
|
+
spec_yaml TEXT NOT NULL
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS task (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
81
|
+
phase INTEGER NOT NULL,
|
|
82
|
+
prompt TEXT NOT NULL,
|
|
83
|
+
agent TEXT NOT NULL DEFAULT 'developer',
|
|
84
|
+
model TEXT,
|
|
85
|
+
timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
86
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
87
|
+
worker_id TEXT,
|
|
88
|
+
worktree TEXT,
|
|
89
|
+
output TEXT,
|
|
90
|
+
exit_code INTEGER,
|
|
91
|
+
started_at TEXT,
|
|
92
|
+
finished_at TEXT,
|
|
93
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
95
|
+
files TEXT,
|
|
96
|
+
depends_on TEXT
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS worker (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
task_id TEXT REFERENCES task(id),
|
|
102
|
+
adapter TEXT NOT NULL,
|
|
103
|
+
pid INTEGER,
|
|
104
|
+
session_id TEXT,
|
|
105
|
+
status TEXT NOT NULL DEFAULT 'spawned',
|
|
106
|
+
worktree TEXT,
|
|
107
|
+
created_at TEXT NOT NULL,
|
|
108
|
+
finished_at TEXT,
|
|
109
|
+
last_heartbeat TEXT
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS event (
|
|
113
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
114
|
+
convoy_id TEXT REFERENCES convoy(id),
|
|
115
|
+
task_id TEXT,
|
|
116
|
+
worker_id TEXT,
|
|
117
|
+
type TEXT NOT NULL,
|
|
118
|
+
data TEXT,
|
|
119
|
+
created_at TEXT NOT NULL
|
|
120
|
+
);
|
|
121
|
+
`)
|
|
122
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
|
|
127
|
+
this.db
|
|
128
|
+
.prepare(
|
|
129
|
+
`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml)
|
|
130
|
+
VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml)`,
|
|
131
|
+
)
|
|
132
|
+
.run(record)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getConvoy(id: string): ConvoyRecord | undefined {
|
|
136
|
+
return this.db
|
|
137
|
+
.prepare('SELECT * FROM convoy WHERE id = :id')
|
|
138
|
+
.get({ id }) as ConvoyRecord | undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
updateConvoyStatus(
|
|
142
|
+
id: string,
|
|
143
|
+
status: ConvoyStatus,
|
|
144
|
+
extra?: { started_at?: string; finished_at?: string },
|
|
145
|
+
): void {
|
|
146
|
+
const sets = ['status = :status']
|
|
147
|
+
const params: Record<string, string | null> = { id, status }
|
|
148
|
+
|
|
149
|
+
if (extra?.started_at !== undefined) {
|
|
150
|
+
sets.push('started_at = :started_at')
|
|
151
|
+
params.started_at = extra.started_at
|
|
152
|
+
}
|
|
153
|
+
if (extra?.finished_at !== undefined) {
|
|
154
|
+
sets.push('finished_at = :finished_at')
|
|
155
|
+
params.finished_at = extra.finished_at
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
insertTask(
|
|
162
|
+
record: Omit<
|
|
163
|
+
TaskRecord,
|
|
164
|
+
'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
|
|
165
|
+
>,
|
|
166
|
+
): void {
|
|
167
|
+
this.db
|
|
168
|
+
.prepare(
|
|
169
|
+
`INSERT INTO task
|
|
170
|
+
(id, convoy_id, phase, prompt, agent, model, timeout_ms, status,
|
|
171
|
+
worker_id, worktree, output, exit_code, started_at, finished_at,
|
|
172
|
+
retries, max_retries, files, depends_on)
|
|
173
|
+
VALUES
|
|
174
|
+
(:id, :convoy_id, :phase, :prompt, :agent, :model, :timeout_ms, :status,
|
|
175
|
+
NULL, NULL, NULL, NULL, NULL, NULL,
|
|
176
|
+
:retries, :max_retries, :files, :depends_on)`,
|
|
177
|
+
)
|
|
178
|
+
.run(record)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getTask(id: string, convoyId: string): TaskRecord | undefined {
|
|
182
|
+
return this.db
|
|
183
|
+
.prepare('SELECT * FROM task WHERE id = :id AND convoy_id = :convoy_id')
|
|
184
|
+
.get({ id, convoy_id: convoyId }) as TaskRecord | undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getTasksByConvoy(convoyId: string): TaskRecord[] {
|
|
188
|
+
return this.db
|
|
189
|
+
.prepare('SELECT * FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
|
|
190
|
+
.all({ convoy_id: convoyId }) as unknown as TaskRecord[]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
updateTaskStatus(
|
|
194
|
+
id: string,
|
|
195
|
+
convoyId: string,
|
|
196
|
+
status: ConvoyTaskStatus,
|
|
197
|
+
extra?: Partial<
|
|
198
|
+
Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
|
|
199
|
+
>,
|
|
200
|
+
): void {
|
|
201
|
+
const sets = ['status = :status']
|
|
202
|
+
const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
|
|
203
|
+
const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries'] as const
|
|
204
|
+
|
|
205
|
+
if (extra) {
|
|
206
|
+
for (const field of extraFields) {
|
|
207
|
+
if (field in extra && extra[field] !== undefined) {
|
|
208
|
+
sets.push(`${field} = :${field}`)
|
|
209
|
+
params[field] = extra[field] as string | number | null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.db
|
|
215
|
+
.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`)
|
|
216
|
+
.run(params)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
getReadyTasks(convoyId: string): TaskRecord[] {
|
|
220
|
+
const allTasks = this.getTasksByConvoy(convoyId)
|
|
221
|
+
const doneTaskIds = new Set(allTasks.filter(t => t.status === 'done').map(t => t.id))
|
|
222
|
+
|
|
223
|
+
return allTasks.filter(task => {
|
|
224
|
+
if (task.status !== 'pending') return false
|
|
225
|
+
if (!task.depends_on) return true
|
|
226
|
+
const deps = JSON.parse(task.depends_on) as string[]
|
|
227
|
+
return deps.length === 0 || deps.every(depId => doneTaskIds.has(depId))
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void {
|
|
232
|
+
this.db
|
|
233
|
+
.prepare(
|
|
234
|
+
`INSERT INTO worker
|
|
235
|
+
(id, task_id, adapter, pid, session_id, status, worktree, created_at,
|
|
236
|
+
finished_at, last_heartbeat)
|
|
237
|
+
VALUES
|
|
238
|
+
(:id, :task_id, :adapter, :pid, :session_id, :status, :worktree, :created_at,
|
|
239
|
+
NULL, NULL)`,
|
|
240
|
+
)
|
|
241
|
+
.run(record)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getWorker(id: string): WorkerRecord | undefined {
|
|
245
|
+
return this.db
|
|
246
|
+
.prepare('SELECT * FROM worker WHERE id = :id')
|
|
247
|
+
.get({ id }) as WorkerRecord | undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
updateWorkerStatus(
|
|
251
|
+
id: string,
|
|
252
|
+
status: WorkerStatus,
|
|
253
|
+
extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
|
|
254
|
+
): void {
|
|
255
|
+
const sets = ['status = :status']
|
|
256
|
+
const params: Record<string, string | number | null> = { id, status }
|
|
257
|
+
|
|
258
|
+
if (extra?.finished_at !== undefined) {
|
|
259
|
+
sets.push('finished_at = :finished_at')
|
|
260
|
+
params.finished_at = extra.finished_at
|
|
261
|
+
}
|
|
262
|
+
if (extra?.last_heartbeat !== undefined) {
|
|
263
|
+
sets.push('last_heartbeat = :last_heartbeat')
|
|
264
|
+
params.last_heartbeat = extra.last_heartbeat
|
|
265
|
+
}
|
|
266
|
+
if (extra?.pid !== undefined) {
|
|
267
|
+
sets.push('pid = :pid')
|
|
268
|
+
params.pid = extra.pid
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.db.prepare(`UPDATE worker SET ${sets.join(', ')} WHERE id = :id`).run(params)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
insertEvent(record: Omit<EventRecord, 'id'>): void {
|
|
275
|
+
this.db
|
|
276
|
+
.prepare(
|
|
277
|
+
`INSERT INTO event (convoy_id, task_id, worker_id, type, data, created_at)
|
|
278
|
+
VALUES (:convoy_id, :task_id, :worker_id, :type, :data, :created_at)`,
|
|
279
|
+
)
|
|
280
|
+
.run(record)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
getEvents(convoyId: string): EventRecord[] {
|
|
284
|
+
return this.db
|
|
285
|
+
.prepare('SELECT * FROM event WHERE convoy_id = :convoy_id ORDER BY id')
|
|
286
|
+
.all({ convoy_id: convoyId }) as unknown as EventRecord[]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
withTransaction<T>(fn: () => T): T {
|
|
290
|
+
this.db.exec('BEGIN')
|
|
291
|
+
try {
|
|
292
|
+
const result = fn()
|
|
293
|
+
this.db.exec('COMMIT')
|
|
294
|
+
return result
|
|
295
|
+
} catch (err) {
|
|
296
|
+
this.db.exec('ROLLBACK')
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
close(): void {
|
|
302
|
+
this.db.close()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createConvoyStore(dbPath: string): ConvoyStore {
|
|
307
|
+
return new ConvoyStoreImpl(dbPath)
|
|
308
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type ConvoyStatus = 'pending' | 'running' | 'done' | 'failed' | 'gate-failed'
|
|
2
|
+
|
|
3
|
+
export type ConvoyTaskStatus =
|
|
4
|
+
| 'pending'
|
|
5
|
+
| 'assigned'
|
|
6
|
+
| 'running'
|
|
7
|
+
| 'done'
|
|
8
|
+
| 'failed'
|
|
9
|
+
| 'timed-out'
|
|
10
|
+
| 'skipped'
|
|
11
|
+
|
|
12
|
+
export type WorkerStatus = 'spawned' | 'running' | 'done' | 'failed' | 'killed'
|
|
13
|
+
|
|
14
|
+
export interface ConvoyRecord {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
spec_hash: string
|
|
18
|
+
status: ConvoyStatus
|
|
19
|
+
branch: string | null
|
|
20
|
+
created_at: string
|
|
21
|
+
started_at: string | null
|
|
22
|
+
finished_at: string | null
|
|
23
|
+
spec_yaml: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TaskRecord {
|
|
27
|
+
id: string
|
|
28
|
+
convoy_id: string
|
|
29
|
+
phase: number
|
|
30
|
+
prompt: string
|
|
31
|
+
agent: string
|
|
32
|
+
model: string | null
|
|
33
|
+
timeout_ms: number
|
|
34
|
+
status: ConvoyTaskStatus
|
|
35
|
+
worker_id: string | null
|
|
36
|
+
worktree: string | null
|
|
37
|
+
output: string | null
|
|
38
|
+
exit_code: number | null
|
|
39
|
+
started_at: string | null
|
|
40
|
+
finished_at: string | null
|
|
41
|
+
retries: number
|
|
42
|
+
max_retries: number
|
|
43
|
+
files: string | null
|
|
44
|
+
depends_on: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkerRecord {
|
|
48
|
+
id: string
|
|
49
|
+
task_id: string | null
|
|
50
|
+
adapter: string
|
|
51
|
+
pid: number | null
|
|
52
|
+
session_id: string | null
|
|
53
|
+
status: WorkerStatus
|
|
54
|
+
worktree: string | null
|
|
55
|
+
created_at: string
|
|
56
|
+
finished_at: string | null
|
|
57
|
+
last_heartbeat: string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EventRecord {
|
|
61
|
+
id?: number
|
|
62
|
+
convoy_id: string | null
|
|
63
|
+
task_id: string | null
|
|
64
|
+
worker_id: string | null
|
|
65
|
+
type: string
|
|
66
|
+
data: string | null
|
|
67
|
+
created_at: string
|
|
68
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { execFile as execFileCb } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
7
|
+
import { createWorktreeManager } from './worktree.js'
|
|
8
|
+
import type { WorktreeManager } from './worktree.js'
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCb)
|
|
11
|
+
|
|
12
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
let tmpDir: string
|
|
15
|
+
let manager: WorktreeManager
|
|
16
|
+
|
|
17
|
+
async function initGitRepo(dir: string): Promise<void> {
|
|
18
|
+
await execFile('git', ['init'], { cwd: dir })
|
|
19
|
+
await execFile('git', ['config', 'user.email', 'test@test.com'], { cwd: dir })
|
|
20
|
+
await execFile('git', ['config', 'user.name', 'Test User'], { cwd: dir })
|
|
21
|
+
await execFile('git', ['commit', '--allow-empty', '-m', 'Initial commit'], { cwd: dir })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'convoy-worktree-test-')))
|
|
26
|
+
await initGitRepo(tmpDir)
|
|
27
|
+
manager = createWorktreeManager(tmpDir)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ── create ────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe('create', () => {
|
|
37
|
+
it('creates a worktree and returns its absolute path', async () => {
|
|
38
|
+
const worktreePath = await manager.create('worker1', 'HEAD')
|
|
39
|
+
expect(worktreePath).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('creates the .opencastle/worktrees directory when it does not exist', async () => {
|
|
43
|
+
const { existsSync } = await import('node:fs')
|
|
44
|
+
const worktreePath = await manager.create('worker1', 'HEAD')
|
|
45
|
+
expect(existsSync(worktreePath)).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('creates the new branch in the worktree', async () => {
|
|
49
|
+
await manager.create('worker1', 'HEAD')
|
|
50
|
+
const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
|
|
51
|
+
// git prefixes branches checked out in a worktree with '+ '
|
|
52
|
+
expect(stdout.trim().replace(/^[*+]\s+/, '')).toBe('convoy-worker1')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('throws when featureBranch does not exist', async () => {
|
|
56
|
+
await expect(manager.create('worker1', 'nonexistent-branch')).rejects.toThrow()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('throws when workerId is already in use', async () => {
|
|
60
|
+
await manager.create('worker1', 'HEAD')
|
|
61
|
+
await expect(manager.create('worker1', 'HEAD')).rejects.toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('throws for workerId with path traversal characters', async () => {
|
|
65
|
+
await expect(manager.create('../escape', 'HEAD')).rejects.toThrow(/Invalid workerId/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws for workerId with slashes', async () => {
|
|
69
|
+
await expect(manager.create('a/b', 'HEAD')).rejects.toThrow(/Invalid workerId/)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ── remove ────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe('remove', () => {
|
|
76
|
+
it('removes the worktree so it no longer appears in list()', async () => {
|
|
77
|
+
const path = await manager.create('worker1', 'HEAD')
|
|
78
|
+
await manager.remove(path)
|
|
79
|
+
const worktrees = await manager.list()
|
|
80
|
+
expect(worktrees).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('deletes the convoy branch after removing the worktree', async () => {
|
|
84
|
+
const path = await manager.create('worker1', 'HEAD')
|
|
85
|
+
await manager.remove(path)
|
|
86
|
+
const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
|
|
87
|
+
expect(stdout.trim()).toBe('')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('is idempotent — does not throw for a non-existent worktree path', async () => {
|
|
91
|
+
const nonExistent = join(tmpDir, '.opencastle', 'worktrees', 'ghost')
|
|
92
|
+
await expect(manager.remove(nonExistent)).resolves.toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('re-throws git errors that are not "not a working tree"', async () => {
|
|
96
|
+
const path = await manager.create('worker1', 'HEAD')
|
|
97
|
+
// Lock the worktree so that single --force removal fails
|
|
98
|
+
await execFile('git', ['worktree', 'lock', path], { cwd: tmpDir })
|
|
99
|
+
await expect(manager.remove(path)).rejects.toThrow()
|
|
100
|
+
// Cleanup: unlock and remove manually
|
|
101
|
+
await execFile('git', ['worktree', 'unlock', path], { cwd: tmpDir })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('throws when path is outside the managed worktrees directory', async () => {
|
|
105
|
+
await expect(manager.remove('/some/arbitrary/path')).rejects.toThrow(
|
|
106
|
+
/outside the managed worktrees directory/,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ── list ──────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('list', () => {
|
|
114
|
+
it('returns an empty array when no convoy worktrees exist', async () => {
|
|
115
|
+
const worktrees = await manager.list()
|
|
116
|
+
expect(worktrees).toHaveLength(0)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('does not include the main worktree', async () => {
|
|
120
|
+
const worktrees = await manager.list()
|
|
121
|
+
for (const wt of worktrees) {
|
|
122
|
+
expect(wt.path).toContain('.opencastle/worktrees')
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns the correct WorktreeInfo for a created worktree', async () => {
|
|
127
|
+
await manager.create('worker1', 'HEAD')
|
|
128
|
+
const worktrees = await manager.list()
|
|
129
|
+
expect(worktrees).toHaveLength(1)
|
|
130
|
+
expect(worktrees[0].path).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
|
|
131
|
+
expect(worktrees[0].branch).toBe('refs/heads/convoy-worker1')
|
|
132
|
+
expect(worktrees[0].head).toMatch(/^[0-9a-f]{40}$/)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns multiple worktrees when several have been created', async () => {
|
|
136
|
+
await manager.create('worker1', 'HEAD')
|
|
137
|
+
await manager.create('worker2', 'HEAD')
|
|
138
|
+
const worktrees = await manager.list()
|
|
139
|
+
expect(worktrees).toHaveLength(2)
|
|
140
|
+
const paths = worktrees.map(w => w.path)
|
|
141
|
+
expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
|
|
142
|
+
expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker2'))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('handles detached HEAD worktrees by returning empty branch string', async () => {
|
|
146
|
+
// git worktree list --porcelain outputs 'detached' (not 'branch refs/...') for
|
|
147
|
+
// detached-HEAD worktrees — this exercises the else-if fallthrough in parseWorktreeList
|
|
148
|
+
const { existsSync, mkdirSync } = await import('node:fs')
|
|
149
|
+
const detachedPath = join(tmpDir, '.opencastle', 'worktrees', 'detached-test')
|
|
150
|
+
if (!existsSync(join(tmpDir, '.opencastle', 'worktrees'))) {
|
|
151
|
+
mkdirSync(join(tmpDir, '.opencastle', 'worktrees'), { recursive: true })
|
|
152
|
+
}
|
|
153
|
+
const { stdout: sha } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: tmpDir })
|
|
154
|
+
await execFile('git', ['worktree', 'add', '--detach', detachedPath, sha.trim()], { cwd: tmpDir })
|
|
155
|
+
const worktrees = await manager.list()
|
|
156
|
+
const detached = worktrees.find(w => w.path === detachedPath)
|
|
157
|
+
expect(detached).toBeDefined()
|
|
158
|
+
expect(detached!.branch).toBe('')
|
|
159
|
+
expect(detached!.head).toMatch(/^[0-9a-f]{40}$/)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ── removeAll ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe('removeAll', () => {
|
|
166
|
+
it('removes all convoy worktrees', async () => {
|
|
167
|
+
await manager.create('worker1', 'HEAD')
|
|
168
|
+
await manager.create('worker2', 'HEAD')
|
|
169
|
+
await manager.removeAll()
|
|
170
|
+
const worktrees = await manager.list()
|
|
171
|
+
expect(worktrees).toHaveLength(0)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('is a no-op when no convoy worktrees exist', async () => {
|
|
175
|
+
await expect(manager.removeAll()).resolves.toBeUndefined()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { realpathSync } from 'node:fs'
|
|
4
|
+
import { join, basename, resolve, sep } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCb)
|
|
8
|
+
|
|
9
|
+
export interface WorktreeInfo {
|
|
10
|
+
path: string
|
|
11
|
+
branch: string
|
|
12
|
+
head: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorktreeManager {
|
|
16
|
+
create(workerId: string, featureBranch: string): Promise<string>
|
|
17
|
+
remove(worktreePath: string): Promise<void>
|
|
18
|
+
list(): Promise<WorktreeInfo[]>
|
|
19
|
+
removeAll(): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createWorktreeManager(basePath: string): WorktreeManager {
|
|
23
|
+
const resolvedBase = realpathSync(resolve(basePath))
|
|
24
|
+
const worktreesDir = join(resolvedBase, '.opencastle', 'worktrees')
|
|
25
|
+
|
|
26
|
+
async function create(workerId: string, featureBranch: string): Promise<string> {
|
|
27
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(workerId)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid workerId "${workerId}": must only contain alphanumeric characters, hyphens, and underscores`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const worktreePath = join(worktreesDir, workerId)
|
|
33
|
+
await mkdir(worktreesDir, { recursive: true })
|
|
34
|
+
await execFile(
|
|
35
|
+
'git',
|
|
36
|
+
['worktree', 'add', worktreePath, '-b', `convoy-${workerId}`, featureBranch],
|
|
37
|
+
{ cwd: resolvedBase },
|
|
38
|
+
)
|
|
39
|
+
return worktreePath
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function remove(worktreePath: string): Promise<void> {
|
|
43
|
+
let resolved: string
|
|
44
|
+
try {
|
|
45
|
+
resolved = realpathSync(worktreePath)
|
|
46
|
+
} catch {
|
|
47
|
+
resolved = resolve(worktreePath)
|
|
48
|
+
}
|
|
49
|
+
if (!resolved.startsWith(worktreesDir + sep)) {
|
|
50
|
+
throw new Error(`Path "${worktreePath}" is outside the managed worktrees directory`)
|
|
51
|
+
}
|
|
52
|
+
const workerId = basename(resolved)
|
|
53
|
+
try {
|
|
54
|
+
await execFile('git', ['worktree', 'remove', resolved, '--force'], {
|
|
55
|
+
cwd: resolvedBase,
|
|
56
|
+
})
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const stderr = (err as { stderr?: string }).stderr ?? ''
|
|
59
|
+
if (stderr.includes('is not a working tree')) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await execFile('git', ['branch', '-D', `convoy-${workerId}`], { cwd: resolvedBase })
|
|
66
|
+
} catch {
|
|
67
|
+
// Branch may already be deleted — ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async function list(): Promise<WorktreeInfo[]> {
|
|
73
|
+
const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], {
|
|
74
|
+
cwd: resolvedBase,
|
|
75
|
+
})
|
|
76
|
+
return parseWorktreeList(stdout, worktreesDir)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function removeAll(): Promise<void> {
|
|
80
|
+
const worktrees = await list()
|
|
81
|
+
for (const wt of worktrees) {
|
|
82
|
+
await remove(wt.path)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { create, remove, list, removeAll }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseWorktreeList(output: string, worktreesDir: string): WorktreeInfo[] {
|
|
90
|
+
const results: WorktreeInfo[] = []
|
|
91
|
+
const blocks = output.trim().split(/\n\n+/).filter(Boolean)
|
|
92
|
+
|
|
93
|
+
for (const block of blocks) {
|
|
94
|
+
const lines = block.split('\n')
|
|
95
|
+
let path = ''
|
|
96
|
+
let head = ''
|
|
97
|
+
let branch = ''
|
|
98
|
+
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (line.startsWith('worktree ')) {
|
|
101
|
+
path = line.slice('worktree '.length)
|
|
102
|
+
} else if (line.startsWith('HEAD ')) {
|
|
103
|
+
head = line.slice('HEAD '.length)
|
|
104
|
+
} else if (line.startsWith('branch ')) {
|
|
105
|
+
branch = line.slice('branch '.length)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Only include worktrees that live under .opencastle/worktrees/
|
|
110
|
+
if (path.startsWith(worktreesDir + sep)) {
|
|
111
|
+
results.push({ path, branch, head })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return results
|
|
116
|
+
}
|
|
@@ -38,7 +38,7 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
|
|
|
38
38
|
const proc = spawn('claude', args, {
|
|
39
39
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
40
|
env: { ...process.env },
|
|
41
|
-
cwd: process.cwd(),
|
|
41
|
+
cwd: options?.cwd ?? process.cwd(),
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
let stdout = ''
|
|
@@ -61,6 +61,11 @@ async function getClient(): Promise<CopilotClientType> {
|
|
|
61
61
|
* - Streaming enabled in verbose mode for live output
|
|
62
62
|
*/
|
|
63
63
|
export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
64
|
+
// NOTE: The Copilot SDK CopilotClient is a shared singleton. Per-task cwd
|
|
65
|
+
// isolation requires SDK support for per-session workingDirectory, which is
|
|
66
|
+
// not yet available. When running in convoy mode with worktrees, prefer
|
|
67
|
+
// subprocess-based adapters (claude-code, cursor) that support options.cwd
|
|
68
|
+
// natively. Copilot SDK per-session cwd support is tracked for Phase 3.
|
|
64
69
|
let prompt = `You are a ${task.agent}. ${task.prompt}`
|
|
65
70
|
|
|
66
71
|
if (task.files && task.files.length > 0) {
|