opencastle 0.33.6 → 0.33.8
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 +5 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +85 -16
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +10 -12
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/events.d.ts +10 -5
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +9 -7
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/pipeline.d.ts +3 -0
- package/dist/cli/convoy/pipeline.d.ts.map +1 -1
- package/dist/cli/convoy/pipeline.js +88 -18
- package/dist/cli/convoy/pipeline.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +25 -0
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +3 -0
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +1 -123
- package/dist/cli/run.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/engine.test.ts +10 -12
- package/src/cli/convoy/engine.ts +84 -16
- package/src/cli/convoy/events.test.ts +10 -8
- package/src/cli/convoy/events.ts +7 -4
- package/src/cli/convoy/pipeline.ts +81 -19
- package/src/cli/convoy/types.ts +6 -0
- package/src/cli/run.ts +0 -118
- package/src/dashboard/dist/data/convoys/demo-api-v2.json +9 -9
- package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +10 -10
- package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +6 -6
- package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +3 -3
- package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +1 -1
- package/src/dashboard/dist/data/convoys/demo-docs-update.json +3 -3
- package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/convoys/demo-api-v2.json +9 -9
- package/src/dashboard/public/data/convoys/demo-auth-revamp.json +10 -10
- package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +6 -6
- package/src/dashboard/public/data/convoys/demo-data-pipeline.json +3 -3
- package/src/dashboard/public/data/convoys/demo-deploy-ci.json +1 -1
- package/src/dashboard/public/data/convoys/demo-docs-update.json +3 -3
- package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
package/src/cli/convoy/engine.ts
CHANGED
|
@@ -82,6 +82,11 @@ export interface ConvoyEngineOptions {
|
|
|
82
82
|
_mergeQueue?: MergeQueue
|
|
83
83
|
/** Override for test injection. Pass `ensureBranch` for real behavior, or a mock. */
|
|
84
84
|
_ensureBranch?: (branchName: string, basePath: string) => Promise<void>
|
|
85
|
+
/** Pass `null` to skip convoy-level worktree creation (test mode).
|
|
86
|
+
* Pass a string path to inject a specific worktree directory.
|
|
87
|
+
* Omit (undefined) for real worktree creation.
|
|
88
|
+
* Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
|
|
89
|
+
_convoyWorktreeDir?: string | null
|
|
85
90
|
/** Injectable for test injection of the review pipeline. */
|
|
86
91
|
_reviewRunner?: (task: TaskRecord, level: ReviewLevel, reviewerModel: string) => Promise<ReviewResult>
|
|
87
92
|
}
|
|
@@ -2981,11 +2986,33 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
2981
2986
|
const specHash = createHash('sha256').update(specYaml).digest('hex')
|
|
2982
2987
|
const baseBranch = spec.branch ?? (await getCurrentBranch())
|
|
2983
2988
|
|
|
2984
|
-
//
|
|
2985
|
-
//
|
|
2989
|
+
// Create convoy-level worktree for branch isolation.
|
|
2990
|
+
// Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
|
|
2991
|
+
let effectiveBasePath = basePath
|
|
2992
|
+
let convoyWorktreeDir: string | undefined
|
|
2986
2993
|
if (spec.branch !== undefined) {
|
|
2987
|
-
const
|
|
2988
|
-
|
|
2994
|
+
const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
|
|
2995
|
+
if (!skipWorktree) {
|
|
2996
|
+
if (typeof options._convoyWorktreeDir === 'string') {
|
|
2997
|
+
effectiveBasePath = options._convoyWorktreeDir
|
|
2998
|
+
convoyWorktreeDir = options._convoyWorktreeDir
|
|
2999
|
+
} else {
|
|
3000
|
+
const worktreeId = `convoy-root-${Date.now()}`
|
|
3001
|
+
convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
|
|
3002
|
+
mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
|
|
3003
|
+
let branchExists = false
|
|
3004
|
+
try {
|
|
3005
|
+
await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
|
|
3006
|
+
branchExists = true
|
|
3007
|
+
} catch { /* branch doesn't exist */ }
|
|
3008
|
+
if (branchExists) {
|
|
3009
|
+
await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
|
|
3010
|
+
} else {
|
|
3011
|
+
await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
|
|
3012
|
+
}
|
|
3013
|
+
effectiveBasePath = convoyWorktreeDir
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
2989
3016
|
}
|
|
2990
3017
|
|
|
2991
3018
|
mkdirSync(dirname(dbPath), { recursive: true })
|
|
@@ -3022,10 +3049,10 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3022
3049
|
const store = createConvoyStore(dbPath)
|
|
3023
3050
|
const ndjsonPath = options.logsDir
|
|
3024
3051
|
? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
|
|
3025
|
-
: ndjsonPathForConvoy(convoyId,
|
|
3052
|
+
: ndjsonPathForConvoy(convoyId, effectiveBasePath)
|
|
3026
3053
|
const events = createEventEmitter(store, { ndjsonPath })
|
|
3027
|
-
const wtManager = options._worktreeManager ?? createWorktreeManager(
|
|
3028
|
-
const mergeQueue = options._mergeQueue ?? createMergeQueue(
|
|
3054
|
+
const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
|
|
3055
|
+
const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
|
|
3029
3056
|
|
|
3030
3057
|
let result: ConvoyResult
|
|
3031
3058
|
try {
|
|
@@ -3088,7 +3115,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3088
3115
|
|
|
3089
3116
|
result = await runConvoy(
|
|
3090
3117
|
convoyId, spec, adapter, store, events,
|
|
3091
|
-
wtManager, mergeQueue,
|
|
3118
|
+
wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
|
|
3092
3119
|
options._reviewRunner,
|
|
3093
3120
|
)
|
|
3094
3121
|
} finally {
|
|
@@ -3096,6 +3123,11 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3096
3123
|
store.close()
|
|
3097
3124
|
lock.release()
|
|
3098
3125
|
lockDb.close()
|
|
3126
|
+
if (convoyWorktreeDir) {
|
|
3127
|
+
try {
|
|
3128
|
+
await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
|
|
3129
|
+
} catch { /* ignore cleanup errors */ }
|
|
3130
|
+
}
|
|
3099
3131
|
}
|
|
3100
3132
|
return result
|
|
3101
3133
|
}
|
|
@@ -3135,12 +3167,9 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3135
3167
|
lock.startHeartbeat()
|
|
3136
3168
|
|
|
3137
3169
|
const store = createConvoyStore(dbPath)
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
const events = createEventEmitter(store, { ndjsonPath })
|
|
3142
|
-
const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
|
|
3143
|
-
const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
|
|
3170
|
+
let effectiveBasePath = basePath
|
|
3171
|
+
let convoyWorktreeDir: string | undefined
|
|
3172
|
+
let events: ConvoyEventEmitter | undefined
|
|
3144
3173
|
|
|
3145
3174
|
let result: ConvoyResult
|
|
3146
3175
|
try {
|
|
@@ -3150,6 +3179,40 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3150
3179
|
}
|
|
3151
3180
|
|
|
3152
3181
|
const baseBranch = convoy.branch ?? spec.branch ?? (await getCurrentBranch())
|
|
3182
|
+
const convoyBranch = convoy.branch ?? spec.branch
|
|
3183
|
+
|
|
3184
|
+
// Create convoy-level worktree for branch isolation.
|
|
3185
|
+
if (convoyBranch !== undefined) {
|
|
3186
|
+
const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
|
|
3187
|
+
if (!skipWorktree) {
|
|
3188
|
+
if (typeof options._convoyWorktreeDir === 'string') {
|
|
3189
|
+
effectiveBasePath = options._convoyWorktreeDir
|
|
3190
|
+
convoyWorktreeDir = options._convoyWorktreeDir
|
|
3191
|
+
} else {
|
|
3192
|
+
const worktreeId = `convoy-root-${Date.now()}`
|
|
3193
|
+
convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
|
|
3194
|
+
mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
|
|
3195
|
+
let branchExists = false
|
|
3196
|
+
try {
|
|
3197
|
+
await execFile('git', ['rev-parse', '--verify', convoyBranch], { cwd: basePath })
|
|
3198
|
+
branchExists = true
|
|
3199
|
+
} catch { /* branch doesn't exist */ }
|
|
3200
|
+
if (branchExists) {
|
|
3201
|
+
await execFile('git', ['worktree', 'add', convoyWorktreeDir, convoyBranch], { cwd: basePath })
|
|
3202
|
+
} else {
|
|
3203
|
+
await execFile('git', ['worktree', 'add', '-b', convoyBranch, convoyWorktreeDir], { cwd: basePath })
|
|
3204
|
+
}
|
|
3205
|
+
effectiveBasePath = convoyWorktreeDir
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const ndjsonPath = options.logsDir
|
|
3211
|
+
? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
|
|
3212
|
+
: ndjsonPathForConvoy(convoyId, effectiveBasePath)
|
|
3213
|
+
events = createEventEmitter(store, { ndjsonPath })
|
|
3214
|
+
const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
|
|
3215
|
+
const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
|
|
3153
3216
|
|
|
3154
3217
|
// Reset interrupted tasks and mark their workers as killed
|
|
3155
3218
|
const allTasks = store.getTasksByConvoy(convoyId)
|
|
@@ -3187,14 +3250,19 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
|
|
|
3187
3250
|
|
|
3188
3251
|
result = await runConvoy(
|
|
3189
3252
|
convoyId, spec, adapter, store, events,
|
|
3190
|
-
wtManager, mergeQueue,
|
|
3253
|
+
wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
|
|
3191
3254
|
options._reviewRunner,
|
|
3192
3255
|
)
|
|
3193
3256
|
} finally {
|
|
3194
|
-
events
|
|
3257
|
+
events?.close()
|
|
3195
3258
|
store.close()
|
|
3196
3259
|
lock.release()
|
|
3197
3260
|
lockDb.close()
|
|
3261
|
+
if (convoyWorktreeDir) {
|
|
3262
|
+
try {
|
|
3263
|
+
await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
|
|
3264
|
+
} catch { /* ignore cleanup errors */ }
|
|
3265
|
+
}
|
|
3198
3266
|
}
|
|
3199
3267
|
return result
|
|
3200
3268
|
}
|
|
@@ -63,7 +63,7 @@ describe('createEventEmitter', () => {
|
|
|
63
63
|
|
|
64
64
|
it('stores null data when no data object is provided', () => {
|
|
65
65
|
const emitter = createEventEmitter(store)
|
|
66
|
-
emitter.emit('
|
|
66
|
+
emitter.emit('convoy_guard', undefined, { convoy_id: 'c1' })
|
|
67
67
|
emitter.close()
|
|
68
68
|
const events = store.getEvents('c1')
|
|
69
69
|
expect(events[0].data).toBeNull()
|
|
@@ -92,7 +92,7 @@ describe('createEventEmitter', () => {
|
|
|
92
92
|
|
|
93
93
|
it('defaults all ids to null when ids are not provided', () => {
|
|
94
94
|
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
95
|
-
emitter.emit('
|
|
95
|
+
emitter.emit('convoy_guard')
|
|
96
96
|
emitter.close()
|
|
97
97
|
const events = store.getEvents('c1')
|
|
98
98
|
expect(events).toHaveLength(0)
|
|
@@ -106,7 +106,7 @@ describe('createEventEmitter', () => {
|
|
|
106
106
|
|
|
107
107
|
it('includes all provided ids in the NDJSON record', () => {
|
|
108
108
|
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
109
|
-
emitter.emit('
|
|
109
|
+
emitter.emit('task_started', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
|
|
110
110
|
emitter.close()
|
|
111
111
|
const line = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
|
|
112
112
|
expect(line.convoy_id).toBe('c1')
|
|
@@ -116,7 +116,7 @@ describe('createEventEmitter', () => {
|
|
|
116
116
|
|
|
117
117
|
it('SQLite event stores correct ids', () => {
|
|
118
118
|
const emitter = createEventEmitter(store)
|
|
119
|
-
emitter.emit('
|
|
119
|
+
emitter.emit('task_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
|
|
120
120
|
emitter.close()
|
|
121
121
|
const events = store.getEvents('c1')
|
|
122
122
|
expect(events[0].task_id).toBe('task-x')
|
|
@@ -125,7 +125,7 @@ describe('createEventEmitter', () => {
|
|
|
125
125
|
|
|
126
126
|
it('does not throw if NDJSON path is not provided', () => {
|
|
127
127
|
const emitter = createEventEmitter(store)
|
|
128
|
-
expect(() => emitter.emit('
|
|
128
|
+
expect(() => emitter.emit('convoy_guard', {}, { convoy_id: 'c1' })).not.toThrow()
|
|
129
129
|
emitter.close()
|
|
130
130
|
})
|
|
131
131
|
|
|
@@ -266,7 +266,7 @@ describe('crash resilience', () => {
|
|
|
266
266
|
const count = 1000
|
|
267
267
|
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
268
268
|
for (let i = 0; i < count; i++) {
|
|
269
|
-
emitter.emit('
|
|
269
|
+
emitter.emit('task_retried', { previous_status: String(i) }, { convoy_id: 'c1', task_id: `t${i}` })
|
|
270
270
|
}
|
|
271
271
|
emitter.close()
|
|
272
272
|
|
|
@@ -280,7 +280,7 @@ describe('crash resilience', () => {
|
|
|
280
280
|
// Each line is valid JSON with the right type
|
|
281
281
|
for (const line of lines) {
|
|
282
282
|
const parsed = JSON.parse(line)
|
|
283
|
-
expect(parsed.type).toBe('
|
|
283
|
+
expect(parsed.type).toBe('task_retried')
|
|
284
284
|
expect(parsed.convoy_id).toBe('c1')
|
|
285
285
|
}
|
|
286
286
|
|
|
@@ -453,7 +453,9 @@ describe('emit-time data validation', () => {
|
|
|
453
453
|
|
|
454
454
|
it('unknown event type bypasses data validation (only one warning)', () => {
|
|
455
455
|
const emitter = createEventEmitter(store)
|
|
456
|
-
|
|
456
|
+
// Cast needed: TypeScript normally catches unknown types at compile time.
|
|
457
|
+
// This tests the runtime guard (e.g. JS callers / dynamic values / `as any` paths).
|
|
458
|
+
emitter.emit('unknown_type_xyz' as Parameters<typeof emitter.emit>[0], { any: 'data' }, { convoy_id: 'c1' })
|
|
457
459
|
emitter.close()
|
|
458
460
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
459
461
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown event type: "unknown_type_xyz"'))
|
package/src/cli/convoy/events.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import type { ConvoyStore } from './store.js'
|
|
4
4
|
import { KNOWN_EVENT_TYPES } from './types.js'
|
|
5
|
+
import type { ConvoyEventType } from './types.js'
|
|
5
6
|
import { validateEventData } from './event-schemas.js'
|
|
6
7
|
|
|
7
8
|
const RESERVED_KEYS = new Set(['_event_id', 'convoy_id', 'task_id', 'worker_id', 'timestamp', 'type'])
|
|
@@ -16,11 +17,13 @@ export function ndjsonPathForConvoy(convoyId: string, basePath?: string): string
|
|
|
16
17
|
return join(base, '.opencastle', 'logs', 'convoys', `${convoyId}.ndjson`)
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
type ConvoyEmitIds = { convoy_id?: string; task_id?: string; worker_id?: string }
|
|
21
|
+
|
|
19
22
|
export interface ConvoyEventEmitter {
|
|
20
|
-
emit(
|
|
21
|
-
type:
|
|
22
|
-
data?:
|
|
23
|
-
ids?:
|
|
23
|
+
emit<T extends ConvoyEventType>(
|
|
24
|
+
type: T['type'],
|
|
25
|
+
data?: T extends { data?: infer D } ? D : never,
|
|
26
|
+
ids?: ConvoyEmitIds,
|
|
24
27
|
): void
|
|
25
28
|
close(): void
|
|
26
29
|
}
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
package/src/cli/convoy/types.ts
CHANGED
|
@@ -364,6 +364,9 @@ export type ConvoyEventType =
|
|
|
364
364
|
| { type: 'tdd_check_passed'; data?: { task_id?: string; new_source_files?: number; existing_test_files?: number } }
|
|
365
365
|
| { type: 'tdd_check_failed'; data?: { task_id?: string; missing_test_files?: string[]; new_source_files?: number } }
|
|
366
366
|
| { type: 'tdd_check_skipped'; data?: { task_id?: string; reason?: string; agent?: string } }
|
|
367
|
+
| { type: 'convoy_resumed'; data?: { original_created_at?: string } }
|
|
368
|
+
| { type: 'artifacts_extracted'; data?: { task_id?: string; count?: number; artifacts?: Array<{ filename: string; summary?: string }> } }
|
|
369
|
+
| { type: 'file_partition_conflict'; data?: { conflicts?: Array<{ phase: number; taskA: string; taskB: string; overlapping: string[] }> } }
|
|
367
370
|
|
|
368
371
|
/** All canonical convoy event type strings. Used for runtime validation. */
|
|
369
372
|
export const KNOWN_EVENT_TYPES: Set<string> = new Set<ConvoyEventType['type']>([
|
|
@@ -414,4 +417,7 @@ export const KNOWN_EVENT_TYPES: Set<string> = new Set<ConvoyEventType['type']>([
|
|
|
414
417
|
'tdd_check_passed',
|
|
415
418
|
'tdd_check_failed',
|
|
416
419
|
'tdd_check_skipped',
|
|
420
|
+
'convoy_resumed',
|
|
421
|
+
'artifacts_extracted',
|
|
422
|
+
'file_partition_conflict',
|
|
417
423
|
])
|
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-
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"id": "artifact-demo-api-v2-reports-security-gate-failure-md",
|
|
58
|
-
"name": "reports/security-gate-failure.md",
|
|
59
|
-
"type": "summary",
|
|
60
|
-
"task_id": "api-t3",
|
|
61
|
-
"created_at": "2026-04-06T12:18:29.969Z"
|
|
54
|
+
"created_at": "2026-04-07T13:06:16.545Z"
|
|
62
55
|
},
|
|
63
56
|
{
|
|
64
57
|
"id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
|
|
65
58
|
"name": "src/api/rate-limiter.ts",
|
|
66
59
|
"type": "file",
|
|
67
60
|
"task_id": "api-t2",
|
|
68
|
-
"created_at": "2026-04-
|
|
61
|
+
"created_at": "2026-04-07T13:06:16.545Z"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "artifact-demo-api-v2-reports-security-gate-failure-md",
|
|
65
|
+
"name": "reports/security-gate-failure.md",
|
|
66
|
+
"type": "summary",
|
|
67
|
+
"task_id": "api-t3",
|
|
68
|
+
"created_at": "2026-04-07T13:06:16.546Z"
|
|
69
69
|
}
|
|
70
70
|
],
|
|
71
71
|
"has_more_events": false,
|