@chrok/pi-braid 0.1.1 → 0.1.3
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 +48 -14
- package/dist/{integrations/pi/display.js → display.js} +1 -1
- package/dist/{integrations/pi/index.js → index.js} +8 -4
- package/dist/{integrations/pi/jobs.js → jobs.js} +1 -1
- package/dist/{integrations/pi/runner.js → runner.js} +4 -3
- package/package.json +5 -4
- package/dist/integrations/pi/workspaces.js +0 -2
- package/dist/src/adapters/openai.js +0 -244
- package/dist/src/budgets.js +0 -19
- package/dist/src/index.js +0 -2
- package/dist/src/merge-tools.js +0 -96
- package/dist/src/runtime.js +0 -532
- package/dist/src/types.js +0 -1
- package/dist/src/validate.js +0 -110
- package/dist/src/workspaces.js +0 -494
- /package/dist/{integrations/pi/command.js → command.js} +0 -0
- /package/dist/{integrations/pi/read-tools.js → read-tools.js} +0 -0
- /package/dist/{integrations/pi/write-tools.js → write-tools.js} +0 -0
package/dist/src/workspaces.js
DELETED
|
@@ -1,494 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { access, copyFile, mkdtemp, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { gitCommands, unavailableGitCommand, validateMergeDispositions } from "./merge-tools.js";
|
|
8
|
-
const exec = promisify(execFile);
|
|
9
|
-
const mergeLocks = new Map();
|
|
10
|
-
const worktreeLocks = new Map();
|
|
11
|
-
/** Git worktree add/remove expose intermediate shared registration files. */
|
|
12
|
-
async function withWorktreeLock(commonDirectory, operation) {
|
|
13
|
-
const previous = worktreeLocks.get(commonDirectory) ?? Promise.resolve();
|
|
14
|
-
let release;
|
|
15
|
-
const gate = new Promise(resolve => { release = resolve; });
|
|
16
|
-
const tail = previous.then(() => gate);
|
|
17
|
-
worktreeLocks.set(commonDirectory, tail);
|
|
18
|
-
await previous;
|
|
19
|
-
try {
|
|
20
|
-
return await operation();
|
|
21
|
-
}
|
|
22
|
-
finally {
|
|
23
|
-
release();
|
|
24
|
-
if (worktreeLocks.get(commonDirectory) === tail)
|
|
25
|
-
worktreeLocks.delete(commonDirectory);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
async function lock(root, signal) {
|
|
29
|
-
const previous = mergeLocks.get(root) ?? Promise.resolve();
|
|
30
|
-
let release;
|
|
31
|
-
const gate = new Promise(resolve => { release = resolve; });
|
|
32
|
-
// A cancelled waiter must not let later callers bypass the current owner.
|
|
33
|
-
const next = previous.then(() => gate);
|
|
34
|
-
mergeLocks.set(root, next);
|
|
35
|
-
void next.then(() => {
|
|
36
|
-
if (mergeLocks.get(root) === next)
|
|
37
|
-
mergeLocks.delete(root);
|
|
38
|
-
});
|
|
39
|
-
let onAbort;
|
|
40
|
-
try {
|
|
41
|
-
await Promise.race([previous, new Promise((_, reject) => {
|
|
42
|
-
onAbort = () => reject(signal.reason);
|
|
43
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
44
|
-
if (signal.aborted)
|
|
45
|
-
onAbort();
|
|
46
|
-
})]);
|
|
47
|
-
signal.throwIfAborted();
|
|
48
|
-
return release;
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
release();
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
signal.removeEventListener("abort", onAbort);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function gitEnvironment() {
|
|
59
|
-
const env = { ...process.env };
|
|
60
|
-
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"])
|
|
61
|
-
delete env[key];
|
|
62
|
-
return env;
|
|
63
|
-
}
|
|
64
|
-
async function git(cwd, args, options = {}) {
|
|
65
|
-
const { stdout } = await exec("git", [
|
|
66
|
-
...(options.hooksDirectory ? ["-c", `core.hooksPath=${options.hooksDirectory}`] : []),
|
|
67
|
-
"-C", cwd, ...args,
|
|
68
|
-
], {
|
|
69
|
-
env: { ...gitEnvironment(), ...options.env },
|
|
70
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
71
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
72
|
-
});
|
|
73
|
-
return stdout.trim();
|
|
74
|
-
}
|
|
75
|
-
/** Read-only, bounded output. A large diff cannot fail workspace preparation or flood the model. */
|
|
76
|
-
async function gitPreview(cwd, args, limit) {
|
|
77
|
-
let output;
|
|
78
|
-
let overflow = false;
|
|
79
|
-
try {
|
|
80
|
-
const result = await exec("git", ["-c", "core.fsmonitor=false", "--no-optional-locks", "--no-pager", "-C", cwd, ...args], {
|
|
81
|
-
env: gitEnvironment(), maxBuffer: Math.max(64 * 1024, limit * 4), encoding: "utf8",
|
|
82
|
-
});
|
|
83
|
-
output = result.stdout;
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
const failure = error;
|
|
87
|
-
if (failure.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || typeof failure.stdout !== "string")
|
|
88
|
-
throw error;
|
|
89
|
-
output = failure.stdout;
|
|
90
|
-
overflow = true;
|
|
91
|
-
}
|
|
92
|
-
return { text: output.slice(0, limit), truncated: overflow || output.length > limit };
|
|
93
|
-
}
|
|
94
|
-
/** One source snapshot per graph; every invoked node gets its own detached worktree. */
|
|
95
|
-
export class GitWorkspaces {
|
|
96
|
-
cwd;
|
|
97
|
-
onWorkspace;
|
|
98
|
-
snapshots = new Map();
|
|
99
|
-
records = new Map();
|
|
100
|
-
locations = new Map();
|
|
101
|
-
allocated = new Set();
|
|
102
|
-
constructor(cwd, onWorkspace) {
|
|
103
|
-
this.cwd = cwd;
|
|
104
|
-
this.onWorkspace = onWorkspace;
|
|
105
|
-
}
|
|
106
|
-
report(workspace) {
|
|
107
|
-
this.records.set(workspace.nodeId, workspace);
|
|
108
|
-
try {
|
|
109
|
-
this.onWorkspace?.({ ...workspace });
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
// Observers cannot change permissions or fail node execution.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
async snapshot() {
|
|
116
|
-
let ancestor = resolve(this.cwd);
|
|
117
|
-
while (true) {
|
|
118
|
-
try {
|
|
119
|
-
await access(join(ancestor, ".git"));
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
if (error.code !== "ENOENT")
|
|
124
|
-
throw error;
|
|
125
|
-
const parent = dirname(ancestor);
|
|
126
|
-
if (parent === ancestor)
|
|
127
|
-
return undefined;
|
|
128
|
-
ancestor = parent;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
let sourceRoot;
|
|
132
|
-
try {
|
|
133
|
-
sourceRoot = await git(this.cwd, ["rev-parse", "--show-toplevel"]);
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
const failure = error;
|
|
137
|
-
if (failure.code === "ENOENT" || /not a git repository|must be run in a work tree/i.test(failure.stderr ?? ""))
|
|
138
|
-
return undefined;
|
|
139
|
-
throw error;
|
|
140
|
-
}
|
|
141
|
-
sourceRoot = await realpath(sourceRoot);
|
|
142
|
-
const commonDirectory = await realpath(resolve(sourceRoot, await git(sourceRoot, ["rev-parse", "--git-common-dir"])));
|
|
143
|
-
const cwdSuffix = relative(sourceRoot, await realpath(this.cwd));
|
|
144
|
-
// Git records canonical worktree paths. In particular, Windows tmpdir()
|
|
145
|
-
// can contain an 8.3 alias that Git later rejects for lock/remove commands.
|
|
146
|
-
const directory = await realpath(await mkdtemp(join(tmpdir(), "braid-workspaces-")));
|
|
147
|
-
const hooksDirectory = join(directory, "hooks");
|
|
148
|
-
await mkdir(hooksDirectory);
|
|
149
|
-
const index = join(directory, "snapshot-index");
|
|
150
|
-
const options = { env: { GIT_INDEX_FILE: index }, hooksDirectory };
|
|
151
|
-
try {
|
|
152
|
-
let baseCommit;
|
|
153
|
-
try {
|
|
154
|
-
baseCommit = await git(sourceRoot, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
// An initialized repository without commits can still supply a snapshot.
|
|
158
|
-
const head = await git(sourceRoot, ["symbolic-ref", "HEAD"]);
|
|
159
|
-
const refs = await git(sourceRoot, ["for-each-ref", "--format=%(refname)", head]);
|
|
160
|
-
if (refs)
|
|
161
|
-
throw new Error("Cannot resolve the Git repository's HEAD");
|
|
162
|
-
}
|
|
163
|
-
const sourceIndex = resolve(sourceRoot, await git(sourceRoot, ["rev-parse", "--git-path", "index"]));
|
|
164
|
-
try {
|
|
165
|
-
// Preserve staged additions, including files explicitly tracked despite
|
|
166
|
-
// ignore rules. Starting from HEAD would silently omit those files.
|
|
167
|
-
await copyFile(sourceIndex, index);
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
if (error.code !== "ENOENT")
|
|
171
|
-
throw error;
|
|
172
|
-
await git(sourceRoot, ["read-tree", "--empty"], options);
|
|
173
|
-
}
|
|
174
|
-
// A temporary index captures tracked edits/deletions and non-ignored new files
|
|
175
|
-
// without changing the parent's real index, branch, or working files.
|
|
176
|
-
await git(sourceRoot, ["add", "--all", "--", "."], options);
|
|
177
|
-
const tree = await git(sourceRoot, ["write-tree"], options);
|
|
178
|
-
const baseTree = baseCommit ? await git(sourceRoot, ["rev-parse", `${baseCommit}^{tree}`]) : undefined;
|
|
179
|
-
const snapshotCommit = tree === baseTree ? baseCommit : await git(sourceRoot, [
|
|
180
|
-
"-c", "user.name=Braid", "-c", "user.email=braid@localhost",
|
|
181
|
-
"-c", "commit.gpgsign=false", "commit-tree", tree,
|
|
182
|
-
...(baseCommit ? ["-p", baseCommit] : []),
|
|
183
|
-
"-m", "Braid isolated workspace snapshot",
|
|
184
|
-
], options);
|
|
185
|
-
const snapshot = {
|
|
186
|
-
directory, commonDirectory, sourceRoot, cwdSuffix, snapshotCommit, hooksDirectory,
|
|
187
|
-
...(baseCommit ? { baseCommit } : {}),
|
|
188
|
-
};
|
|
189
|
-
this.allocated.add(snapshot);
|
|
190
|
-
return snapshot;
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
await rm(directory, { recursive: true, force: true });
|
|
194
|
-
throw error;
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
await rm(index, { force: true });
|
|
198
|
-
await rm(`${index}.lock`, { force: true });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
async prepare(request) {
|
|
202
|
-
request.signal.throwIfAborted();
|
|
203
|
-
let pending = this.snapshots.get(request.execution.runId);
|
|
204
|
-
if (!pending) {
|
|
205
|
-
// Do not bind the shared snapshot to one node's cancellation signal.
|
|
206
|
-
pending = this.snapshot();
|
|
207
|
-
this.snapshots.set(request.execution.runId, pending);
|
|
208
|
-
}
|
|
209
|
-
const snapshot = await pending;
|
|
210
|
-
request.signal.throwIfAborted();
|
|
211
|
-
if (!snapshot) {
|
|
212
|
-
const workspace = {
|
|
213
|
-
nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready",
|
|
214
|
-
};
|
|
215
|
-
this.report(workspace);
|
|
216
|
-
return workspace;
|
|
217
|
-
}
|
|
218
|
-
const worktreeRoot = join(snapshot.directory, crypto.randomUUID());
|
|
219
|
-
const workspace = {
|
|
220
|
-
nodeId: request.node.id, mode: "worktree", state: "preparing",
|
|
221
|
-
sourceRoot: snapshot.sourceRoot, worktreeRoot,
|
|
222
|
-
workingDirectory: resolve(worktreeRoot, snapshot.cwdSuffix),
|
|
223
|
-
snapshotCommit: snapshot.snapshotCommit,
|
|
224
|
-
...(snapshot.baseCommit ? { baseCommit: snapshot.baseCommit } : {}),
|
|
225
|
-
};
|
|
226
|
-
this.locations.set(request.node.id, snapshot);
|
|
227
|
-
this.report(workspace);
|
|
228
|
-
try {
|
|
229
|
-
// Finish registration even if cancellation arrives during creation. Report
|
|
230
|
-
// the retained path before observing cancellation, so it can be recovered.
|
|
231
|
-
await withWorktreeLock(snapshot.commonDirectory, async () => {
|
|
232
|
-
request.signal.throwIfAborted();
|
|
233
|
-
await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, snapshot.snapshotCommit], {
|
|
234
|
-
hooksDirectory: snapshot.hooksDirectory,
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
// The original cwd may be an empty or ignored directory absent from Git.
|
|
238
|
-
await mkdir(workspace.workingDirectory, { recursive: true });
|
|
239
|
-
workspace.state = "ready";
|
|
240
|
-
}
|
|
241
|
-
catch (error) {
|
|
242
|
-
workspace.state = "failed";
|
|
243
|
-
throw new Error(`Cannot prepare isolated node worktree at ${worktreeRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
244
|
-
}
|
|
245
|
-
finally {
|
|
246
|
-
this.report(workspace);
|
|
247
|
-
}
|
|
248
|
-
request.signal.throwIfAborted();
|
|
249
|
-
return workspace;
|
|
250
|
-
}
|
|
251
|
-
all() {
|
|
252
|
-
return Object.fromEntries([...this.records].map(([id, workspace]) => [id, { ...workspace }]));
|
|
253
|
-
}
|
|
254
|
-
pending() {
|
|
255
|
-
return [...this.records.values()]
|
|
256
|
-
.filter(workspace => workspace.mode === "worktree" && ["preparing", "ready", "failed"].includes(workspace.state))
|
|
257
|
-
.map(workspace => workspace.nodeId);
|
|
258
|
-
}
|
|
259
|
-
/** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
|
|
260
|
-
async checkpoint(workspace) {
|
|
261
|
-
if (workspace.checkpointRef)
|
|
262
|
-
return;
|
|
263
|
-
const location = this.locations.get(workspace.nodeId);
|
|
264
|
-
const cwd = workspace.worktreeRoot;
|
|
265
|
-
const options = { hooksDirectory: location.hooksDirectory };
|
|
266
|
-
// A Git tree stores only a submodule commit, never files written inside it.
|
|
267
|
-
// Refuse destructive cleanup if an external/custom runner populated one.
|
|
268
|
-
const entries = (await git(cwd, ["ls-files", "--stage", "-z"])).split("\0");
|
|
269
|
-
for (const entry of entries.filter(entry => entry.startsWith("160000 "))) {
|
|
270
|
-
const path = entry.slice(entry.indexOf("\t") + 1);
|
|
271
|
-
try {
|
|
272
|
-
if ((await readdir(join(cwd, path))).length)
|
|
273
|
-
throw new Error(`Cannot checkpoint populated submodule '${path}'; worktree retained at ${cwd}`);
|
|
274
|
-
}
|
|
275
|
-
catch (error) {
|
|
276
|
-
if (!["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
|
|
277
|
-
throw error;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
await git(cwd, ["add", "--force", "--all", "--", "."], options);
|
|
281
|
-
const tree = await git(cwd, ["write-tree"], options);
|
|
282
|
-
const commit = await git(cwd, [
|
|
283
|
-
"-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
|
|
284
|
-
"commit-tree", tree, "-p", workspace.snapshotCommit, "-m", `Braid node checkpoint: ${workspace.nodeId}`,
|
|
285
|
-
], options);
|
|
286
|
-
const suffix = createHash("sha256").update(cwd).digest("hex");
|
|
287
|
-
const ref = `refs/braid/checkpoints/${suffix}`;
|
|
288
|
-
await git(cwd, ["update-ref", ref, commit], options);
|
|
289
|
-
workspace.checkpointCommit = commit;
|
|
290
|
-
workspace.checkpointRef = ref;
|
|
291
|
-
this.report(workspace);
|
|
292
|
-
}
|
|
293
|
-
async release(id, disposition, reason) {
|
|
294
|
-
const workspace = this.records.get(id);
|
|
295
|
-
const location = this.locations.get(id);
|
|
296
|
-
if (workspace.state === "failed") {
|
|
297
|
-
try {
|
|
298
|
-
await access(workspace.worktreeRoot);
|
|
299
|
-
}
|
|
300
|
-
catch (error) {
|
|
301
|
-
if (error.code !== "ENOENT")
|
|
302
|
-
throw error;
|
|
303
|
-
await withWorktreeLock(location.commonDirectory, async () => {
|
|
304
|
-
const registrations = await git(location.sourceRoot, ["worktree", "list", "--porcelain", "-z"]);
|
|
305
|
-
if (registrations.split("\0").some(field => field.startsWith("worktree ") &&
|
|
306
|
-
relative(field.slice("worktree ".length), workspace.worktreeRoot) === ""))
|
|
307
|
-
await git(location.sourceRoot, ["worktree", "remove", "--force", workspace.worktreeRoot]);
|
|
308
|
-
});
|
|
309
|
-
workspace.state = "archived";
|
|
310
|
-
workspace.reason = "Worktree creation failed before a directory was available; no node changes to archive";
|
|
311
|
-
this.report(workspace);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
await this.checkpoint(workspace);
|
|
316
|
-
await withWorktreeLock(location.commonDirectory, () => git(location.sourceRoot, ["worktree", "remove", "--force", workspace.worktreeRoot], {
|
|
317
|
-
hooksDirectory: location.hooksDirectory,
|
|
318
|
-
}));
|
|
319
|
-
workspace.state = disposition;
|
|
320
|
-
workspace.reason = reason;
|
|
321
|
-
this.report(workspace);
|
|
322
|
-
}
|
|
323
|
-
/** Only archive/remove here. Choosing merge/cherry-pick/apply always belongs to the agent. */
|
|
324
|
-
async archivePending(reason) {
|
|
325
|
-
const errors = [];
|
|
326
|
-
for (const id of this.pending()) {
|
|
327
|
-
try {
|
|
328
|
-
await this.release(id, "archived", reason);
|
|
329
|
-
}
|
|
330
|
-
catch (error) {
|
|
331
|
-
errors.push(error);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (errors.length)
|
|
335
|
-
throw new AggregateError(errors, "Some workspaces could not be archived and removed; their paths are retained in workspaces");
|
|
336
|
-
}
|
|
337
|
-
async close() {
|
|
338
|
-
await Promise.allSettled(this.snapshots.values());
|
|
339
|
-
const errors = [];
|
|
340
|
-
for (const snapshot of this.allocated) {
|
|
341
|
-
// Ownership is explicit; a POSIX path prefix would miss retained Windows
|
|
342
|
-
// worktrees and recursively delete data after checkpoint/cleanup failure.
|
|
343
|
-
const live = [...this.records.values()].some(workspace => workspace.mode === "worktree" && this.locations.get(workspace.nodeId) === snapshot &&
|
|
344
|
-
["ready", "preparing", "failed"].includes(workspace.state));
|
|
345
|
-
if (!live) {
|
|
346
|
-
try {
|
|
347
|
-
await rm(snapshot.directory, { recursive: true, force: true });
|
|
348
|
-
}
|
|
349
|
-
catch (error) {
|
|
350
|
-
errors.push(error);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (errors.length)
|
|
355
|
-
throw new AggregateError(errors, "Some workspace temporary directories could not be removed");
|
|
356
|
-
}
|
|
357
|
-
async git(request, args, input) {
|
|
358
|
-
request.signal.throwIfAborted();
|
|
359
|
-
if (!Array.isArray(args) || args.length === 0 || args.some(arg => typeof arg !== "string" || arg.includes("\0")))
|
|
360
|
-
throw new Error("git requires a nonempty array of string arguments");
|
|
361
|
-
if (input !== undefined && typeof input !== "string")
|
|
362
|
-
throw new Error("Git input must be a string");
|
|
363
|
-
const mutate = ["add", "commit", "merge", "cherry-pick", "apply", "restore"];
|
|
364
|
-
const command = args[0];
|
|
365
|
-
if (!gitCommands(request.node.type === "merge").includes(command))
|
|
366
|
-
throw unavailableGitCommand(command, request.node.type === "merge");
|
|
367
|
-
const blockedOptions = ["--output", "--ext-diff", "--textconv", "--unsafe-paths", "--directory", "--strategy", "--gpg-sign", "--work-tree", "--git-dir"];
|
|
368
|
-
// Git accepts abbreviated long options (e.g. --out) and attached short
|
|
369
|
-
// values (-scustom). Apply the same boundary to those spellings.
|
|
370
|
-
if (args.some(arg => {
|
|
371
|
-
const option = arg.split("=")[0];
|
|
372
|
-
return /^-[sSC]/.test(arg) ||
|
|
373
|
-
(option.length > 2 && option.startsWith("--") && blockedOptions.some(blocked => blocked.startsWith(option)));
|
|
374
|
-
}))
|
|
375
|
-
throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
|
|
376
|
-
const workspace = request.workspace;
|
|
377
|
-
const cwd = workspace.mode === "merge" ? workspace.sourceRoot : workspace.worktreeRoot;
|
|
378
|
-
const location = this.locations.get(request.node.id);
|
|
379
|
-
const actualArgs = [command,
|
|
380
|
-
...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
|
|
381
|
-
...args.slice(1),
|
|
382
|
-
];
|
|
383
|
-
const execute = () => new Promise((resolve, reject) => {
|
|
384
|
-
const child = execFile("git", [
|
|
385
|
-
"-c", `core.hooksPath=${location.hooksDirectory}`, "-c", "commit.gpgsign=false",
|
|
386
|
-
"-c", "core.fsmonitor=false", "--no-pager", "-C", cwd, ...actualArgs,
|
|
387
|
-
], {
|
|
388
|
-
env: { ...gitEnvironment(), GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true", GIT_MERGE_AUTOEDIT: "no" },
|
|
389
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
390
|
-
}, (error, stdout, stderr) => {
|
|
391
|
-
if (error && typeof error.code !== "number")
|
|
392
|
-
reject(error);
|
|
393
|
-
else
|
|
394
|
-
resolve({ exitCode: error?.code ?? 0, stdout, stderr });
|
|
395
|
-
});
|
|
396
|
-
child.stdin?.end(input);
|
|
397
|
-
});
|
|
398
|
-
return mutate.includes(command) ? request.withWorkspaceWrite(execute) : execute();
|
|
399
|
-
}
|
|
400
|
-
async beginMerge(request, sourceIds) {
|
|
401
|
-
// Discover the source without allocating a merge worktree: agents integrate
|
|
402
|
-
// directly in the caller's checkout, under a repository-scoped mutex.
|
|
403
|
-
let pending = this.snapshots.get(request.execution.runId);
|
|
404
|
-
if (!pending) {
|
|
405
|
-
pending = this.snapshot();
|
|
406
|
-
this.snapshots.set(request.execution.runId, pending);
|
|
407
|
-
}
|
|
408
|
-
let snapshot = await pending;
|
|
409
|
-
const unlock = snapshot ? await lock(snapshot.sourceRoot, request.signal) : () => { };
|
|
410
|
-
let finished = false;
|
|
411
|
-
let resolutions;
|
|
412
|
-
const ids = sourceIds.filter(id => this.pending().includes(id));
|
|
413
|
-
try {
|
|
414
|
-
request.signal.throwIfAborted();
|
|
415
|
-
if (snapshot) {
|
|
416
|
-
snapshot = await this.snapshot();
|
|
417
|
-
this.snapshots.set(request.execution.runId, Promise.resolve(snapshot));
|
|
418
|
-
}
|
|
419
|
-
for (const id of ids)
|
|
420
|
-
await this.checkpoint(this.records.get(id));
|
|
421
|
-
const workspace = snapshot ? {
|
|
422
|
-
nodeId: request.node.id, mode: "merge", workingDirectory: snapshot.sourceRoot,
|
|
423
|
-
sourceRoot: snapshot.sourceRoot, snapshotCommit: snapshot.snapshotCommit, state: "ready",
|
|
424
|
-
} : { nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready" };
|
|
425
|
-
if (snapshot) {
|
|
426
|
-
workspace.backupRef = `refs/braid/merge-backups/${crypto.randomUUID()}`;
|
|
427
|
-
await git(snapshot.sourceRoot, ["update-ref", workspace.backupRef, snapshot.snapshotCommit], { hooksDirectory: snapshot.hooksDirectory });
|
|
428
|
-
}
|
|
429
|
-
request.workspace = workspace;
|
|
430
|
-
if (snapshot)
|
|
431
|
-
this.locations.set(request.node.id, snapshot);
|
|
432
|
-
this.report(workspace);
|
|
433
|
-
const sourceStatus = snapshot ? await gitPreview(snapshot.sourceRoot, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
|
|
434
|
-
const sources = [];
|
|
435
|
-
for (const id of ids) {
|
|
436
|
-
const source = this.records.get(id);
|
|
437
|
-
const diffArgs = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", source.snapshotCommit, source.checkpointRef, "--"];
|
|
438
|
-
const count = Math.max(1, ids.length);
|
|
439
|
-
const files = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--name-only", "-z", "--"], Math.floor(8_000 / count));
|
|
440
|
-
const stat = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--stat", "--"], Math.floor(4_000 / count));
|
|
441
|
-
const diff = await gitPreview(source.sourceRoot, diffArgs, Math.min(6_000, Math.floor(24_000 / count)));
|
|
442
|
-
// Truncated NUL output must not invent a partial filename.
|
|
443
|
-
const names = files.text.slice(0, files.text.lastIndexOf("\0") + 1).split("\0").filter(Boolean);
|
|
444
|
-
sources.push({ ...source, changes: { files: names, filesTruncated: files.truncated, stat, diff } });
|
|
445
|
-
}
|
|
446
|
-
return {
|
|
447
|
-
sources,
|
|
448
|
-
...(sourceStatus ? { sourceStatus: { ...sourceStatus, dirty: sourceStatus.text.length > 0 || sourceStatus.truncated } } : {}),
|
|
449
|
-
finish: async (decisions) => {
|
|
450
|
-
request.signal.throwIfAborted();
|
|
451
|
-
if (finished)
|
|
452
|
-
throw new Error("finish_merge must be called exactly once");
|
|
453
|
-
validateMergeDispositions(ids, decisions);
|
|
454
|
-
if (snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"]))
|
|
455
|
-
throw new Error("Unresolved Git conflicts remain in the source checkout");
|
|
456
|
-
resolutions = structuredClone(decisions);
|
|
457
|
-
finished = true;
|
|
458
|
-
},
|
|
459
|
-
complete: async (success) => {
|
|
460
|
-
try {
|
|
461
|
-
const errors = [];
|
|
462
|
-
// Check again after all tracked writes have drained: an adapter can
|
|
463
|
-
// invoke more tools after finish_merge in the same model response.
|
|
464
|
-
if (success && snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"])) {
|
|
465
|
-
success = false;
|
|
466
|
-
errors.push(new Error("Unresolved Git conflicts remain in the source checkout"));
|
|
467
|
-
}
|
|
468
|
-
for (const id of ids) {
|
|
469
|
-
const decision = success && finished ? resolutions.find(value => value.nodeId === id) : undefined;
|
|
470
|
-
try {
|
|
471
|
-
await this.release(id, decision?.disposition ?? "archived", decision?.reason ?? "Merge agent did not complete; changes preserved in checkpointRef");
|
|
472
|
-
}
|
|
473
|
-
catch (error) {
|
|
474
|
-
errors.push(error);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
this.snapshots.delete(request.execution.runId);
|
|
478
|
-
if (errors.length)
|
|
479
|
-
throw new AggregateError(errors, "Some merge sources could not be cleaned up; their paths are retained in workspaces");
|
|
480
|
-
if (success && (!finished || resolutions.some(value => value.disposition === "archived")))
|
|
481
|
-
throw new Error("Merge agent did not integrate or explicitly discard every source; remaining changes were archived");
|
|
482
|
-
}
|
|
483
|
-
finally {
|
|
484
|
-
unlock();
|
|
485
|
-
}
|
|
486
|
-
},
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
catch (error) {
|
|
490
|
-
unlock();
|
|
491
|
-
throw error;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|