@shapeshift-labs/frontier-swarm-codex 0.2.0 → 0.4.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/README.md +39 -5
- package/benchmarks/package-bench.mjs +19 -2
- package/dist/cli.js +87 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +276 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +969 -23
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { FRONTIER_SWARM_DEFAULT_MODEL, FRONTIER_SWARM_DEFAULT_REASONING_EFFORT, checkSwarmOwnership, completeSwarmJob, createSwarmLeases, createSwarmPlan, createSwarmProof, createSwarmRun, createSwarmSchedule, recordSwarmEvent } from '@shapeshift-labs/frontier-swarm';
|
|
5
|
+
import { FRONTIER_SWARM_DEFAULT_MODEL, FRONTIER_SWARM_DEFAULT_REASONING_EFFORT, checkSwarmOwnership, completeSwarmJob, FRONTIER_SWARM_MERGE_BUNDLE_KIND, FRONTIER_SWARM_MERGE_BUNDLE_VERSION, createSwarmMergeBundle, createSwarmMergeIndex, createSwarmQueueOverlay, matchesGlob, createSwarmEventStream, createSwarmLeases, createSwarmPlan, createSwarmProof, createSwarmRun, createSwarmSchedule, recordSwarmEvent, routeSwarmEventToMailboxes } from '@shapeshift-labs/frontier-swarm';
|
|
5
6
|
export const FRONTIER_SWARM_CODEX_DEFAULT_MODEL = FRONTIER_SWARM_DEFAULT_MODEL;
|
|
6
7
|
export const FRONTIER_SWARM_CODEX_DEFAULT_REASONING_EFFORT = FRONTIER_SWARM_DEFAULT_REASONING_EFFORT;
|
|
8
|
+
export const FRONTIER_SWARM_CODEX_WORKSPACE_MANIFEST_KIND = 'frontier.swarm-codex.workspace-manifest';
|
|
9
|
+
export const FRONTIER_SWARM_CODEX_WORKSPACE_MANIFEST_VERSION = 1;
|
|
10
|
+
export const FRONTIER_SWARM_CODEX_WORKSPACE_PROOF_KIND = 'frontier.swarm-codex.workspace-proof';
|
|
11
|
+
export const FRONTIER_SWARM_CODEX_WORKSPACE_PROOF_VERSION = 1;
|
|
12
|
+
export const FRONTIER_SWARM_CODEX_PID_MANIFEST_KIND = 'frontier.swarm-codex.pid-manifest';
|
|
13
|
+
export const FRONTIER_SWARM_CODEX_PID_MANIFEST_VERSION = 1;
|
|
14
|
+
export const FRONTIER_SWARM_CODEX_COLLECTION_KIND = 'frontier.swarm-codex.collection';
|
|
15
|
+
export const FRONTIER_SWARM_CODEX_COLLECTION_VERSION = 1;
|
|
16
|
+
export const FRONTIER_SWARM_CODEX_APPLY_LEDGER_KIND = 'frontier.swarm-codex.apply-ledger';
|
|
17
|
+
export const FRONTIER_SWARM_CODEX_APPLY_LEDGER_VERSION = 1;
|
|
18
|
+
export const FRONTIER_SWARM_CODEX_PATCH_SCORE_KIND = 'frontier.swarm-codex.patch-score';
|
|
19
|
+
export const FRONTIER_SWARM_CODEX_PATCH_SCORE_VERSION = 1;
|
|
7
20
|
const DEFAULT_WORKSPACE_INCLUDES = ['AGENTS.md', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'config'];
|
|
8
21
|
const DEFAULT_WORKSPACE_EXCLUDES = [
|
|
9
22
|
'.git',
|
|
@@ -15,6 +28,7 @@ const DEFAULT_WORKSPACE_EXCLUDES = [
|
|
|
15
28
|
'test/roms',
|
|
16
29
|
'target'
|
|
17
30
|
];
|
|
31
|
+
const pidManifestWriteQueues = new Map();
|
|
18
32
|
export function createCodexSwarmPlan(input) {
|
|
19
33
|
return createSwarmPlan(coerceCodexSwarmManifestInput(input.manifest), coerceCodexSwarmTasksInput(input.tasks), input.plan ?? {});
|
|
20
34
|
}
|
|
@@ -78,11 +92,16 @@ export function coerceCodexSwarmTasksInput(value) {
|
|
|
78
92
|
sourceRefs: readStringArray(task.sourceRefs).concat(readStringArray(task.legacySourcePaths)),
|
|
79
93
|
targetRefs: readStringArray(task.targetRefs).concat(readStringArray(task.ownedFiles), readStringArray(task.files)),
|
|
80
94
|
allowedWrites: readStringArray(task.allowedWrites).concat(readStringArray(task.ownedFiles), readStringArray(task.files)),
|
|
95
|
+
ownershipRegions: Array.isArray(task.ownershipRegions) ? task.ownershipRegions : [],
|
|
96
|
+
ownedRegions: readStringArray(task.ownedRegions),
|
|
97
|
+
changedRegions: readStringArray(task.changedRegions),
|
|
81
98
|
acceptance: readStringArray(task.acceptance),
|
|
82
99
|
acceptanceChecks: Array.isArray(task.acceptanceChecks) ? task.acceptanceChecks : undefined,
|
|
83
100
|
verification: Array.isArray(task.verification) ? task.verification : undefined,
|
|
84
101
|
evidenceCommand: typeof task.evidenceCommand === 'string' ? task.evidenceCommand : undefined,
|
|
85
102
|
shardCommand: typeof task.shardCommand === 'string' ? task.shardCommand : undefined,
|
|
103
|
+
capabilities: readStringArray(task.capabilities),
|
|
104
|
+
resourceRequirements: isObject(task.resourceRequirements) ? task.resourceRequirements : undefined,
|
|
86
105
|
tags: readStringArray(task.tags),
|
|
87
106
|
metadata: { source: task }
|
|
88
107
|
};
|
|
@@ -92,26 +111,87 @@ export async function runCodexSwarm(plan, options) {
|
|
|
92
111
|
const outDir = path.resolve(options.cwd ?? process.cwd(), options.outDir);
|
|
93
112
|
await fs.mkdir(outDir, { recursive: true });
|
|
94
113
|
await fs.writeFile(path.join(outDir, 'swarm-plan.json'), JSON.stringify(plan, null, 2) + '\n');
|
|
114
|
+
const eventStream = options.eventStream ?? createSwarmEventStream({
|
|
115
|
+
runId: plan.runId,
|
|
116
|
+
root: path.join(outDir, 'streams'),
|
|
117
|
+
lanes: Array.from(new Set(plan.jobs.map((job) => job.lane)))
|
|
118
|
+
});
|
|
119
|
+
await initFileSwarmEventStream(eventStream);
|
|
120
|
+
const pidManifestPath = path.resolve(options.cwd ?? process.cwd(), options.pidManifestPath ?? path.join(outDir, 'pids.json'));
|
|
121
|
+
await appendCodexPidManifest(pidManifestPath, { pid: process.pid, role: 'parent', runId: plan.runId, startedAt: Date.now() }, plan.runId);
|
|
95
122
|
let run = createSwarmRun({ plan, status: 'running', startedAt: Date.now() });
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
const startedEvent = { type: 'swarm.started', runId: run.id, at: run.startedAt, data: { jobCount: plan.jobs.length } };
|
|
124
|
+
run = recordSwarmEvent(run, startedEvent);
|
|
125
|
+
await appendFileSwarmEvent(eventStream, startedEvent);
|
|
126
|
+
const runOptions = { ...options, eventStream, pidManifestPath };
|
|
127
|
+
const results = await runScheduledJobPool(plan, Math.max(1, options.maxConcurrency ?? 1), (job, lease) => runCodexJob(job, runOptions, outDir, lease));
|
|
128
|
+
for (const result of results) {
|
|
129
|
+
const job = plan.jobs.find((entry) => entry.id === result.jobId);
|
|
130
|
+
if (job) {
|
|
131
|
+
await options.onJobFinished?.({ job, result });
|
|
132
|
+
await appendFileSwarmEvent(eventStream, {
|
|
133
|
+
type: 'agent.finished',
|
|
134
|
+
runId: run.id,
|
|
135
|
+
jobId: job.id,
|
|
136
|
+
taskId: job.taskId,
|
|
137
|
+
lane: job.lane,
|
|
138
|
+
data: { status: result.status, mergeReadiness: result.mergeReadiness, changedPathCount: result.changedPaths?.length ?? 0 }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
98
142
|
for (const result of results)
|
|
99
143
|
run = completeSwarmJob(run, result);
|
|
100
144
|
const proof = createSwarmProof(run, { validation: plan.validation });
|
|
101
145
|
const ok = run.summary.failedCount === 0 && run.summary.blockedCount === 0 && run.summary.ownershipViolationCount === 0;
|
|
146
|
+
await appendFileSwarmEvent(eventStream, {
|
|
147
|
+
type: 'swarm.finished',
|
|
148
|
+
runId: run.id,
|
|
149
|
+
data: { ok, summary: run.summary }
|
|
150
|
+
});
|
|
102
151
|
await fs.writeFile(path.join(outDir, 'swarm-results.json'), JSON.stringify({ ok, outDir, run, proof }, null, 2) + '\n');
|
|
103
|
-
|
|
152
|
+
await writeSwarmCoordinatorSnapshot(options.coordinatorSnapshotPath ? path.resolve(options.cwd ?? process.cwd(), options.coordinatorSnapshotPath) : path.join(outDir, 'coordinator-dashboard.json'), {
|
|
153
|
+
ok,
|
|
154
|
+
outDir,
|
|
155
|
+
plan,
|
|
156
|
+
run,
|
|
157
|
+
proof,
|
|
158
|
+
eventStream,
|
|
159
|
+
pidManifestPath
|
|
160
|
+
});
|
|
161
|
+
const result = { ok, outDir, plan, run, proof };
|
|
162
|
+
await options.onSwarmFinished?.({ result });
|
|
163
|
+
return result;
|
|
104
164
|
}
|
|
105
165
|
export async function runCodexJob(job, options, outDir, lease) {
|
|
106
|
-
const paths = await createJobPaths(outDir, job);
|
|
166
|
+
const paths = await createJobPaths(outDir, job, options);
|
|
107
167
|
const workspace = await prepareCodexWorkspace(job, options);
|
|
108
168
|
const workspacePlan = createCodexWorkspacePlan(job, options);
|
|
169
|
+
const hookInput = {
|
|
170
|
+
job,
|
|
171
|
+
cwd: options.cwd ?? process.cwd(),
|
|
172
|
+
outDir,
|
|
173
|
+
workspacePath: workspace,
|
|
174
|
+
workspacePlan,
|
|
175
|
+
paths
|
|
176
|
+
};
|
|
177
|
+
await options.prepareJobWorkspace?.(hookInput);
|
|
109
178
|
const fileSnapshot = shouldSnapshotWorkspaceChanges(workspacePlan, options)
|
|
110
179
|
? await snapshotWorkspaceFiles(workspace)
|
|
111
180
|
: undefined;
|
|
112
|
-
const
|
|
181
|
+
const basePrompt = renderCodexPrompt(job, { workspacePath: workspace, paths });
|
|
182
|
+
const prompt = options.renderJobPrompt
|
|
183
|
+
? await options.renderJobPrompt({ ...hookInput, prompt: basePrompt })
|
|
184
|
+
: basePrompt;
|
|
113
185
|
await fs.writeFile(paths.promptPath, prompt);
|
|
114
186
|
const args = buildCodexArgs(job, { ...options, workspacePath: workspace, paths });
|
|
187
|
+
await options.onJobStarted?.({ ...hookInput, prompt, args });
|
|
188
|
+
await appendFileSwarmEvent(options.eventStream, {
|
|
189
|
+
type: 'agent.scheduled',
|
|
190
|
+
jobId: job.id,
|
|
191
|
+
taskId: job.taskId,
|
|
192
|
+
lane: job.lane,
|
|
193
|
+
data: { workspace: workspacePlan.path, capabilities: job.capabilities, resourceRequirements: job.resourceRequirements }
|
|
194
|
+
});
|
|
115
195
|
const startedAt = Date.now();
|
|
116
196
|
const execution = options.dryRun
|
|
117
197
|
? { exitCode: 0, changedPaths: [] }
|
|
@@ -125,12 +205,27 @@ export async function runCodexJob(job, options, outDir, lease) {
|
|
|
125
205
|
paths,
|
|
126
206
|
timeoutMs: job.compute.timeoutMs ?? options.jobTimeoutMs ?? 7200000
|
|
127
207
|
});
|
|
128
|
-
const
|
|
208
|
+
const collected = execution.changedPaths
|
|
209
|
+
? filterWorkspaceChangedPaths(execution.changedPaths, workspacePlan)
|
|
210
|
+
: options.collectGitStatus === false
|
|
211
|
+
? { changedPaths: [], ignoredChangedPaths: [] }
|
|
212
|
+
: await collectChangedPaths(workspace, fileSnapshot, workspacePlan);
|
|
213
|
+
const rawChangedPaths = collected.changedPaths;
|
|
214
|
+
const changedPaths = options.changedPathFilter ? [...options.changedPathFilter(rawChangedPaths, hookInput)] : rawChangedPaths;
|
|
215
|
+
const workspaceProof = await createSwarmWorkspaceProof(workspacePlan, { ignoredChangedPaths: collected.ignoredChangedPaths });
|
|
216
|
+
await fs.writeFile(paths.workspaceProofPath, JSON.stringify(workspaceProof, null, 2) + '\n');
|
|
129
217
|
const ownership = checkSwarmOwnership(job, changedPaths);
|
|
130
218
|
const verification = options.runVerification ? await runVerification(job.verification, workspace) : [];
|
|
131
219
|
const failedVerification = verification.some((entry) => entry.required !== false && entry.status !== 0);
|
|
132
220
|
const status = ownership.ok && execution.exitCode === 0 && !failedVerification ? 'completed' : 'failed';
|
|
133
|
-
|
|
221
|
+
const patchPath = await writeCodexPatchFile({
|
|
222
|
+
workspace,
|
|
223
|
+
sourceRoot: path.resolve(options.cwd ?? process.cwd()),
|
|
224
|
+
paths,
|
|
225
|
+
workspacePlan,
|
|
226
|
+
changedPaths
|
|
227
|
+
});
|
|
228
|
+
const result = {
|
|
134
229
|
jobId: job.id,
|
|
135
230
|
status,
|
|
136
231
|
startedAt,
|
|
@@ -138,19 +233,34 @@ export async function runCodexJob(job, options, outDir, lease) {
|
|
|
138
233
|
exitCode: execution.exitCode,
|
|
139
234
|
signal: execution.signal,
|
|
140
235
|
changedPaths,
|
|
236
|
+
changedRegions: job.changedRegions,
|
|
141
237
|
ownershipViolations: ownership.violations,
|
|
142
|
-
evidencePaths: [paths.evidenceDir],
|
|
238
|
+
evidencePaths: [paths.evidenceDir, paths.workspaceProofPath, paths.mergeBundlePath, ...(patchPath ? [patchPath] : [])],
|
|
239
|
+
...(patchPath ? { patchPath } : {}),
|
|
240
|
+
queueItemIds: [job.taskId],
|
|
143
241
|
verification,
|
|
144
242
|
lastMessage: execution.lastMessage,
|
|
145
243
|
error: execution.error,
|
|
146
244
|
metadata: lease ? { leaseId: lease.id, leaseToken: lease.token, fencingToken: lease.fencingToken } : undefined
|
|
147
245
|
};
|
|
246
|
+
const mergeBundle = createSwarmMergeBundle({
|
|
247
|
+
runId: options.eventStream?.runId,
|
|
248
|
+
job,
|
|
249
|
+
result,
|
|
250
|
+
...(patchPath ? { patchPath } : {}),
|
|
251
|
+
evidencePaths: [paths.evidenceDir, paths.workspaceProofPath],
|
|
252
|
+
queueItemIds: [job.taskId]
|
|
253
|
+
});
|
|
254
|
+
await fs.writeFile(paths.mergeBundlePath, JSON.stringify(mergeBundle, null, 2) + '\n');
|
|
255
|
+
return result;
|
|
148
256
|
}
|
|
149
257
|
export function buildCodexArgs(job, input) {
|
|
150
|
-
const model = job
|
|
151
|
-
const effort = job
|
|
258
|
+
const model = resolveCodexModelFlag(job, input);
|
|
259
|
+
const effort = resolveCodexReasoningEffort(job, input);
|
|
152
260
|
const sandbox = job.compute.sandbox ?? input.sandbox ?? 'workspace-write';
|
|
261
|
+
const approval = normalizeCodexApprovalPolicy(input.approval);
|
|
153
262
|
const args = [
|
|
263
|
+
...(approval ? ['--ask-for-approval', approval] : []),
|
|
154
264
|
'exec',
|
|
155
265
|
'--cd',
|
|
156
266
|
input.workspacePath,
|
|
@@ -160,12 +270,12 @@ export function buildCodexArgs(job, input) {
|
|
|
160
270
|
sandbox,
|
|
161
271
|
'--json',
|
|
162
272
|
'--output-last-message',
|
|
163
|
-
input.paths.lastMessagePath
|
|
164
|
-
'--model',
|
|
165
|
-
model,
|
|
166
|
-
'-c',
|
|
167
|
-
`model_reasoning_effort="${effort}"`
|
|
273
|
+
input.paths.lastMessagePath
|
|
168
274
|
];
|
|
275
|
+
if (model)
|
|
276
|
+
args.push('--model', model);
|
|
277
|
+
if (effort)
|
|
278
|
+
args.push('-c', `model_reasoning_effort="${effort}"`);
|
|
169
279
|
if (shouldSkipGitRepoCheck(input))
|
|
170
280
|
args.push('--skip-git-repo-check');
|
|
171
281
|
for (const dir of input.addDirs ?? [])
|
|
@@ -178,6 +288,56 @@ export function buildCodexArgs(job, input) {
|
|
|
178
288
|
args.push('-');
|
|
179
289
|
return args;
|
|
180
290
|
}
|
|
291
|
+
export function normalizeCodexModelFlag(model) {
|
|
292
|
+
if (model === false || model == null)
|
|
293
|
+
return undefined;
|
|
294
|
+
const value = String(model).trim();
|
|
295
|
+
if (!value)
|
|
296
|
+
return undefined;
|
|
297
|
+
const normalized = value.toLowerCase();
|
|
298
|
+
if (normalized === 'auto' || normalized === 'default' || normalized === 'config' || normalized === 'config-default') {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
export function normalizeCodexApprovalPolicy(approval) {
|
|
304
|
+
if (approval === false || approval == null)
|
|
305
|
+
return undefined;
|
|
306
|
+
const value = String(approval).trim().toLowerCase().replaceAll('_', '-');
|
|
307
|
+
if (!value || value === 'default' || value === 'config-default')
|
|
308
|
+
return undefined;
|
|
309
|
+
if (value === 'never' || value === 'none' || value === 'off' || value === 'false' || value === 'full-auto')
|
|
310
|
+
return 'never';
|
|
311
|
+
if (value === 'untrusted')
|
|
312
|
+
return 'untrusted';
|
|
313
|
+
if (value === 'on-failure')
|
|
314
|
+
return 'on-failure';
|
|
315
|
+
if (value === 'on-request' || value === 'request' || value === 'manual')
|
|
316
|
+
return 'on-request';
|
|
317
|
+
throw new Error(`unsupported Codex approval policy "${approval}"; expected untrusted, on-request, on-failure, never, full-auto, none, or default`);
|
|
318
|
+
}
|
|
319
|
+
function resolveCodexModelFlag(job, input) {
|
|
320
|
+
const explicit = normalizeCodexModelFlag(input.model);
|
|
321
|
+
if (explicit || input.model === false)
|
|
322
|
+
return explicit;
|
|
323
|
+
const policy = input.modelPolicy ?? (input.forwardPlanModel ? 'plan' : 'config-default');
|
|
324
|
+
if (policy === 'plan')
|
|
325
|
+
return normalizeCodexModelFlag(job.compute.model ?? FRONTIER_SWARM_CODEX_DEFAULT_MODEL);
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
function resolveCodexReasoningEffort(job, input) {
|
|
329
|
+
if (input.reasoningEffort === false)
|
|
330
|
+
return undefined;
|
|
331
|
+
if (typeof input.reasoningEffort === 'string') {
|
|
332
|
+
const explicit = input.reasoningEffort.trim();
|
|
333
|
+
return explicit && explicit !== 'default' && explicit !== 'config-default' ? explicit : undefined;
|
|
334
|
+
}
|
|
335
|
+
const policy = input.modelPolicy ?? (input.forwardPlanModel || input.forwardPlanReasoningEffort ? 'plan' : 'config-default');
|
|
336
|
+
if (policy !== 'plan')
|
|
337
|
+
return undefined;
|
|
338
|
+
const effort = job.compute.reasoningEffort ?? FRONTIER_SWARM_CODEX_DEFAULT_REASONING_EFFORT;
|
|
339
|
+
return effort ? String(effort).trim() : undefined;
|
|
340
|
+
}
|
|
181
341
|
export function renderCodexPrompt(job, input) {
|
|
182
342
|
return [
|
|
183
343
|
'# Frontier Swarm Codex Job',
|
|
@@ -237,6 +397,15 @@ export async function spawnCodexExecutor(input) {
|
|
|
237
397
|
await fs.writeFile(input.paths.stderrPath, '');
|
|
238
398
|
return new Promise((resolve) => {
|
|
239
399
|
const child = spawn(input.codexPath, input.args, { cwd: input.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
400
|
+
if (child.pid) {
|
|
401
|
+
appendCodexPidManifest(input.paths.pidManifestPath, {
|
|
402
|
+
pid: child.pid,
|
|
403
|
+
role: 'codex',
|
|
404
|
+
jobId: input.job.id,
|
|
405
|
+
startedAt: Date.now(),
|
|
406
|
+
command: [input.codexPath, ...input.args]
|
|
407
|
+
}).catch(() => { });
|
|
408
|
+
}
|
|
240
409
|
const timer = setTimeout(() => child.kill('SIGTERM'), input.timeoutMs);
|
|
241
410
|
child.stdout.on('data', (chunk) => fs.appendFile(input.paths.eventsPath, chunk).catch(() => { }));
|
|
242
411
|
child.stderr.on('data', (chunk) => fs.appendFile(input.paths.stderrPath, chunk).catch(() => { }));
|
|
@@ -255,7 +424,7 @@ export async function spawnCodexExecutor(input) {
|
|
|
255
424
|
});
|
|
256
425
|
});
|
|
257
426
|
}
|
|
258
|
-
async function createJobPaths(outDir, job) {
|
|
427
|
+
async function createJobPaths(outDir, job, options) {
|
|
259
428
|
const jobDir = path.join(outDir, job.id);
|
|
260
429
|
const paths = {
|
|
261
430
|
jobDir,
|
|
@@ -263,7 +432,11 @@ async function createJobPaths(outDir, job) {
|
|
|
263
432
|
eventsPath: path.join(jobDir, 'codex-events.jsonl'),
|
|
264
433
|
stderrPath: path.join(jobDir, 'codex-stderr.log'),
|
|
265
434
|
lastMessagePath: path.join(jobDir, 'last-message.md'),
|
|
266
|
-
evidenceDir: path.join(jobDir, 'evidence')
|
|
435
|
+
evidenceDir: path.join(jobDir, 'evidence'),
|
|
436
|
+
workspaceProofPath: path.join(jobDir, 'evidence', 'workspace-proof.json'),
|
|
437
|
+
patchPath: path.join(jobDir, 'evidence', 'changes.patch'),
|
|
438
|
+
mergeBundlePath: path.join(jobDir, 'evidence', 'merge.json'),
|
|
439
|
+
pidManifestPath: path.resolve(options.cwd ?? process.cwd(), options.pidManifestPath ?? path.join(outDir, 'pids.json'))
|
|
267
440
|
};
|
|
268
441
|
await fs.mkdir(paths.evidenceDir, { recursive: true });
|
|
269
442
|
return paths;
|
|
@@ -285,6 +458,7 @@ export async function prepareCodexWorkspace(job, options) {
|
|
|
285
458
|
if (await pathExists(plan.path)) {
|
|
286
459
|
if (!plan.replace)
|
|
287
460
|
return plan.path;
|
|
461
|
+
assertGeneratedWorkspacePath(plan);
|
|
288
462
|
await fs.rm(plan.path, { recursive: true, force: true });
|
|
289
463
|
}
|
|
290
464
|
await fs.mkdir(plan.path, { recursive: true });
|
|
@@ -314,6 +488,10 @@ export function createCodexWorkspacePlan(job, options) {
|
|
|
314
488
|
excludes: [],
|
|
315
489
|
artifactIncludes: [],
|
|
316
490
|
linkPaths: [],
|
|
491
|
+
requiredIncludes: [],
|
|
492
|
+
optionalIncludes: [],
|
|
493
|
+
strategy: workspace.strategy ?? 'fs-cp',
|
|
494
|
+
...(workspace.guardRoot ? { guardRoot: path.resolve(cwd, workspace.guardRoot) } : {}),
|
|
317
495
|
linkNodeModules: false,
|
|
318
496
|
replace: false,
|
|
319
497
|
skipGitRepoCheck: workspace.skipGitRepoCheck ?? false
|
|
@@ -341,6 +519,16 @@ export function createCodexWorkspacePlan(job, options) {
|
|
|
341
519
|
...readStringArray(rawTask.snapshotLinkPaths),
|
|
342
520
|
...readStringArray(rawTask.linkPaths)
|
|
343
521
|
]);
|
|
522
|
+
const requiredIncludes = uniqueWorkspacePaths([
|
|
523
|
+
...readStringArray(workspace.requiredIncludes),
|
|
524
|
+
...readStringArray(rawTask.requiredIncludes),
|
|
525
|
+
...readStringArray(rawTask.snapshotRequiredIncludes)
|
|
526
|
+
]);
|
|
527
|
+
const optionalIncludes = uniqueWorkspacePaths([
|
|
528
|
+
...readStringArray(workspace.optionalIncludes),
|
|
529
|
+
...readStringArray(rawTask.optionalIncludes),
|
|
530
|
+
...readStringArray(rawTask.snapshotOptionalIncludes)
|
|
531
|
+
]);
|
|
344
532
|
return {
|
|
345
533
|
mode,
|
|
346
534
|
root,
|
|
@@ -349,11 +537,562 @@ export function createCodexWorkspacePlan(job, options) {
|
|
|
349
537
|
excludes,
|
|
350
538
|
artifactIncludes,
|
|
351
539
|
linkPaths,
|
|
540
|
+
requiredIncludes,
|
|
541
|
+
optionalIncludes,
|
|
542
|
+
strategy: workspace.strategy ?? 'fs-cp',
|
|
543
|
+
guardRoot: path.resolve(cwd, workspace.guardRoot ?? workspace.root ?? path.join('agent-worktrees', 'frontier-swarm-codex')),
|
|
352
544
|
linkNodeModules: workspace.linkNodeModules ?? (mode !== 'git-worktree'),
|
|
353
545
|
replace: workspace.replace ?? false,
|
|
354
546
|
skipGitRepoCheck: workspace.skipGitRepoCheck ?? (mode === 'copy' || mode === 'snapshot')
|
|
355
547
|
};
|
|
356
548
|
}
|
|
549
|
+
export function createSwarmWorkspaceManifest(plan) {
|
|
550
|
+
return {
|
|
551
|
+
kind: FRONTIER_SWARM_CODEX_WORKSPACE_MANIFEST_KIND,
|
|
552
|
+
version: FRONTIER_SWARM_CODEX_WORKSPACE_MANIFEST_VERSION,
|
|
553
|
+
id: 'codex-workspace:' + stableHash([plan.mode, plan.root, plan.path, plan.includes, plan.linkPaths]),
|
|
554
|
+
mode: plan.mode,
|
|
555
|
+
root: plan.root,
|
|
556
|
+
path: plan.path,
|
|
557
|
+
includes: [...plan.includes],
|
|
558
|
+
excludes: [...plan.excludes],
|
|
559
|
+
artifactIncludes: [...plan.artifactIncludes],
|
|
560
|
+
linkPaths: [...plan.linkPaths],
|
|
561
|
+
requiredIncludes: [...plan.requiredIncludes],
|
|
562
|
+
optionalIncludes: [...plan.optionalIncludes],
|
|
563
|
+
strategy: plan.strategy,
|
|
564
|
+
...(plan.guardRoot ? { guardRoot: plan.guardRoot } : {}),
|
|
565
|
+
linkNodeModules: plan.linkNodeModules,
|
|
566
|
+
skipGitRepoCheck: plan.skipGitRepoCheck
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
export async function createSwarmWorkspaceProof(plan, input = {}) {
|
|
570
|
+
const generatedAt = input.generatedAt ?? Date.now();
|
|
571
|
+
const manifest = createSwarmWorkspaceManifest(plan);
|
|
572
|
+
const copiedCandidates = uniqueWorkspacePaths([...plan.includes, ...plan.artifactIncludes, ...plan.requiredIncludes]);
|
|
573
|
+
const optionalCandidates = uniqueWorkspacePaths(plan.optionalIncludes);
|
|
574
|
+
const copiedPaths = [];
|
|
575
|
+
const missingRequired = [];
|
|
576
|
+
const missingOptional = [];
|
|
577
|
+
for (const include of copiedCandidates) {
|
|
578
|
+
if (await pathExists(path.join(plan.path, include)))
|
|
579
|
+
copiedPaths.push(include);
|
|
580
|
+
else if (plan.requiredIncludes.includes(include))
|
|
581
|
+
missingRequired.push(include);
|
|
582
|
+
}
|
|
583
|
+
for (const include of optionalCandidates) {
|
|
584
|
+
if (await pathExists(path.join(plan.path, include)))
|
|
585
|
+
copiedPaths.push(include);
|
|
586
|
+
else
|
|
587
|
+
missingOptional.push(include);
|
|
588
|
+
}
|
|
589
|
+
const linkedPaths = [];
|
|
590
|
+
for (const linkPath of uniqueWorkspacePaths([...plan.linkPaths, ...(plan.linkNodeModules ? ['node_modules'] : [])])) {
|
|
591
|
+
const stat = await fs.lstat(path.join(plan.path, linkPath)).catch(() => undefined);
|
|
592
|
+
if (stat?.isSymbolicLink())
|
|
593
|
+
linkedPaths.push(linkPath);
|
|
594
|
+
}
|
|
595
|
+
const ignoredChangedPaths = uniqueWorkspacePaths(input.ignoredChangedPaths ?? []);
|
|
596
|
+
return {
|
|
597
|
+
kind: FRONTIER_SWARM_CODEX_WORKSPACE_PROOF_KIND,
|
|
598
|
+
version: FRONTIER_SWARM_CODEX_WORKSPACE_PROOF_VERSION,
|
|
599
|
+
id: 'codex-workspace-proof:' + stableHash([manifest.id, copiedPaths, linkedPaths, missingRequired, missingOptional, generatedAt]),
|
|
600
|
+
generatedAt,
|
|
601
|
+
manifest,
|
|
602
|
+
copiedPaths: uniqueWorkspacePaths(copiedPaths),
|
|
603
|
+
linkedPaths,
|
|
604
|
+
missingRequired,
|
|
605
|
+
missingOptional,
|
|
606
|
+
ignoredChangedPaths,
|
|
607
|
+
summary: {
|
|
608
|
+
copiedCount: uniqueWorkspacePaths(copiedPaths).length,
|
|
609
|
+
linkedCount: linkedPaths.length,
|
|
610
|
+
missingRequiredCount: missingRequired.length,
|
|
611
|
+
missingOptionalCount: missingOptional.length,
|
|
612
|
+
ignoredChangedPathCount: ignoredChangedPaths.length
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
export async function initFileSwarmEventStream(stream) {
|
|
617
|
+
if (!stream)
|
|
618
|
+
return;
|
|
619
|
+
const mailboxes = [stream.global, ...Object.values(stream.lanes)];
|
|
620
|
+
await Promise.all(mailboxes.map(async (mailbox) => {
|
|
621
|
+
if (!mailbox.path)
|
|
622
|
+
return;
|
|
623
|
+
await fs.mkdir(path.dirname(mailbox.path), { recursive: true });
|
|
624
|
+
await fs.writeFile(mailbox.path, '');
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
export async function appendFileSwarmEvent(stream, event) {
|
|
628
|
+
if (!stream)
|
|
629
|
+
return;
|
|
630
|
+
const line = JSON.stringify({ at: Date.now(), ...event }) + '\n';
|
|
631
|
+
const paths = routeSwarmEventToMailboxes(stream, event)
|
|
632
|
+
.map((mailbox) => mailbox.path)
|
|
633
|
+
.filter((mailboxPath) => !!mailboxPath);
|
|
634
|
+
await Promise.all(paths.map(async (mailboxPath) => {
|
|
635
|
+
await fs.mkdir(path.dirname(mailboxPath), { recursive: true });
|
|
636
|
+
await fs.appendFile(mailboxPath, line);
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
export async function writeSwarmCoordinatorSnapshot(file, input) {
|
|
640
|
+
const byLane = input.run.jobs.reduce((acc, job) => {
|
|
641
|
+
const current = acc[job.lane] ?? { total: 0, completed: 0, failed: 0, blocked: 0 };
|
|
642
|
+
current.total += 1;
|
|
643
|
+
const result = input.run.results.find((entry) => entry.jobId === job.id);
|
|
644
|
+
if (result?.status === 'completed' || result?.status === 'verified')
|
|
645
|
+
current.completed += 1;
|
|
646
|
+
else if (result?.status === 'failed')
|
|
647
|
+
current.failed += 1;
|
|
648
|
+
else if (result?.status === 'blocked')
|
|
649
|
+
current.blocked += 1;
|
|
650
|
+
acc[job.lane] = current;
|
|
651
|
+
return acc;
|
|
652
|
+
}, {});
|
|
653
|
+
const mergeReadiness = input.run.results.reduce((acc, result) => {
|
|
654
|
+
acc[result.mergeReadiness] = (acc[result.mergeReadiness] ?? 0) + 1;
|
|
655
|
+
return acc;
|
|
656
|
+
}, {});
|
|
657
|
+
const dashboard = {
|
|
658
|
+
kind: 'frontier.swarm-codex.coordinator-dashboard',
|
|
659
|
+
version: 1,
|
|
660
|
+
generatedAt: new Date().toISOString(),
|
|
661
|
+
ok: input.ok,
|
|
662
|
+
outDir: input.outDir,
|
|
663
|
+
runId: input.run.id,
|
|
664
|
+
planId: input.plan.id,
|
|
665
|
+
summary: input.run.summary,
|
|
666
|
+
byLane,
|
|
667
|
+
mergeReadiness,
|
|
668
|
+
eventStream: input.eventStream ?? null,
|
|
669
|
+
pidManifestPath: input.pidManifestPath ?? null,
|
|
670
|
+
proof: input.proof
|
|
671
|
+
};
|
|
672
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
673
|
+
await fs.writeFile(file, JSON.stringify(dashboard, null, 2) + '\n');
|
|
674
|
+
}
|
|
675
|
+
export async function appendCodexPidManifest(file, entry, runId) {
|
|
676
|
+
const absolute = path.resolve(file);
|
|
677
|
+
const previous = pidManifestWriteQueues.get(absolute) ?? Promise.resolve();
|
|
678
|
+
let next;
|
|
679
|
+
next = previous
|
|
680
|
+
.catch(() => { })
|
|
681
|
+
.then(() => appendCodexPidManifestUnlocked(absolute, entry, runId))
|
|
682
|
+
.finally(() => {
|
|
683
|
+
if (pidManifestWriteQueues.get(absolute) === next)
|
|
684
|
+
pidManifestWriteQueues.delete(absolute);
|
|
685
|
+
});
|
|
686
|
+
pidManifestWriteQueues.set(absolute, next);
|
|
687
|
+
return next;
|
|
688
|
+
}
|
|
689
|
+
async function appendCodexPidManifestUnlocked(file, entry, runId) {
|
|
690
|
+
const manifest = await readCodexPidManifest(file).catch(() => ({
|
|
691
|
+
kind: FRONTIER_SWARM_CODEX_PID_MANIFEST_KIND,
|
|
692
|
+
version: FRONTIER_SWARM_CODEX_PID_MANIFEST_VERSION,
|
|
693
|
+
...(runId ? { runId } : {}),
|
|
694
|
+
entries: []
|
|
695
|
+
}));
|
|
696
|
+
const entries = manifest.entries.filter((existing) => existing.pid !== entry.pid || existing.jobId !== entry.jobId);
|
|
697
|
+
entries.push(entry);
|
|
698
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
699
|
+
await writeJsonAtomic(file, { ...manifest, ...(runId ? { runId } : {}), entries });
|
|
700
|
+
}
|
|
701
|
+
export async function readCodexPidManifest(file) {
|
|
702
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
703
|
+
}
|
|
704
|
+
export async function stopCodexSwarmRun(input) {
|
|
705
|
+
const signal = input.signal ?? 'SIGTERM';
|
|
706
|
+
const pidManifestPath = await resolvePidManifestPath(input.run);
|
|
707
|
+
const manifest = await readCodexPidManifest(pidManifestPath);
|
|
708
|
+
const stopped = [];
|
|
709
|
+
const missing = [];
|
|
710
|
+
const errors = [];
|
|
711
|
+
for (const entry of manifest.entries.filter((item) => item.pid !== process.pid).sort((left, right) => right.startedAt - left.startedAt)) {
|
|
712
|
+
try {
|
|
713
|
+
process.kill(entry.pid, signal);
|
|
714
|
+
stopped.push(entry.pid);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : '';
|
|
718
|
+
if (code === 'ESRCH')
|
|
719
|
+
missing.push(entry.pid);
|
|
720
|
+
else
|
|
721
|
+
errors.push({ pid: entry.pid, error: error instanceof Error ? error.message : String(error) });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return { ok: errors.length === 0, pidManifestPath, signal, stopped, missing, errors };
|
|
725
|
+
}
|
|
726
|
+
export async function collectCodexSwarmRun(input) {
|
|
727
|
+
const generatedAt = Date.now();
|
|
728
|
+
const cwd = path.resolve(input.cwd ?? process.cwd());
|
|
729
|
+
const runDir = await resolveRunDirectory(input.run);
|
|
730
|
+
const outDir = path.resolve(cwd, input.outDir ?? path.join(runDir, 'collected'));
|
|
731
|
+
const buckets = {
|
|
732
|
+
'ready-to-apply': [],
|
|
733
|
+
'needs-human-port': [],
|
|
734
|
+
'failed-evidence': [],
|
|
735
|
+
'stale-against-head': []
|
|
736
|
+
};
|
|
737
|
+
const collectedBundles = [];
|
|
738
|
+
const patchStatuses = {};
|
|
739
|
+
const mergePaths = (await findFilesByName(runDir, 'merge.json'))
|
|
740
|
+
.filter((mergePath) => !pathHasIgnoredSegment(path.relative(runDir, mergePath), [
|
|
741
|
+
'collected',
|
|
742
|
+
'patch-scores',
|
|
743
|
+
'ready-to-apply',
|
|
744
|
+
'needs-human-port',
|
|
745
|
+
'failed-evidence',
|
|
746
|
+
'stale-against-head'
|
|
747
|
+
]));
|
|
748
|
+
const mergeRecordsByJob = new Map();
|
|
749
|
+
for (const mergePath of mergePaths.sort()) {
|
|
750
|
+
const bundle = normalizeCollectedMergeBundle(JSON.parse(await fs.readFile(mergePath, 'utf8')), mergePath);
|
|
751
|
+
const existing = mergeRecordsByJob.get(bundle.jobId);
|
|
752
|
+
const next = { mergePath, bundle };
|
|
753
|
+
if (!existing || mergeRecordScore(next) > mergeRecordScore(existing))
|
|
754
|
+
mergeRecordsByJob.set(bundle.jobId, next);
|
|
755
|
+
}
|
|
756
|
+
const mergeRecords = Array.from(mergeRecordsByJob.values()).sort((left, right) => left.bundle.jobId.localeCompare(right.bundle.jobId));
|
|
757
|
+
for (const { mergePath, bundle } of mergeRecords) {
|
|
758
|
+
const patchPath = resolveBundlePatchPath(bundle, mergePath);
|
|
759
|
+
const patchExists = !!patchPath && await pathExists(patchPath);
|
|
760
|
+
const staleAgainstHead = input.checkStale === false ? false : await bundlePatchIsStale(bundle, mergePath, cwd);
|
|
761
|
+
const bucket = classifyCodexCollectBucket(bundle, staleAgainstHead);
|
|
762
|
+
const branchName = input.branchPrefix ? `${input.branchPrefix}/${slug(bundle.jobId)}` : bundle.branchName;
|
|
763
|
+
const nextBundle = {
|
|
764
|
+
...bundle,
|
|
765
|
+
...(branchName ? { branchName } : {}),
|
|
766
|
+
staleAgainstHead: bundle.staleAgainstHead || staleAgainstHead,
|
|
767
|
+
disposition: staleAgainstHead ? 'stale-against-head' : bundle.disposition,
|
|
768
|
+
autoMergeable: bucket === 'ready-to-apply' && bundle.autoMergeable
|
|
769
|
+
};
|
|
770
|
+
collectedBundles.push(nextBundle);
|
|
771
|
+
patchStatuses[nextBundle.jobId] = staleAgainstHead ? 'stale' : patchExists ? input.checkStale === false ? 'unknown' : 'applies' : 'missing';
|
|
772
|
+
const outputDir = path.join(outDir, bucket, slug(bundle.jobId));
|
|
773
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
774
|
+
await fs.writeFile(path.join(outputDir, 'merge.json'), JSON.stringify(nextBundle, null, 2) + '\n');
|
|
775
|
+
if (patchPath && await pathExists(patchPath))
|
|
776
|
+
await fs.copyFile(patchPath, path.join(outputDir, 'changes.patch')).catch(() => { });
|
|
777
|
+
buckets[bucket].push({ bucket, jobId: bundle.jobId, mergePath, outputDir, bundle: nextBundle });
|
|
778
|
+
}
|
|
779
|
+
const mergeIndex = createSwarmMergeIndex({
|
|
780
|
+
runId: path.basename(runDir),
|
|
781
|
+
bundles: collectedBundles,
|
|
782
|
+
patchStatuses
|
|
783
|
+
});
|
|
784
|
+
const queueOverlay = createSwarmQueueOverlay({
|
|
785
|
+
runId: path.basename(runDir),
|
|
786
|
+
bundles: collectedBundles
|
|
787
|
+
});
|
|
788
|
+
const summary = {
|
|
789
|
+
total: mergeRecords.length,
|
|
790
|
+
'ready-to-apply': buckets['ready-to-apply'].length,
|
|
791
|
+
'needs-human-port': buckets['needs-human-port'].length,
|
|
792
|
+
'failed-evidence': buckets['failed-evidence'].length,
|
|
793
|
+
'stale-against-head': buckets['stale-against-head'].length
|
|
794
|
+
};
|
|
795
|
+
const result = {
|
|
796
|
+
kind: FRONTIER_SWARM_CODEX_COLLECTION_KIND,
|
|
797
|
+
version: FRONTIER_SWARM_CODEX_COLLECTION_VERSION,
|
|
798
|
+
ok: summary['failed-evidence'] === 0 && summary['stale-against-head'] === 0,
|
|
799
|
+
runDir,
|
|
800
|
+
outDir,
|
|
801
|
+
generatedAt,
|
|
802
|
+
buckets,
|
|
803
|
+
mergeIndex,
|
|
804
|
+
queueOverlay,
|
|
805
|
+
summary
|
|
806
|
+
};
|
|
807
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
808
|
+
await fs.writeFile(path.join(outDir, 'collection.json'), JSON.stringify(result, null, 2) + '\n');
|
|
809
|
+
await fs.writeFile(path.join(outDir, 'merge-index.json'), JSON.stringify(mergeIndex, null, 2) + '\n');
|
|
810
|
+
await fs.writeFile(path.join(outDir, 'queue-overlay.json'), JSON.stringify(queueOverlay, null, 2) + '\n');
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
export async function applyCodexSwarmCollection(input) {
|
|
814
|
+
const generatedAt = Date.now();
|
|
815
|
+
const cwd = path.resolve(input.cwd ?? process.cwd());
|
|
816
|
+
const dryRun = input.dryRun ?? true;
|
|
817
|
+
if (!input.collection && !input.run)
|
|
818
|
+
throw new Error('apply requires --collection <dir> or --run <run-dir>');
|
|
819
|
+
const collectionDir = input.collection
|
|
820
|
+
? path.resolve(cwd, input.collection)
|
|
821
|
+
: (await collectCodexSwarmRun({ run: String(input.run ?? ''), cwd, outDir: input.outDir })).outDir;
|
|
822
|
+
const outDir = path.resolve(cwd, input.outDir ?? path.join(collectionDir, 'apply-ledger'));
|
|
823
|
+
if (!dryRun && !input.allowDirty) {
|
|
824
|
+
const dirty = await gitDirty(cwd);
|
|
825
|
+
if (dirty.length)
|
|
826
|
+
throw new Error(`refusing to apply into dirty worktree; pass allowDirty to override (${dirty.slice(0, 8).join(', ')})`);
|
|
827
|
+
}
|
|
828
|
+
const bucket = input.bucket ?? 'ready-to-apply';
|
|
829
|
+
const roots = bucket === 'all'
|
|
830
|
+
? ['ready-to-apply', 'needs-human-port', 'failed-evidence', 'stale-against-head'].map((entry) => path.join(collectionDir, entry))
|
|
831
|
+
: [path.join(collectionDir, bucket)];
|
|
832
|
+
const wanted = new Set(input.jobIds ?? []);
|
|
833
|
+
const mergePaths = (await Promise.all(roots.map((root) => findFilesByName(root, 'merge.json')))).flat().sort();
|
|
834
|
+
const entries = [];
|
|
835
|
+
for (const mergePath of mergePaths.slice(0, input.limit ? Math.max(0, Math.floor(input.limit)) : undefined)) {
|
|
836
|
+
const bundle = JSON.parse(await fs.readFile(mergePath, 'utf8'));
|
|
837
|
+
if (wanted.size && !wanted.has(bundle.jobId))
|
|
838
|
+
continue;
|
|
839
|
+
entries.push(await applyCodexMergeBundle({
|
|
840
|
+
cwd,
|
|
841
|
+
bundle,
|
|
842
|
+
mergePath,
|
|
843
|
+
dryRun,
|
|
844
|
+
commit: input.commit ?? false,
|
|
845
|
+
branchPrefix: input.branchPrefix
|
|
846
|
+
}));
|
|
847
|
+
}
|
|
848
|
+
const summary = {
|
|
849
|
+
total: entries.length,
|
|
850
|
+
checked: entries.filter((entry) => entry.status === 'checked').length,
|
|
851
|
+
applied: entries.filter((entry) => entry.status === 'applied').length,
|
|
852
|
+
committed: entries.filter((entry) => entry.status === 'committed').length,
|
|
853
|
+
skipped: entries.filter((entry) => entry.status === 'skipped').length,
|
|
854
|
+
failed: entries.filter((entry) => entry.status === 'failed').length
|
|
855
|
+
};
|
|
856
|
+
const result = {
|
|
857
|
+
kind: FRONTIER_SWARM_CODEX_APPLY_LEDGER_KIND,
|
|
858
|
+
version: FRONTIER_SWARM_CODEX_APPLY_LEDGER_VERSION,
|
|
859
|
+
ok: summary.failed === 0,
|
|
860
|
+
cwd,
|
|
861
|
+
collectionDir,
|
|
862
|
+
outDir,
|
|
863
|
+
generatedAt,
|
|
864
|
+
dryRun,
|
|
865
|
+
entries,
|
|
866
|
+
summary
|
|
867
|
+
};
|
|
868
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
869
|
+
await fs.writeFile(path.join(outDir, 'apply-ledger.json'), JSON.stringify(result, null, 2) + '\n');
|
|
870
|
+
return result;
|
|
871
|
+
}
|
|
872
|
+
export async function scoreCodexSwarmPatches(input) {
|
|
873
|
+
const generatedAt = Date.now();
|
|
874
|
+
const cwd = path.resolve(input.cwd ?? process.cwd());
|
|
875
|
+
if (!input.collection && !input.run)
|
|
876
|
+
throw new Error('score requires --collection <dir> or --run <run-dir>');
|
|
877
|
+
const collectionDir = input.collection
|
|
878
|
+
? path.resolve(cwd, input.collection)
|
|
879
|
+
: (await collectCodexSwarmRun({ run: String(input.run ?? ''), cwd, outDir: input.outDir })).outDir;
|
|
880
|
+
const outDir = path.resolve(cwd, input.outDir ?? path.join(collectionDir, 'patch-scores'));
|
|
881
|
+
const bucket = input.bucket ?? 'all';
|
|
882
|
+
const roots = bucket === 'all'
|
|
883
|
+
? ['ready-to-apply', 'needs-human-port', 'failed-evidence', 'stale-against-head'].map((entry) => path.join(collectionDir, entry))
|
|
884
|
+
: [path.join(collectionDir, bucket)];
|
|
885
|
+
const wanted = new Set(input.jobIds ?? []);
|
|
886
|
+
const mergePaths = (await Promise.all(roots.map((root) => findFilesByName(root, 'merge.json')))).flat().sort();
|
|
887
|
+
const entries = [];
|
|
888
|
+
for (const mergePath of mergePaths.slice(0, input.limit ? Math.max(0, Math.floor(input.limit)) : undefined)) {
|
|
889
|
+
const bundle = JSON.parse(await fs.readFile(mergePath, 'utf8'));
|
|
890
|
+
if (wanted.size && !wanted.has(bundle.jobId))
|
|
891
|
+
continue;
|
|
892
|
+
entries.push(await scoreCodexMergeBundle({ cwd, mergePath, bundle, outDir, input }));
|
|
893
|
+
}
|
|
894
|
+
const statuses = ['accepted-clean', 'accepted-needs-port', 'conflict', 'test-fail', 'stale', 'evidence-only'];
|
|
895
|
+
const summary = Object.fromEntries(statuses.map((status) => [status, entries.filter((entry) => entry.status === status).length]));
|
|
896
|
+
const result = {
|
|
897
|
+
kind: FRONTIER_SWARM_CODEX_PATCH_SCORE_KIND,
|
|
898
|
+
version: FRONTIER_SWARM_CODEX_PATCH_SCORE_VERSION,
|
|
899
|
+
ok: entries.every((entry) => entry.status === 'accepted-clean' || entry.status === 'accepted-needs-port' || entry.status === 'evidence-only'),
|
|
900
|
+
cwd,
|
|
901
|
+
collectionDir,
|
|
902
|
+
outDir,
|
|
903
|
+
generatedAt,
|
|
904
|
+
entries: entries.sort((left, right) => right.score - left.score || left.jobId.localeCompare(right.jobId)),
|
|
905
|
+
summary: { ...summary, total: entries.length }
|
|
906
|
+
};
|
|
907
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
908
|
+
await fs.writeFile(path.join(outDir, 'patch-score.json'), JSON.stringify(result, null, 2) + '\n');
|
|
909
|
+
return result;
|
|
910
|
+
}
|
|
911
|
+
async function applyCodexMergeBundle(input) {
|
|
912
|
+
const commands = [];
|
|
913
|
+
const patchPath = await resolveApplyPatchPath(input.bundle, input.mergePath);
|
|
914
|
+
const branchName = input.branchPrefix ? `${input.branchPrefix}/${slug(input.bundle.jobId)}` : input.bundle.branchName;
|
|
915
|
+
const base = {
|
|
916
|
+
jobId: input.bundle.jobId,
|
|
917
|
+
bundlePath: input.mergePath,
|
|
918
|
+
...(patchPath ? { patchPath } : {}),
|
|
919
|
+
...(branchName ? { branchName } : {}),
|
|
920
|
+
dryRun: input.dryRun,
|
|
921
|
+
commands
|
|
922
|
+
};
|
|
923
|
+
if (!patchPath) {
|
|
924
|
+
return {
|
|
925
|
+
...base,
|
|
926
|
+
status: input.bundle.disposition === 'discovery-only' ? 'skipped' : 'failed',
|
|
927
|
+
error: 'missing patch'
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const check = await runLoggedProcess('git', ['apply', '--check', patchPath], input.cwd);
|
|
931
|
+
commands.push(check);
|
|
932
|
+
if (check.status !== 0)
|
|
933
|
+
return { ...base, status: 'failed', error: 'git apply --check failed' };
|
|
934
|
+
if (input.dryRun)
|
|
935
|
+
return { ...base, status: 'checked' };
|
|
936
|
+
if (branchName) {
|
|
937
|
+
const branch = await runLoggedProcess('git', ['switch', '-c', branchName], input.cwd);
|
|
938
|
+
commands.push(branch);
|
|
939
|
+
if (branch.status !== 0)
|
|
940
|
+
return { ...base, status: 'failed', error: 'git switch -c failed' };
|
|
941
|
+
}
|
|
942
|
+
const apply = await runLoggedProcess('git', ['apply', patchPath], input.cwd);
|
|
943
|
+
commands.push(apply);
|
|
944
|
+
if (apply.status !== 0)
|
|
945
|
+
return { ...base, status: 'failed', error: 'git apply failed' };
|
|
946
|
+
if (!input.commit)
|
|
947
|
+
return { ...base, status: 'applied' };
|
|
948
|
+
const add = await runLoggedProcess('git', ['add', '--', ...input.bundle.changedPaths], input.cwd);
|
|
949
|
+
commands.push(add);
|
|
950
|
+
if (add.status !== 0)
|
|
951
|
+
return { ...base, status: 'failed', error: 'git add failed' };
|
|
952
|
+
const commit = await runLoggedProcess('git', ['commit', '-m', `Apply swarm bundle ${input.bundle.jobId}`], input.cwd);
|
|
953
|
+
commands.push(commit);
|
|
954
|
+
if (commit.status !== 0)
|
|
955
|
+
return { ...base, status: 'failed', error: 'git commit failed' };
|
|
956
|
+
const rev = await runLoggedProcess('git', ['rev-parse', 'HEAD'], input.cwd);
|
|
957
|
+
commands.push(rev);
|
|
958
|
+
return {
|
|
959
|
+
...base,
|
|
960
|
+
status: 'committed',
|
|
961
|
+
commit: rev.stdoutTail[0]
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
async function scoreCodexMergeBundle(input) {
|
|
965
|
+
const commands = [];
|
|
966
|
+
const patchPath = await resolveApplyPatchPath(input.bundle, input.mergePath);
|
|
967
|
+
const base = {
|
|
968
|
+
jobId: input.bundle.jobId,
|
|
969
|
+
bundlePath: input.mergePath,
|
|
970
|
+
...(patchPath ? { patchPath } : {}),
|
|
971
|
+
changedPaths: [...input.bundle.changedPaths],
|
|
972
|
+
commands
|
|
973
|
+
};
|
|
974
|
+
if (!patchPath || input.bundle.disposition === 'discovery-only' || input.bundle.changedPaths.length === 0) {
|
|
975
|
+
return { ...base, status: 'evidence-only', score: 20, reasons: ['no patch to apply'] };
|
|
976
|
+
}
|
|
977
|
+
if (input.bundle.staleAgainstHead || input.bundle.disposition === 'stale-against-head') {
|
|
978
|
+
return { ...base, status: 'stale', score: 0, reasons: ['stale-against-head'] };
|
|
979
|
+
}
|
|
980
|
+
const workspacePath = await createScoreWorkspace(input.cwd, input.bundle.jobId, input.input);
|
|
981
|
+
try {
|
|
982
|
+
const check = await runLoggedProcess('git', ['apply', '--check', patchPath], workspacePath);
|
|
983
|
+
commands.push(check);
|
|
984
|
+
if (check.status !== 0)
|
|
985
|
+
return { ...base, workspacePath, status: 'conflict', score: 0, reasons: ['git apply --check failed'] };
|
|
986
|
+
const apply = await runLoggedProcess('git', ['apply', patchPath], workspacePath);
|
|
987
|
+
commands.push(apply);
|
|
988
|
+
if (apply.status !== 0)
|
|
989
|
+
return { ...base, workspacePath, status: 'conflict', score: 0, reasons: ['git apply failed'] };
|
|
990
|
+
const gates = scoreCommands(input.bundle, input.input);
|
|
991
|
+
for (const gate of gates) {
|
|
992
|
+
const run = await runLoggedProcess(gate.command, gate.args, gate.cwd ? path.resolve(workspacePath, gate.cwd) : workspacePath);
|
|
993
|
+
commands.push(run);
|
|
994
|
+
if (run.status !== 0 && gate.required !== false) {
|
|
995
|
+
return { ...base, workspacePath, status: 'test-fail', score: 10, reasons: [`gate failed: ${gate.name}`] };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const clean = input.bundle.disposition === 'auto-mergeable' && input.bundle.autoMergeable;
|
|
999
|
+
return {
|
|
1000
|
+
...base,
|
|
1001
|
+
workspacePath,
|
|
1002
|
+
status: clean ? 'accepted-clean' : 'accepted-needs-port',
|
|
1003
|
+
score: clean ? 100 : 70,
|
|
1004
|
+
reasons: clean ? [] : ['patch applies but bundle is not auto-mergeable']
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
if (!input.input.keepWorkspaces)
|
|
1009
|
+
await fs.rm(workspacePath, { recursive: true, force: true }).catch(() => { });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function createScoreWorkspace(cwd, jobId, input) {
|
|
1013
|
+
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), `frontier-swarm-score-${slug(jobId)}-`));
|
|
1014
|
+
const excludes = uniqueWorkspacePaths([
|
|
1015
|
+
'.git',
|
|
1016
|
+
'node_modules',
|
|
1017
|
+
'dist',
|
|
1018
|
+
'coverage',
|
|
1019
|
+
'agent-runs',
|
|
1020
|
+
'.frontier-framework',
|
|
1021
|
+
...(input.workspaceExcludes ?? [])
|
|
1022
|
+
]);
|
|
1023
|
+
const includes = uniqueWorkspacePaths(input.workspaceIncludes ?? []);
|
|
1024
|
+
if (includes.length) {
|
|
1025
|
+
for (const include of includes)
|
|
1026
|
+
await copyWorkspacePath(cwd, workspacePath, include, excludes);
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
await fs.cp(cwd, workspacePath, {
|
|
1030
|
+
recursive: true,
|
|
1031
|
+
force: true,
|
|
1032
|
+
filter: (source) => {
|
|
1033
|
+
if (source === cwd)
|
|
1034
|
+
return true;
|
|
1035
|
+
const relative = path.relative(cwd, source).replace(/\\/g, '/');
|
|
1036
|
+
if (!relative)
|
|
1037
|
+
return true;
|
|
1038
|
+
if (pathHasIgnoredSegment(relative, excludes))
|
|
1039
|
+
return false;
|
|
1040
|
+
return !excludes.some((entry) => relative === entry || relative.startsWith(entry.replace(/\/$/, '') + '/'));
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return workspacePath;
|
|
1045
|
+
}
|
|
1046
|
+
function scoreCommands(bundle, input) {
|
|
1047
|
+
const focused = normalizeScoreCommands(input.focusedCommands ?? []);
|
|
1048
|
+
const global = bundle.changedPaths.some((file) => (input.globalGlobs ?? []).some((glob) => matchesGlob(file, glob)))
|
|
1049
|
+
? normalizeScoreCommands(input.globalCommands ?? [])
|
|
1050
|
+
: [];
|
|
1051
|
+
return [...focused, ...global];
|
|
1052
|
+
}
|
|
1053
|
+
function normalizeScoreCommands(input) {
|
|
1054
|
+
return input.map((entry) => {
|
|
1055
|
+
if (typeof entry === 'string')
|
|
1056
|
+
return { name: entry, command: 'sh', args: ['-c', entry], required: true };
|
|
1057
|
+
return {
|
|
1058
|
+
name: entry.name,
|
|
1059
|
+
command: entry.command,
|
|
1060
|
+
args: [...entry.args],
|
|
1061
|
+
required: entry.required,
|
|
1062
|
+
...(entry.cwd ? { cwd: entry.cwd } : {}),
|
|
1063
|
+
...(entry.metadata ? { metadata: entry.metadata } : {})
|
|
1064
|
+
};
|
|
1065
|
+
}).filter((entry) => entry.command.length > 0);
|
|
1066
|
+
}
|
|
1067
|
+
async function resolveApplyPatchPath(bundle, mergePath) {
|
|
1068
|
+
const sibling = path.join(path.dirname(mergePath), 'changes.patch');
|
|
1069
|
+
if (await pathExists(sibling))
|
|
1070
|
+
return sibling;
|
|
1071
|
+
const patchPath = resolveBundlePatchPath(bundle, mergePath);
|
|
1072
|
+
if (patchPath && await pathExists(patchPath))
|
|
1073
|
+
return patchPath;
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
async function runLoggedProcess(command, args, cwd) {
|
|
1077
|
+
const result = await runProcess(command, args, { cwd, allowFailure: true });
|
|
1078
|
+
return {
|
|
1079
|
+
command: [command, ...args],
|
|
1080
|
+
status: result.status,
|
|
1081
|
+
stdoutTail: tail(result.stdout),
|
|
1082
|
+
stderrTail: tail(result.stderr)
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
async function writeJsonAtomic(file, value) {
|
|
1086
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
1087
|
+
await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
1088
|
+
await fs.rename(tmp, file);
|
|
1089
|
+
}
|
|
1090
|
+
async function gitDirty(cwd) {
|
|
1091
|
+
const result = await runProcess('git', ['status', '--porcelain'], { cwd, allowFailure: true });
|
|
1092
|
+
if (result.status !== 0)
|
|
1093
|
+
return [];
|
|
1094
|
+
return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3));
|
|
1095
|
+
}
|
|
357
1096
|
async function copyWorkspacePath(cwd, workspacePath, include, excludes) {
|
|
358
1097
|
const relative = normalizeWorkspacePath(include);
|
|
359
1098
|
if (!relative)
|
|
@@ -392,6 +1131,12 @@ function shouldSkipGitRepoCheck(input) {
|
|
|
392
1131
|
return workspace.skipGitRepoCheck;
|
|
393
1132
|
return workspace.mode === 'copy' || workspace.mode === 'snapshot';
|
|
394
1133
|
}
|
|
1134
|
+
function assertGeneratedWorkspacePath(plan) {
|
|
1135
|
+
const relative = path.relative(plan.guardRoot ?? plan.root, plan.path);
|
|
1136
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || relative === '') {
|
|
1137
|
+
throw new Error(`Refusing to replace workspace outside generated root: ${plan.path}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
395
1140
|
function readRawTask(job) {
|
|
396
1141
|
const metadata = isObject(job.task.metadata) ? job.task.metadata : {};
|
|
397
1142
|
return isObject(metadata.source) ? metadata.source : {};
|
|
@@ -426,12 +1171,77 @@ async function gitChangedPaths(cwd) {
|
|
|
426
1171
|
return value.includes(' -> ') ? value.split(' -> ') : [value];
|
|
427
1172
|
});
|
|
428
1173
|
}
|
|
429
|
-
async function collectChangedPaths(cwd, baseline) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return gitPaths;
|
|
1174
|
+
async function collectChangedPaths(cwd, baseline, plan) {
|
|
1175
|
+
if (!baseline)
|
|
1176
|
+
return filterWorkspaceChangedPaths(await gitChangedPaths(cwd), plan);
|
|
433
1177
|
const after = await snapshotWorkspaceFiles(cwd);
|
|
434
|
-
return diffWorkspaceFiles(baseline, after);
|
|
1178
|
+
return filterWorkspaceChangedPaths(diffWorkspaceFiles(baseline, after), plan);
|
|
1179
|
+
}
|
|
1180
|
+
async function writeCodexPatchFile(input) {
|
|
1181
|
+
await fs.mkdir(path.dirname(input.paths.patchPath), { recursive: true });
|
|
1182
|
+
const changedPaths = uniqueWorkspacePaths(input.changedPaths);
|
|
1183
|
+
if (changedPaths.length === 0) {
|
|
1184
|
+
await fs.writeFile(input.paths.patchPath, '');
|
|
1185
|
+
return undefined;
|
|
1186
|
+
}
|
|
1187
|
+
const diff = input.workspacePlan.mode === 'current' || input.workspacePlan.mode === 'git-worktree'
|
|
1188
|
+
? await gitDiffPatch(input.workspace, changedPaths)
|
|
1189
|
+
: await noIndexWorkspacePatch(input.sourceRoot, input.workspace, changedPaths);
|
|
1190
|
+
await fs.writeFile(input.paths.patchPath, diff);
|
|
1191
|
+
return diff.trim().length ? input.paths.patchPath : undefined;
|
|
1192
|
+
}
|
|
1193
|
+
async function gitDiffPatch(workspace, changedPaths) {
|
|
1194
|
+
const result = await runProcess('git', ['diff', '--', ...changedPaths], { cwd: workspace, allowFailure: true });
|
|
1195
|
+
return result.stdout;
|
|
1196
|
+
}
|
|
1197
|
+
async function noIndexWorkspacePatch(sourceRoot, workspace, changedPaths) {
|
|
1198
|
+
const chunks = [];
|
|
1199
|
+
for (const file of changedPaths) {
|
|
1200
|
+
const source = path.join(sourceRoot, file);
|
|
1201
|
+
const target = path.join(workspace, file);
|
|
1202
|
+
const sourceExists = await pathExists(source);
|
|
1203
|
+
const targetExists = await pathExists(target);
|
|
1204
|
+
if (!sourceExists && !targetExists)
|
|
1205
|
+
continue;
|
|
1206
|
+
const left = sourceExists ? source : '/dev/null';
|
|
1207
|
+
const right = targetExists ? target : '/dev/null';
|
|
1208
|
+
const result = await runProcess('git', ['diff', '--no-index', '--', left, right], { cwd: sourceRoot, allowFailure: true });
|
|
1209
|
+
if (result.stdout.trim())
|
|
1210
|
+
chunks.push(result.stdout);
|
|
1211
|
+
}
|
|
1212
|
+
return chunks.join('\n');
|
|
1213
|
+
}
|
|
1214
|
+
function filterWorkspaceChangedPaths(paths, plan) {
|
|
1215
|
+
const changedPaths = [];
|
|
1216
|
+
const ignoredChangedPaths = [];
|
|
1217
|
+
for (const file of uniqueWorkspacePaths(paths)) {
|
|
1218
|
+
if (isIgnoredWorkspaceChangedPath(file, plan))
|
|
1219
|
+
ignoredChangedPaths.push(file);
|
|
1220
|
+
else
|
|
1221
|
+
changedPaths.push(file);
|
|
1222
|
+
}
|
|
1223
|
+
return { changedPaths, ignoredChangedPaths };
|
|
1224
|
+
}
|
|
1225
|
+
function isIgnoredWorkspaceChangedPath(file, plan) {
|
|
1226
|
+
if (plan.mode !== 'copy' && plan.mode !== 'snapshot')
|
|
1227
|
+
return false;
|
|
1228
|
+
if (pathHasIgnoredSegment(file, ['node_modules', 'dist', 'coverage', '.frontier-framework', 'agent-runs']))
|
|
1229
|
+
return true;
|
|
1230
|
+
const ignored = [
|
|
1231
|
+
...plan.excludes,
|
|
1232
|
+
...plan.artifactIncludes,
|
|
1233
|
+
...plan.linkPaths,
|
|
1234
|
+
...(plan.linkNodeModules ? ['node_modules'] : []),
|
|
1235
|
+
'agent-runs',
|
|
1236
|
+
'.frontier-framework',
|
|
1237
|
+
'dist',
|
|
1238
|
+
'coverage'
|
|
1239
|
+
];
|
|
1240
|
+
return ignored.some((entry) => file === entry || file.startsWith(entry.replace(/\/$/, '') + '/'));
|
|
1241
|
+
}
|
|
1242
|
+
function pathHasIgnoredSegment(file, segments) {
|
|
1243
|
+
const parts = file.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
1244
|
+
return parts.some((part) => segments.includes(part));
|
|
435
1245
|
}
|
|
436
1246
|
async function snapshotWorkspaceFiles(root) {
|
|
437
1247
|
const snapshot = new Map();
|
|
@@ -625,6 +1435,122 @@ async function pathExists(file) {
|
|
|
625
1435
|
return false;
|
|
626
1436
|
}
|
|
627
1437
|
}
|
|
1438
|
+
async function resolvePidManifestPath(runPath) {
|
|
1439
|
+
const absolute = path.resolve(runPath);
|
|
1440
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1441
|
+
if (stat?.isDirectory())
|
|
1442
|
+
return path.join(absolute, 'pids.json');
|
|
1443
|
+
if (path.basename(absolute) === 'swarm-results.json')
|
|
1444
|
+
return path.join(path.dirname(absolute), 'pids.json');
|
|
1445
|
+
return absolute;
|
|
1446
|
+
}
|
|
1447
|
+
async function resolveRunDirectory(runPath) {
|
|
1448
|
+
const absolute = path.resolve(runPath);
|
|
1449
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1450
|
+
if (stat?.isDirectory())
|
|
1451
|
+
return absolute;
|
|
1452
|
+
if (path.basename(absolute) === 'swarm-results.json' || path.basename(absolute) === 'pids.json')
|
|
1453
|
+
return path.dirname(absolute);
|
|
1454
|
+
return path.dirname(absolute);
|
|
1455
|
+
}
|
|
1456
|
+
async function findFilesByName(root, name) {
|
|
1457
|
+
const out = [];
|
|
1458
|
+
async function walk(current) {
|
|
1459
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1460
|
+
for (const entry of entries) {
|
|
1461
|
+
const absolute = path.join(current, entry.name);
|
|
1462
|
+
if (entry.isDirectory()) {
|
|
1463
|
+
if (entry.name === 'collected' || entry.name === 'node_modules' || entry.name === '.git')
|
|
1464
|
+
continue;
|
|
1465
|
+
await walk(absolute);
|
|
1466
|
+
}
|
|
1467
|
+
else if (entry.isFile() && entry.name === name) {
|
|
1468
|
+
out.push(absolute);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
await walk(root);
|
|
1473
|
+
return out;
|
|
1474
|
+
}
|
|
1475
|
+
async function bundlePatchIsStale(bundle, mergePath, cwd) {
|
|
1476
|
+
const patchPath = resolveBundlePatchPath(bundle, mergePath);
|
|
1477
|
+
if (!patchPath || !await pathExists(patchPath))
|
|
1478
|
+
return false;
|
|
1479
|
+
const patch = await fs.readFile(patchPath, 'utf8').catch(() => '');
|
|
1480
|
+
if (!patch.trim())
|
|
1481
|
+
return false;
|
|
1482
|
+
const result = await runProcess('git', ['apply', '--check', patchPath], { cwd, allowFailure: true });
|
|
1483
|
+
return result.status !== 0;
|
|
1484
|
+
}
|
|
1485
|
+
function resolveBundlePatchPath(bundle, mergePath) {
|
|
1486
|
+
if (!bundle.patchPath)
|
|
1487
|
+
return undefined;
|
|
1488
|
+
return path.isAbsolute(bundle.patchPath) ? bundle.patchPath : path.resolve(path.dirname(mergePath), bundle.patchPath);
|
|
1489
|
+
}
|
|
1490
|
+
function normalizeCollectedMergeBundle(value, mergePath) {
|
|
1491
|
+
const input = typeof value === 'object' && value !== null ? value : {};
|
|
1492
|
+
const jobId = typeof input.jobId === 'string' && input.jobId ? input.jobId : path.basename(path.dirname(mergePath));
|
|
1493
|
+
const changedPaths = stringArray(input.changedPaths);
|
|
1494
|
+
const status = typeof input.status === 'string' ? input.status : 'completed';
|
|
1495
|
+
const autoMergeable = Boolean(input.autoMergeable);
|
|
1496
|
+
const disposition = typeof input.disposition === 'string'
|
|
1497
|
+
? input.disposition
|
|
1498
|
+
: autoMergeable ? 'auto-mergeable' : status === 'failed' ? 'rejected' : 'needs-port';
|
|
1499
|
+
return {
|
|
1500
|
+
kind: typeof input.kind === 'string' ? input.kind : FRONTIER_SWARM_MERGE_BUNDLE_KIND,
|
|
1501
|
+
version: typeof input.version === 'number' ? input.version : FRONTIER_SWARM_MERGE_BUNDLE_VERSION,
|
|
1502
|
+
id: typeof input.id === 'string' && input.id ? input.id : `swarm-merge-bundle:${jobId}`,
|
|
1503
|
+
...(typeof input.runId === 'string' ? { runId: input.runId } : {}),
|
|
1504
|
+
...(typeof input.planId === 'string' ? { planId: input.planId } : {}),
|
|
1505
|
+
jobId,
|
|
1506
|
+
...(typeof input.taskId === 'string' ? { taskId: input.taskId } : {}),
|
|
1507
|
+
...(typeof input.lane === 'string' ? { lane: input.lane } : {}),
|
|
1508
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
1509
|
+
generatedAt: typeof input.generatedAt === 'number' ? input.generatedAt : Date.now(),
|
|
1510
|
+
status,
|
|
1511
|
+
mergeReadiness: typeof input.mergeReadiness === 'string'
|
|
1512
|
+
? input.mergeReadiness
|
|
1513
|
+
: changedPaths.length ? 'patch-candidate' : 'discovery-only',
|
|
1514
|
+
disposition,
|
|
1515
|
+
riskLevel: typeof input.riskLevel === 'string' ? input.riskLevel : 'unknown',
|
|
1516
|
+
autoMergeable,
|
|
1517
|
+
changedPaths,
|
|
1518
|
+
changedRegions: stringArray(input.changedRegions),
|
|
1519
|
+
ownedFilesTouched: stringArray(input.ownedFilesTouched),
|
|
1520
|
+
allowedWrites: stringArray(input.allowedWrites),
|
|
1521
|
+
ownershipViolations: stringArray(input.ownershipViolations),
|
|
1522
|
+
...(typeof input.patchPath === 'string' ? { patchPath: input.patchPath } : {}),
|
|
1523
|
+
...(typeof input.patchHash === 'string' ? { patchHash: input.patchHash } : {}),
|
|
1524
|
+
evidencePaths: stringArray(input.evidencePaths),
|
|
1525
|
+
commandsPassed: Array.isArray(input.commandsPassed) ? input.commandsPassed : [],
|
|
1526
|
+
commandsFailed: Array.isArray(input.commandsFailed) ? input.commandsFailed : [],
|
|
1527
|
+
queueItemIds: stringArray(input.queueItemIds),
|
|
1528
|
+
...(typeof input.branchName === 'string' ? { branchName: input.branchName } : {}),
|
|
1529
|
+
...(typeof input.commit === 'string' ? { commit: input.commit } : {}),
|
|
1530
|
+
staleAgainstHead: Boolean(input.staleAgainstHead),
|
|
1531
|
+
reasons: stringArray(input.reasons)
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
function mergeRecordScore(record) {
|
|
1535
|
+
return (record.mergePath.includes('/evidence/') ? 100 : 0)
|
|
1536
|
+
+ record.bundle.changedPaths.length
|
|
1537
|
+
+ record.bundle.evidencePaths.length
|
|
1538
|
+
+ record.bundle.commandsPassed.length
|
|
1539
|
+
+ record.bundle.commandsFailed.length;
|
|
1540
|
+
}
|
|
1541
|
+
function stringArray(value) {
|
|
1542
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
|
|
1543
|
+
}
|
|
1544
|
+
function classifyCodexCollectBucket(bundle, staleAgainstHead) {
|
|
1545
|
+
if (staleAgainstHead || bundle.staleAgainstHead || bundle.disposition === 'stale-against-head')
|
|
1546
|
+
return 'stale-against-head';
|
|
1547
|
+
if (bundle.disposition === 'rejected' || bundle.disposition === 'blocked' || bundle.commandsFailed.length > 0 || bundle.status === 'failed') {
|
|
1548
|
+
return 'failed-evidence';
|
|
1549
|
+
}
|
|
1550
|
+
if (bundle.disposition === 'auto-mergeable' && bundle.autoMergeable)
|
|
1551
|
+
return 'ready-to-apply';
|
|
1552
|
+
return 'needs-human-port';
|
|
1553
|
+
}
|
|
628
1554
|
async function readOptionalText(file) {
|
|
629
1555
|
try {
|
|
630
1556
|
return await fs.readFile(file, 'utf8');
|
|
@@ -662,4 +1588,24 @@ async function runProcess(command, args, options) {
|
|
|
662
1588
|
function tail(text, maxLines = 24) {
|
|
663
1589
|
return text.trim().split(/\r?\n/).filter(Boolean).slice(-maxLines);
|
|
664
1590
|
}
|
|
1591
|
+
function stableHash(value) {
|
|
1592
|
+
const text = stableStringify(value);
|
|
1593
|
+
let hash = 2166136261;
|
|
1594
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1595
|
+
hash ^= text.charCodeAt(index);
|
|
1596
|
+
hash = Math.imul(hash, 16777619);
|
|
1597
|
+
}
|
|
1598
|
+
return 'fnv1a32:' + (hash >>> 0).toString(16).padStart(8, '0');
|
|
1599
|
+
}
|
|
1600
|
+
function slug(value) {
|
|
1601
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'item';
|
|
1602
|
+
}
|
|
1603
|
+
function stableStringify(value) {
|
|
1604
|
+
if (value === null || typeof value !== 'object')
|
|
1605
|
+
return JSON.stringify(value);
|
|
1606
|
+
if (Array.isArray(value))
|
|
1607
|
+
return '[' + value.map(stableStringify).join(',') + ']';
|
|
1608
|
+
const object = value;
|
|
1609
|
+
return '{' + Object.keys(object).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(object[key])).join(',') + '}';
|
|
1610
|
+
}
|
|
665
1611
|
//# sourceMappingURL=index.js.map
|