opencastle 0.33.6 → 0.33.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/pipeline.ts +81 -19
- package/src/cli/run.ts +0 -118
- package/src/dashboard/dist/data/convoys/demo-api-v2.json +3 -3
- package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +4 -4
- 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 +3 -3
- package/src/dashboard/public/data/convoys/demo-auth-revamp.json +4 -4
- 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/package.json
CHANGED
|
@@ -103,6 +103,7 @@ function makeEngine(opts: ConvoyEngineOptions): ReturnType<typeof createConvoyEn
|
|
|
103
103
|
return createConvoyEngine({
|
|
104
104
|
logsDir: join(tmpDir, 'logs'), // prevents test data in production logs
|
|
105
105
|
_ensureBranch: vi.fn().mockResolvedValue(undefined),
|
|
106
|
+
_convoyWorktreeDir: null,
|
|
106
107
|
...opts,
|
|
107
108
|
})
|
|
108
109
|
}
|
|
@@ -3283,11 +3284,10 @@ describe('symlink security scan', () => {
|
|
|
3283
3284
|
})
|
|
3284
3285
|
})
|
|
3285
3286
|
|
|
3286
|
-
// ── Security:
|
|
3287
|
+
// ── Security: convoy-level worktree when branch is set ───────────────────────
|
|
3287
3288
|
|
|
3288
|
-
describe('
|
|
3289
|
-
it('
|
|
3290
|
-
const branchFn = vi.fn().mockResolvedValue(undefined)
|
|
3289
|
+
describe('convoy-level worktree when branch is set', () => {
|
|
3290
|
+
it('runs successfully when _convoyWorktreeDir is null and branch is set', async () => {
|
|
3291
3291
|
const adapter = makeAdapter()
|
|
3292
3292
|
const spec = makeSpec({ branch: 'feature-x' })
|
|
3293
3293
|
const engine = createConvoyEngine({
|
|
@@ -3297,15 +3297,14 @@ describe('ensureBranch fallback when _ensureBranch not provided', () => {
|
|
|
3297
3297
|
dbPath,
|
|
3298
3298
|
_worktreeManager: makeWorktreeManager(),
|
|
3299
3299
|
_mergeQueue: makeMergeQueue(),
|
|
3300
|
-
|
|
3300
|
+
_convoyWorktreeDir: null,
|
|
3301
3301
|
})
|
|
3302
3302
|
|
|
3303
|
-
await engine.run()
|
|
3304
|
-
expect(
|
|
3303
|
+
const result = await engine.run()
|
|
3304
|
+
expect(result.status).toBe('done')
|
|
3305
3305
|
})
|
|
3306
3306
|
|
|
3307
|
-
it('does not
|
|
3308
|
-
const branchFn = vi.fn().mockResolvedValue(undefined)
|
|
3307
|
+
it('does not attempt worktree creation when spec has no branch', async () => {
|
|
3309
3308
|
const adapter = makeAdapter()
|
|
3310
3309
|
const spec = makeSpec({ branch: undefined })
|
|
3311
3310
|
const engine = makeEngine({
|
|
@@ -3315,11 +3314,10 @@ describe('ensureBranch fallback when _ensureBranch not provided', () => {
|
|
|
3315
3314
|
dbPath,
|
|
3316
3315
|
_worktreeManager: makeWorktreeManager(),
|
|
3317
3316
|
_mergeQueue: makeMergeQueue(),
|
|
3318
|
-
_ensureBranch: branchFn,
|
|
3319
3317
|
})
|
|
3320
3318
|
|
|
3321
|
-
await engine.run()
|
|
3322
|
-
expect(
|
|
3319
|
+
const result = await engine.run()
|
|
3320
|
+
expect(result.status).toBe('done')
|
|
3323
3321
|
})
|
|
3324
3322
|
})
|
|
3325
3323
|
|
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
|
}
|
|
@@ -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/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-
|
|
54
|
+
"created_at": "2026-04-07T12:58:13.324Z"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"id": "artifact-demo-api-v2-reports-security-gate-failure-md",
|
|
58
58
|
"name": "reports/security-gate-failure.md",
|
|
59
59
|
"type": "summary",
|
|
60
60
|
"task_id": "api-t3",
|
|
61
|
-
"created_at": "2026-04-
|
|
61
|
+
"created_at": "2026-04-07T12:58:13.324Z"
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
|
|
65
65
|
"name": "src/api/rate-limiter.ts",
|
|
66
66
|
"type": "file",
|
|
67
67
|
"task_id": "api-t2",
|
|
68
|
-
"created_at": "2026-04-
|
|
68
|
+
"created_at": "2026-04-07T12:58:13.324Z"
|
|
69
69
|
}
|
|
70
70
|
],
|
|
71
71
|
"has_more_events": false,
|
|
@@ -42,28 +42,28 @@
|
|
|
42
42
|
"name": "libs/auth/src/jwt-middleware.ts",
|
|
43
43
|
"type": "file",
|
|
44
44
|
"task_id": "auth-t2",
|
|
45
|
-
"created_at": "2026-04-
|
|
45
|
+
"created_at": "2026-04-07T12:58:13.323Z"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"id": "artifact-demo-auth-revamp-libs-auth-src-rls-policies-sql",
|
|
49
49
|
"name": "libs/auth/src/rls-policies.sql",
|
|
50
50
|
"type": "file",
|
|
51
51
|
"task_id": "auth-t3",
|
|
52
|
-
"created_at": "2026-04-
|
|
52
|
+
"created_at": "2026-04-07T12:58:13.323Z"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"id": "artifact-demo-auth-revamp-tests-auth-integration-test-ts",
|
|
56
56
|
"name": "tests/auth/integration.test.ts",
|
|
57
57
|
"type": "file",
|
|
58
58
|
"task_id": "auth-t4",
|
|
59
|
-
"created_at": "2026-04-
|
|
59
|
+
"created_at": "2026-04-07T12:58:13.323Z"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"id": "artifact-demo-auth-revamp-reports-auth-review-summary-md",
|
|
63
63
|
"name": "reports/auth-review-summary.md",
|
|
64
64
|
"type": "summary",
|
|
65
65
|
"task_id": "auth-t5",
|
|
66
|
-
"created_at": "2026-04-
|
|
66
|
+
"created_at": "2026-04-07T12:58:13.324Z"
|
|
67
67
|
}
|
|
68
68
|
],
|
|
69
69
|
"has_more_events": false,
|