@qilin-agent/workspace-changes 0.0.0-stage → 3.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/LICENSE +21 -0
- package/README.i18n.yaml +37 -0
- package/README.md +105 -2
- package/README.zh.md +106 -0
- package/lib/index.js +1155 -0
- package/lib/types/capture.d.ts +47 -0
- package/lib/types/capture.js +107 -0
- package/lib/types/compare.d.ts +19 -0
- package/lib/types/compare.js +56 -0
- package/lib/types/git.d.ts +140 -0
- package/lib/types/git.js +224 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/index.js +146 -0
- package/lib/types/numstat.d.ts +19 -0
- package/lib/types/numstat.js +45 -0
- package/lib/types/paths.d.ts +65 -0
- package/lib/types/paths.js +106 -0
- package/lib/types/recorder.d.ts +126 -0
- package/lib/types/recorder.js +370 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +2 -0
- package/package.json +56 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
|
+
import z from "@qilin-agent/schemastery";
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { structuredPatch } from "diff";
|
|
7
|
+
//#region lib/types/numstat.js
|
|
8
|
+
/** Parsing of `git diff-tree --numstat -z` output. */
|
|
9
|
+
/**
|
|
10
|
+
* Parse NUL-terminated numstat records. A rename record carries an empty path
|
|
11
|
+
* followed by the old and new paths.
|
|
12
|
+
* @param output - complete stdout of `git diff-tree -r -M -z --numstat`.
|
|
13
|
+
* @returns records in git's output order.
|
|
14
|
+
* @throws when a record is malformed, which indicates truncated output.
|
|
15
|
+
*/
|
|
16
|
+
function parseNumstat(output) {
|
|
17
|
+
const queue = output.split("\0");
|
|
18
|
+
if (queue.at(-1) !== "") throw new Error("numstat output is not NUL-terminated");
|
|
19
|
+
queue.pop();
|
|
20
|
+
const entries = [];
|
|
21
|
+
while (queue.length > 0) {
|
|
22
|
+
const record = queue.shift();
|
|
23
|
+
const first = record.indexOf(" ");
|
|
24
|
+
const second = first < 0 ? -1 : record.indexOf(" ", first + 1);
|
|
25
|
+
if (second < 0) throw new Error(`malformed numstat record: ${record}`);
|
|
26
|
+
const added = record.slice(0, first);
|
|
27
|
+
const deleted = record.slice(first + 1, second);
|
|
28
|
+
let target = record.slice(second + 1);
|
|
29
|
+
let oldPath;
|
|
30
|
+
if (target === "") {
|
|
31
|
+
oldPath = queue.shift();
|
|
32
|
+
const renamed = queue.shift();
|
|
33
|
+
if (oldPath === void 0 || renamed === void 0) throw new Error("malformed numstat rename record");
|
|
34
|
+
target = renamed;
|
|
35
|
+
}
|
|
36
|
+
const binary = added === "-";
|
|
37
|
+
entries.push({
|
|
38
|
+
path: target,
|
|
39
|
+
...oldPath === void 0 ? {} : { oldPath },
|
|
40
|
+
added: binary ? 0 : Number(added),
|
|
41
|
+
deleted: binary ? 0 : Number(deleted),
|
|
42
|
+
binary
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region lib/types/paths.js
|
|
49
|
+
/** Path classification and display forms for changed files. */
|
|
50
|
+
/**
|
|
51
|
+
* Slash-separated form of a native relative path.
|
|
52
|
+
* @param path - native path.
|
|
53
|
+
* @returns the same path with `/` separators.
|
|
54
|
+
*/
|
|
55
|
+
function toPosix(path) {
|
|
56
|
+
return path.split(sep).join("/");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether `path` is `root` or lies under it.
|
|
60
|
+
* @param root - absolute directory.
|
|
61
|
+
* @param path - absolute path to test.
|
|
62
|
+
* @returns true for the root itself and every descendant.
|
|
63
|
+
*/
|
|
64
|
+
function isInside(root, path) {
|
|
65
|
+
const rel = relative(root, path);
|
|
66
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Canonical spellings of the temporary directories a workspace-write sandbox
|
|
70
|
+
* grants: the host `/tmp` and the platform temp area, each also in its
|
|
71
|
+
* symlink-resolved form so `/tmp` and `/private/tmp` match alike.
|
|
72
|
+
* @param candidates - directories to canonicalize.
|
|
73
|
+
* @returns absolute directory paths.
|
|
74
|
+
*/
|
|
75
|
+
async function temporaryRoots(candidates = ["/tmp", tmpdir()]) {
|
|
76
|
+
const roots = /* @__PURE__ */ new Set();
|
|
77
|
+
for (const root of candidates) {
|
|
78
|
+
roots.add(root);
|
|
79
|
+
roots.add(await canonicalPath(root));
|
|
80
|
+
}
|
|
81
|
+
return [...roots];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Symlink-resolved path. A path that does not exist yet is resolved through
|
|
85
|
+
* its nearest existing ancestor, so a file created through a directory
|
|
86
|
+
* symlink has the same canonical spelling before and after it exists.
|
|
87
|
+
* @param path - absolute path.
|
|
88
|
+
* @returns the canonical spelling git reports for the path.
|
|
89
|
+
*/
|
|
90
|
+
async function canonicalPath(path) {
|
|
91
|
+
const missing = [];
|
|
92
|
+
let head = path;
|
|
93
|
+
while (true) try {
|
|
94
|
+
return join(await realpath(head), ...missing);
|
|
95
|
+
} catch {
|
|
96
|
+
const parent = dirname(head);
|
|
97
|
+
if (parent === head || dirname(parent) === parent) return path;
|
|
98
|
+
missing.unshift(basename(head));
|
|
99
|
+
head = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether a file lives under a temporary root, where the model keeps scratch work.
|
|
104
|
+
* @param path - absolute file path.
|
|
105
|
+
* @param roots - {@link temporaryRoots}.
|
|
106
|
+
* @returns true for scratch paths that never enter the change summary.
|
|
107
|
+
*/
|
|
108
|
+
function isTemporaryPath(path, roots) {
|
|
109
|
+
return roots.some((root) => isInside(root, path));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Sort key and label of a changed file; see `WorkspaceChangedFile.display`.
|
|
113
|
+
* @param absolute - canonical absolute file path.
|
|
114
|
+
* @param cwd - canonical Session working directory.
|
|
115
|
+
* @param root - repository top-level directory.
|
|
116
|
+
* @param home - canonical home directory, or empty to skip the `~` form.
|
|
117
|
+
* @returns the slash-separated display path.
|
|
118
|
+
*/
|
|
119
|
+
function displayPathOf(absolute, cwd, root, home) {
|
|
120
|
+
if (isInside(cwd, absolute) || isInside(root, absolute)) return toPosix(relative(cwd, absolute));
|
|
121
|
+
if (home !== "" && isInside(home, absolute)) return `~/${toPosix(relative(home, absolute))}`;
|
|
122
|
+
return toPosix(absolute);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The durable `path` field: relative inside the working directory, absolute elsewhere.
|
|
126
|
+
* @param absolute - canonical absolute file path.
|
|
127
|
+
* @param cwd - canonical Session working directory.
|
|
128
|
+
* @returns the path the Web client opens the file through.
|
|
129
|
+
*/
|
|
130
|
+
function durablePathOf(absolute, cwd) {
|
|
131
|
+
return isInside(cwd, absolute) ? toPosix(relative(cwd, absolute)) : absolute;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Code-unit order of display paths, which places `../` and absolute paths
|
|
135
|
+
* before letters and matches git's own listing order for relative paths.
|
|
136
|
+
* @param a - first file.
|
|
137
|
+
* @param b - second file.
|
|
138
|
+
* @returns negative, zero, or positive as `Array.prototype.sort` expects.
|
|
139
|
+
*/
|
|
140
|
+
function compareDisplay(a, b) {
|
|
141
|
+
return a.display < b.display ? -1 : a.display > b.display ? 1 : 0;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region lib/types/git.js
|
|
145
|
+
/** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
|
|
146
|
+
/** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
|
|
147
|
+
const TERMINATE_GRACE_MS = 2e3;
|
|
148
|
+
/** Retained stderr tail for diagnostics. */
|
|
149
|
+
const STDERR_TAIL_BYTES = 16 * 1024;
|
|
150
|
+
/** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */
|
|
151
|
+
var GitRunner = class {
|
|
152
|
+
subprocess;
|
|
153
|
+
executable;
|
|
154
|
+
limits;
|
|
155
|
+
constructor(subprocess, executable, limits) {
|
|
156
|
+
this.subprocess = subprocess;
|
|
157
|
+
this.executable = executable;
|
|
158
|
+
this.limits = limits;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Run `git <args>` to completion.
|
|
162
|
+
* @param args - git arguments; never shell-interpreted.
|
|
163
|
+
* @param options - working directory, extra environment, stdin data, and cancellation.
|
|
164
|
+
* @returns exit facts and collected output.
|
|
165
|
+
* @throws when the command times out, is aborted, or cannot spawn.
|
|
166
|
+
*/
|
|
167
|
+
async run(args, options) {
|
|
168
|
+
const timeout = AbortSignal.timeout(this.limits.timeoutMs);
|
|
169
|
+
const signal = AbortSignal.any([options.signal, timeout]);
|
|
170
|
+
const handle = this.subprocess.spawn({
|
|
171
|
+
argv: [this.executable, ...args],
|
|
172
|
+
cwd: options.cwd,
|
|
173
|
+
stdio: {
|
|
174
|
+
stdin: options.stdin === void 0 ? "ignore" : { data: options.stdin },
|
|
175
|
+
stdout: { maxBytes: options.maxBytes ?? this.limits.outputMaxBytes },
|
|
176
|
+
stderr: { maxBytes: STDERR_TAIL_BYTES }
|
|
177
|
+
},
|
|
178
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
179
|
+
signal,
|
|
180
|
+
env: {
|
|
181
|
+
GIT_CONFIG_COUNT: "0",
|
|
182
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
183
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
184
|
+
LC_ALL: "C",
|
|
185
|
+
...options.env
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const outcome = await handle.done;
|
|
189
|
+
if (signal.aborted) throw new Error(`git ${args.join(" ")} ${timeout.aborted ? `timed out after ${this.limits.timeoutMs}ms` : "was aborted"}`);
|
|
190
|
+
/* v8 ignore start -- collect-mode stdio always yields both readers. */
|
|
191
|
+
const stdout = handle.collected.stdout?.readFrom(0) ?? {
|
|
192
|
+
text: "",
|
|
193
|
+
lossy: false
|
|
194
|
+
};
|
|
195
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? "";
|
|
196
|
+
/* v8 ignore stop */
|
|
197
|
+
return {
|
|
198
|
+
exitCode: outcome.exitCode,
|
|
199
|
+
stdout: stdout.text,
|
|
200
|
+
stderr,
|
|
201
|
+
truncated: stdout.lossy
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Reject a failed command with its stderr.
|
|
207
|
+
* @param result - settled command facts.
|
|
208
|
+
* @param what - command description for the error message.
|
|
209
|
+
* @returns the same result when it exited zero.
|
|
210
|
+
*/
|
|
211
|
+
function ok(result, what) {
|
|
212
|
+
if (result.exitCode !== 0) throw new Error(`${what} failed: ${result.stderr.trim()}`);
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
/** Whether a filesystem error names a missing path. */
|
|
216
|
+
function isMissing$1(error) {
|
|
217
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Locate the repository enclosing a working directory and prepare the private
|
|
221
|
+
* directory its snapshots write to. The repository's own object store is
|
|
222
|
+
* attached read-only as an alternate, so snapshots read committed content from
|
|
223
|
+
* it and write nothing into it. A private directory that lies inside the work
|
|
224
|
+
* tree, as a temporary root under the workspace does, is excluded from every
|
|
225
|
+
* snapshot. A directory outside any repository yields null; any other git
|
|
226
|
+
* failure throws.
|
|
227
|
+
* @param git - command runner.
|
|
228
|
+
* @param cwd - absolute Session working directory.
|
|
229
|
+
* @param scratch - yields the private directory for snapshot objects and scratch indexes; called only for a located repository.
|
|
230
|
+
* @param signal - cancellation.
|
|
231
|
+
* @returns the repository, or null when the directory is not inside one.
|
|
232
|
+
*/
|
|
233
|
+
async function locateGitWorkspace(git, cwd, scratch, signal) {
|
|
234
|
+
const found = await git.run([
|
|
235
|
+
"rev-parse",
|
|
236
|
+
"--show-toplevel",
|
|
237
|
+
"--absolute-git-dir",
|
|
238
|
+
"--git-path",
|
|
239
|
+
"objects"
|
|
240
|
+
], {
|
|
241
|
+
cwd,
|
|
242
|
+
signal
|
|
243
|
+
});
|
|
244
|
+
if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null;
|
|
245
|
+
const [root, gitDir, repositoryObjects] = ok(found, "git rev-parse").stdout.split("\n").map((line) => resolve(cwd, line));
|
|
246
|
+
const directory = await canonicalPath(await scratch());
|
|
247
|
+
const objects = join(directory, "objects");
|
|
248
|
+
await mkdir(objects, { recursive: true });
|
|
249
|
+
const excludes = isInside(root, directory) ? [toPosix(relative(root, directory))] : [];
|
|
250
|
+
return {
|
|
251
|
+
root,
|
|
252
|
+
gitDir,
|
|
253
|
+
scratch: directory,
|
|
254
|
+
env: {
|
|
255
|
+
GIT_OBJECT_DIRECTORY: objects,
|
|
256
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects
|
|
257
|
+
},
|
|
258
|
+
excludes
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Write the complete work tree, including untracked and modified files but
|
|
263
|
+
* not ignored ones, as a tree object through a private index seeded from the
|
|
264
|
+
* repository's index. New blobs and the tree land in the private object store;
|
|
265
|
+
* the repository's index, object store, work tree, and refs stay unchanged,
|
|
266
|
+
* and an in-progress merge keeps its unmerged entries.
|
|
267
|
+
* @param git - command runner.
|
|
268
|
+
* @param workspace - addressed repository.
|
|
269
|
+
* @param signal - cancellation.
|
|
270
|
+
* @returns the tree object id.
|
|
271
|
+
*/
|
|
272
|
+
async function snapshotTree(git, workspace, signal) {
|
|
273
|
+
const scratch = await mkdtemp(join(workspace.scratch, "index-"));
|
|
274
|
+
try {
|
|
275
|
+
const index = join(scratch, "index");
|
|
276
|
+
await copyFile(join(workspace.gitDir, "index"), index).catch((error) => {
|
|
277
|
+
if (!isMissing$1(error)) throw error;
|
|
278
|
+
});
|
|
279
|
+
const env = {
|
|
280
|
+
...workspace.env,
|
|
281
|
+
GIT_INDEX_FILE: index
|
|
282
|
+
};
|
|
283
|
+
const pathspec = workspace.excludes.length === 0 ? [] : [
|
|
284
|
+
"--",
|
|
285
|
+
".",
|
|
286
|
+
...workspace.excludes.map((path) => `:(exclude)${path}`)
|
|
287
|
+
];
|
|
288
|
+
const added = await git.run([
|
|
289
|
+
"add",
|
|
290
|
+
"--all",
|
|
291
|
+
"--ignore-errors",
|
|
292
|
+
...pathspec
|
|
293
|
+
], {
|
|
294
|
+
cwd: workspace.root,
|
|
295
|
+
env,
|
|
296
|
+
signal
|
|
297
|
+
});
|
|
298
|
+
/* v8 ignore next -- git reports a skipped unreadable file only on hosts whose permissions the tests can revoke. */
|
|
299
|
+
if (added.exitCode !== 1) ok(added, `git add in ${workspace.root}`);
|
|
300
|
+
return ok(await git.run(["write-tree"], {
|
|
301
|
+
cwd: workspace.root,
|
|
302
|
+
env,
|
|
303
|
+
signal
|
|
304
|
+
}), "git write-tree").stdout.trim();
|
|
305
|
+
} finally {
|
|
306
|
+
await rm(scratch, {
|
|
307
|
+
recursive: true,
|
|
308
|
+
force: true
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The blob a snapshot tree holds at one path.
|
|
314
|
+
* @param git - command runner.
|
|
315
|
+
* @param workspace - addressed repository.
|
|
316
|
+
* @param tree - snapshot tree id.
|
|
317
|
+
* @param path - slash-separated path relative to the repository root.
|
|
318
|
+
* @param signal - cancellation.
|
|
319
|
+
* @returns the blob, or null when the tree holds nothing at the path or holds a gitlink or tree there.
|
|
320
|
+
*/
|
|
321
|
+
async function treeBlob(git, workspace, tree, path, signal) {
|
|
322
|
+
const entry = ok(await git.run([
|
|
323
|
+
"ls-tree",
|
|
324
|
+
"-z",
|
|
325
|
+
"-l",
|
|
326
|
+
tree,
|
|
327
|
+
"--",
|
|
328
|
+
path
|
|
329
|
+
], {
|
|
330
|
+
cwd: workspace.root,
|
|
331
|
+
env: {
|
|
332
|
+
...workspace.env,
|
|
333
|
+
GIT_LITERAL_PATHSPECS: "1"
|
|
334
|
+
},
|
|
335
|
+
signal
|
|
336
|
+
}), "git ls-tree").stdout.split("\0")[0];
|
|
337
|
+
const match = /^\d+ (\S+) ([0-9a-f]+) +(\d+)\t/.exec(entry);
|
|
338
|
+
if (match === null || match[1] !== "blob") return null;
|
|
339
|
+
return {
|
|
340
|
+
oid: match[2],
|
|
341
|
+
size: Number(match[3])
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The text of one blob whose size {@link treeBlob} reported within the cap.
|
|
346
|
+
* @param git - command runner.
|
|
347
|
+
* @param workspace - addressed repository.
|
|
348
|
+
* @param oid - blob id.
|
|
349
|
+
* @param maxBytes - inclusive byte cap the caller checked the blob's size against.
|
|
350
|
+
* @param signal - cancellation.
|
|
351
|
+
* @returns the blob decoded as UTF-8.
|
|
352
|
+
*/
|
|
353
|
+
async function blobText(git, workspace, oid, maxBytes, signal) {
|
|
354
|
+
const result = ok(await git.run([
|
|
355
|
+
"cat-file",
|
|
356
|
+
"blob",
|
|
357
|
+
oid
|
|
358
|
+
], {
|
|
359
|
+
cwd: workspace.root,
|
|
360
|
+
env: workspace.env,
|
|
361
|
+
maxBytes,
|
|
362
|
+
signal
|
|
363
|
+
}), "git cat-file");
|
|
364
|
+
/* v8 ignore next -- callers size the blob with treeBlob first; a blob is immutable, so the cap cannot be exceeded here. */
|
|
365
|
+
if (result.truncated) throw new Error(`blob ${oid} exceeds ${maxBytes} bytes`);
|
|
366
|
+
return result.stdout;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Per-file line counts between two snapshot trees, with renames detected.
|
|
370
|
+
* @param git - command runner.
|
|
371
|
+
* @param workspace - addressed repository.
|
|
372
|
+
* @param before - turn-start tree id.
|
|
373
|
+
* @param after - turn-end tree id.
|
|
374
|
+
* @param signal - cancellation.
|
|
375
|
+
* @returns changed files relative to the repository root.
|
|
376
|
+
* @throws when git fails or the output exceeded the cap.
|
|
377
|
+
*/
|
|
378
|
+
async function diffTrees(git, workspace, before, after, signal) {
|
|
379
|
+
if (before === after) return [];
|
|
380
|
+
const result = ok(await git.run([
|
|
381
|
+
"diff-tree",
|
|
382
|
+
"-r",
|
|
383
|
+
"-M",
|
|
384
|
+
"-z",
|
|
385
|
+
"--numstat",
|
|
386
|
+
before,
|
|
387
|
+
after
|
|
388
|
+
], {
|
|
389
|
+
cwd: workspace.root,
|
|
390
|
+
env: workspace.env,
|
|
391
|
+
signal
|
|
392
|
+
}), "git diff-tree");
|
|
393
|
+
if (result.truncated) throw new Error("git diff-tree output exceeded the configured cap");
|
|
394
|
+
return parseNumstat(result.stdout);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Work-tree directories the index records as gitlinks: nested repositories and
|
|
398
|
+
* submodules, whose contents snapshots never descend into and `check-ignore`
|
|
399
|
+
* refuses to classify.
|
|
400
|
+
* @param git - command runner.
|
|
401
|
+
* @param workspace - addressed repository.
|
|
402
|
+
* @param signal - cancellation.
|
|
403
|
+
* @returns slash-separated gitlink paths relative to the repository root.
|
|
404
|
+
*/
|
|
405
|
+
async function gitlinkPaths(git, workspace, signal) {
|
|
406
|
+
const result = ok(await git.run([
|
|
407
|
+
"ls-files",
|
|
408
|
+
"-z",
|
|
409
|
+
"--stage"
|
|
410
|
+
], {
|
|
411
|
+
cwd: workspace.root,
|
|
412
|
+
env: workspace.env,
|
|
413
|
+
signal
|
|
414
|
+
}), "git ls-files");
|
|
415
|
+
const links = /* @__PURE__ */ new Set();
|
|
416
|
+
for (const entry of result.stdout.split("\0")) if (entry.startsWith("160000 ")) links.add(entry.slice(entry.indexOf(" ") + 1));
|
|
417
|
+
return links;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The subset of work-tree paths that the repository ignores. Tracked files
|
|
421
|
+
* are never reported, so a tracked file matching an ignore pattern still
|
|
422
|
+
* counts as covered by snapshots.
|
|
423
|
+
* @param git - command runner.
|
|
424
|
+
* @param workspace - addressed repository.
|
|
425
|
+
* @param paths - slash-separated paths relative to the repository root.
|
|
426
|
+
* @param signal - cancellation.
|
|
427
|
+
* @returns the ignored members of `paths`.
|
|
428
|
+
*/
|
|
429
|
+
async function ignoredPaths(git, workspace, paths, signal) {
|
|
430
|
+
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
431
|
+
const result = await git.run([
|
|
432
|
+
"check-ignore",
|
|
433
|
+
"-z",
|
|
434
|
+
"--stdin"
|
|
435
|
+
], {
|
|
436
|
+
cwd: workspace.root,
|
|
437
|
+
env: workspace.env,
|
|
438
|
+
stdin: `${paths.join("\0")}\0`,
|
|
439
|
+
signal
|
|
440
|
+
});
|
|
441
|
+
if (result.exitCode === 1) return /* @__PURE__ */ new Set();
|
|
442
|
+
return new Set(ok(result, "git check-ignore").stdout.split("\0").filter((path) => path !== ""));
|
|
443
|
+
}
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region lib/types/capture.js
|
|
446
|
+
/**
|
|
447
|
+
* Whole-file captures around file-tool edits: the content of a path before
|
|
448
|
+
* the turn's first mutation of it and at turn end, stored content-addressed
|
|
449
|
+
* under the Session's temporary directory so both sides of a comparison
|
|
450
|
+
* survive later edits without depending on git.
|
|
451
|
+
*/
|
|
452
|
+
/** Bytes git inspects for a NUL byte before treating content as binary. */
|
|
453
|
+
const BINARY_PROBE_BYTES = 8e3;
|
|
454
|
+
/** Whether a filesystem error names a missing path. */
|
|
455
|
+
function isMissing(error) {
|
|
456
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Store the current content of one path. At most `maxBytes + 1` bytes are
|
|
460
|
+
* read, so a file that grows past the cap while it is read costs no more
|
|
461
|
+
* memory than the cap.
|
|
462
|
+
* @param absolute - canonical absolute path of the file.
|
|
463
|
+
* @param directory - directory holding content-addressed copies; created when missing.
|
|
464
|
+
* @param maxBytes - inclusive byte cap on a stored copy.
|
|
465
|
+
* @returns the capture, or undefined for a path that is neither absent nor a regular file.
|
|
466
|
+
* @throws when the path cannot be opened or read for a reason other than absence, or the copy cannot be written.
|
|
467
|
+
*/
|
|
468
|
+
async function captureFile(absolute, directory, maxBytes) {
|
|
469
|
+
let handle;
|
|
470
|
+
try {
|
|
471
|
+
handle = await open(absolute, "r");
|
|
472
|
+
} catch (error) {
|
|
473
|
+
/* v8 ignore next -- an unopenable present file needs permissions the tests cannot revoke on every host. */
|
|
474
|
+
if (!isMissing(error)) throw error;
|
|
475
|
+
return { kind: "absent" };
|
|
476
|
+
}
|
|
477
|
+
let bytes;
|
|
478
|
+
try {
|
|
479
|
+
if (!(await handle.stat()).isFile()) return void 0;
|
|
480
|
+
const probe = Buffer.allocUnsafe(maxBytes + 1);
|
|
481
|
+
let length = 0;
|
|
482
|
+
while (length < probe.length) {
|
|
483
|
+
const { bytesRead } = await handle.read(probe, length, probe.length - length, length);
|
|
484
|
+
if (bytesRead === 0) break;
|
|
485
|
+
length += bytesRead;
|
|
486
|
+
}
|
|
487
|
+
if (length > maxBytes) return { kind: "oversized" };
|
|
488
|
+
bytes = probe.subarray(0, length);
|
|
489
|
+
} finally {
|
|
490
|
+
await handle.close();
|
|
491
|
+
}
|
|
492
|
+
const file = join(directory, createHash("sha1").update(bytes).digest("hex"));
|
|
493
|
+
await mkdir(directory, { recursive: true });
|
|
494
|
+
await writeFile(file, bytes, { flag: "wx" }).catch((error) => {
|
|
495
|
+
/* v8 ignore next -- a copy that cannot be written needs a directory the tests cannot make unwritable on every host. */
|
|
496
|
+
if (error.code !== "EEXIST") throw error;
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
kind: "file",
|
|
500
|
+
file,
|
|
501
|
+
binary: bytes.subarray(0, BINARY_PROBE_BYTES).includes(0)
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Whether two captures are known to hold the same content. Two absent sides
|
|
506
|
+
* are the same; two stored copies are the same when their bytes hash alike;
|
|
507
|
+
* an oversized side is never known to match anything, since its content was
|
|
508
|
+
* not read.
|
|
509
|
+
* @param a - one side.
|
|
510
|
+
* @param b - the other side.
|
|
511
|
+
* @returns true only when both sides are known to match.
|
|
512
|
+
*/
|
|
513
|
+
function sameCapture(a, b) {
|
|
514
|
+
if (a.kind === "absent" || b.kind === "absent") return a.kind === b.kind;
|
|
515
|
+
return a.kind === "file" && b.kind === "file" && a.file === b.file;
|
|
516
|
+
}
|
|
517
|
+
/** A non-blank string, or undefined. */
|
|
518
|
+
function text(value) {
|
|
519
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The path a first-party file-tool call is about to mutate: `write`, `edit`,
|
|
523
|
+
* and the mutating `str_replace_editor` commands. Other tools, reads, and
|
|
524
|
+
* incomplete arguments yield undefined.
|
|
525
|
+
* @param name - wire tool name.
|
|
526
|
+
* @param args - parsed call arguments.
|
|
527
|
+
* @returns the model-facing path, or undefined.
|
|
528
|
+
*/
|
|
529
|
+
function mutationPath(name, args) {
|
|
530
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return void 0;
|
|
531
|
+
const record = args;
|
|
532
|
+
switch (name) {
|
|
533
|
+
case "write": return typeof record.content === "string" ? text(record.file_path) : void 0;
|
|
534
|
+
case "edit": return typeof record.old_string === "string" && typeof record.new_string === "string" ? text(record.file_path) : void 0;
|
|
535
|
+
case "str_replace_editor": return record.command === "create" || record.command === "str_replace" || record.command === "insert" ? text(record.path) : void 0;
|
|
536
|
+
default: return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region lib/types/compare.js
|
|
541
|
+
/** Line comparison of two whole-file texts, bounded by a timeout that degrades to whole-file replacement. */
|
|
542
|
+
/** Context lines around each change, the unified-diff default. */
|
|
543
|
+
const CONTEXT_LINES = 3;
|
|
544
|
+
/**
|
|
545
|
+
* A side's text with every line terminated, so the last line compares by
|
|
546
|
+
* content alone and empty text reads as no lines rather than one empty line.
|
|
547
|
+
*/
|
|
548
|
+
function terminated(text) {
|
|
549
|
+
return text === "" || text.endsWith("\n") ? text : `${text}\n`;
|
|
550
|
+
}
|
|
551
|
+
/** Content lines of a terminated text; empty text is zero lines. */
|
|
552
|
+
function lines(text) {
|
|
553
|
+
return text === "" ? [] : text.slice(0, -1).split("\n");
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Compare two texts line by line. A side that is null means the file did not
|
|
557
|
+
* exist. A comparison exceeding `timeoutMs` yields one hunk that deletes every
|
|
558
|
+
* old line and adds every new line.
|
|
559
|
+
* @param before - turn-start text, or null.
|
|
560
|
+
* @param after - turn-end text, or null.
|
|
561
|
+
* @param timeoutMs - milliseconds the line comparison may run.
|
|
562
|
+
* @returns hunks and totals; no hunks when both sides hold the same lines.
|
|
563
|
+
*/
|
|
564
|
+
function compareText(before, after, timeoutMs) {
|
|
565
|
+
const oldText = terminated(before ?? "");
|
|
566
|
+
const newText = terminated(after ?? "");
|
|
567
|
+
const patch = structuredPatch("", "", oldText, newText, void 0, void 0, {
|
|
568
|
+
context: CONTEXT_LINES,
|
|
569
|
+
timeout: timeoutMs
|
|
570
|
+
});
|
|
571
|
+
let hunks;
|
|
572
|
+
let coarse = false;
|
|
573
|
+
if (patch === void 0) {
|
|
574
|
+
coarse = true;
|
|
575
|
+
const oldLines = lines(oldText);
|
|
576
|
+
const newLines = lines(newText);
|
|
577
|
+
hunks = [{
|
|
578
|
+
oldStart: 1,
|
|
579
|
+
oldLines: oldLines.length,
|
|
580
|
+
newStart: 1,
|
|
581
|
+
newLines: newLines.length,
|
|
582
|
+
lines: [...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`)]
|
|
583
|
+
}];
|
|
584
|
+
} else hunks = patch.hunks.map(({ oldStart, oldLines, newStart, newLines, lines: body }) => ({
|
|
585
|
+
oldStart,
|
|
586
|
+
oldLines,
|
|
587
|
+
newStart,
|
|
588
|
+
newLines,
|
|
589
|
+
lines: body
|
|
590
|
+
}));
|
|
591
|
+
let added = 0;
|
|
592
|
+
let deleted = 0;
|
|
593
|
+
for (const hunk of hunks) for (const line of hunk.lines) if (line.startsWith("+")) added += 1;
|
|
594
|
+
else if (line.startsWith("-")) deleted += 1;
|
|
595
|
+
return {
|
|
596
|
+
hunks,
|
|
597
|
+
coarse,
|
|
598
|
+
added,
|
|
599
|
+
deleted
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region lib/types/recorder.js
|
|
604
|
+
/** Per-Session turn recorder: snapshots, captures around file-tool edits, the turn-end diff, and the records kept until disposal. */
|
|
605
|
+
function freshState(turn) {
|
|
606
|
+
return {
|
|
607
|
+
turn,
|
|
608
|
+
baseline: null,
|
|
609
|
+
captures: /* @__PURE__ */ new Map(),
|
|
610
|
+
lastToolResultSeq: -1,
|
|
611
|
+
attemptedAfterSeq: -1,
|
|
612
|
+
recordedAfterSeq: -1
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/** A snapshot side larger than the byte cap. */
|
|
616
|
+
const OVERSIZED = Symbol("oversized");
|
|
617
|
+
/**
|
|
618
|
+
* Serializes one Session's recording work: the turn-start snapshot, the
|
|
619
|
+
* whole-file capture before each file-tool mutation, the turn-end snapshot
|
|
620
|
+
* with its diff, and the appended `workspace/changes` event whose summary and
|
|
621
|
+
* comparisons this recorder keeps. Snapshot objects and captured copies live in
|
|
622
|
+
* a temporary directory owned by the recorder; disposal removes it together
|
|
623
|
+
* with the summaries. Tool execution waits for pending work so a snapshot or
|
|
624
|
+
* capture never races a mutation. A working directory outside any repository,
|
|
625
|
+
* or a Host without git, gets no snapshot; its summary lists the files the file
|
|
626
|
+
* tools changed.
|
|
627
|
+
*/
|
|
628
|
+
var TurnRecorder = class {
|
|
629
|
+
session;
|
|
630
|
+
cwd;
|
|
631
|
+
env;
|
|
632
|
+
chain = Promise.resolve();
|
|
633
|
+
/** The open turn; before the first `turn/start` it is an empty placeholder no event can match. */
|
|
634
|
+
state = freshState(0);
|
|
635
|
+
/** Canonical paths, resolved by the first turn. */
|
|
636
|
+
paths;
|
|
637
|
+
/** The located repository, reused across turns once found; null keeps retrying each turn. */
|
|
638
|
+
repository = null;
|
|
639
|
+
/** Temporary directory holding this Session's snapshot objects, scratch indexes, and captured copies. */
|
|
640
|
+
scratch;
|
|
641
|
+
/** Records by the sequence of the event that announced them. */
|
|
642
|
+
records = /* @__PURE__ */ new Map();
|
|
643
|
+
lifetime = new AbortController();
|
|
644
|
+
constructor(session, cwd, env) {
|
|
645
|
+
this.session = session;
|
|
646
|
+
this.cwd = cwd;
|
|
647
|
+
this.env = env;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Open a turn with fresh per-turn state and queue its baseline snapshot.
|
|
651
|
+
* @param turn - the turn number from `turn/start`.
|
|
652
|
+
*/
|
|
653
|
+
start(turn) {
|
|
654
|
+
const state = freshState(turn);
|
|
655
|
+
this.state = state;
|
|
656
|
+
this.enqueue(async (signal) => {
|
|
657
|
+
try {
|
|
658
|
+
this.paths ??= {
|
|
659
|
+
cwd: await realpath(this.cwd),
|
|
660
|
+
home: await canonicalPath(homedir()),
|
|
661
|
+
temporaryRoots: await temporaryRoots()
|
|
662
|
+
};
|
|
663
|
+
const repository = await this.locate(this.paths.cwd, signal);
|
|
664
|
+
if (repository === null) return;
|
|
665
|
+
const tree = await snapshotTree(repository.git, repository.workspace, signal);
|
|
666
|
+
state.baseline = {
|
|
667
|
+
...repository,
|
|
668
|
+
tree
|
|
669
|
+
};
|
|
670
|
+
} catch (error) {
|
|
671
|
+
state.baseline = "failed";
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Queue the capture of the path a file tool is about to mutate, before the
|
|
678
|
+
* tool runs; only the turn's first mutation of a path captures it. Await
|
|
679
|
+
* {@link settled} afterwards so the tool cannot overtake the capture.
|
|
680
|
+
* @param name - wire tool name.
|
|
681
|
+
* @param args - parsed call arguments.
|
|
682
|
+
*/
|
|
683
|
+
capture(name, args) {
|
|
684
|
+
const path = mutationPath(name, args);
|
|
685
|
+
if (path === void 0) return;
|
|
686
|
+
const state = this.state;
|
|
687
|
+
this.enqueue(async () => {
|
|
688
|
+
const paths = this.paths;
|
|
689
|
+
if (paths === void 0) return;
|
|
690
|
+
const absolute = await canonicalPath(resolve(paths.cwd, path));
|
|
691
|
+
if (state.captures.has(absolute)) return;
|
|
692
|
+
const capture = await captureFile(absolute, join(await this.scratchDir(), "captures"), this.env.maxFileBytes);
|
|
693
|
+
if (capture !== void 0) state.captures.set(absolute, capture);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Remember a settled tool result, so a record after `turn/end` covers it.
|
|
698
|
+
* @param event - the appended `tool/result` event.
|
|
699
|
+
*/
|
|
700
|
+
observe(event) {
|
|
701
|
+
const state = this.state;
|
|
702
|
+
if (event.data.turn === state.turn) state.lastToolResultSeq = event.seq;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Record the turn's changes inside the turn, before `turn/end` commits.
|
|
706
|
+
* @param turn - the stopping turn.
|
|
707
|
+
* @returns after the event is appended or the attempt failed.
|
|
708
|
+
*/
|
|
709
|
+
stopping(turn) {
|
|
710
|
+
const state = this.state;
|
|
711
|
+
if (turn !== state.turn) return Promise.resolve();
|
|
712
|
+
return this.enqueue((signal) => this.record(state, signal));
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Record after `turn/end` unless a record was already attempted after the turn's last tool result.
|
|
716
|
+
* @param turn - the turn number from `turn/end`.
|
|
717
|
+
*/
|
|
718
|
+
end(turn) {
|
|
719
|
+
const state = this.state;
|
|
720
|
+
if (turn !== state.turn || state.attemptedAfterSeq >= state.lastToolResultSeq) return;
|
|
721
|
+
this.enqueue((signal) => this.record(state, signal));
|
|
722
|
+
}
|
|
723
|
+
/** Resolves once every queued snapshot, capture, and record has settled. */
|
|
724
|
+
settled() {
|
|
725
|
+
return this.chain;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The summary announced by one `workspace/changes` event of this Session.
|
|
729
|
+
* @param seq - the event's sequence number.
|
|
730
|
+
* @returns the summary, or undefined for a sequence this recorder did not announce.
|
|
731
|
+
*/
|
|
732
|
+
summary(seq) {
|
|
733
|
+
return this.records.get(seq)?.summary;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Every file this Session's recorded turns changed, folded by path.
|
|
737
|
+
*
|
|
738
|
+
* The per-file counts and flags come from the LATEST turn that listed the
|
|
739
|
+
* file, so a rewrite reads as the delta it left rather than as the churn of
|
|
740
|
+
* every rewrite; `turns` carries that churn. The Session totals come from
|
|
741
|
+
* each turn's own complete counts, which include files that turn's list cap
|
|
742
|
+
* omitted.
|
|
743
|
+
* @returns the folded list in `display` order, empty before the first recorded turn.
|
|
744
|
+
*/
|
|
745
|
+
sessionChanges() {
|
|
746
|
+
const folded = /* @__PURE__ */ new Map();
|
|
747
|
+
let added = 0;
|
|
748
|
+
let deleted = 0;
|
|
749
|
+
for (const [seq, record] of [...this.records].sort(([left], [right]) => left - right)) {
|
|
750
|
+
const { turn, files, added: turnAdded, deleted: turnDeleted } = record.summary;
|
|
751
|
+
added += turnAdded;
|
|
752
|
+
deleted += turnDeleted;
|
|
753
|
+
files.forEach((file, index) => {
|
|
754
|
+
const previous = folded.get(file.path);
|
|
755
|
+
folded.set(file.path, {
|
|
756
|
+
path: file.path,
|
|
757
|
+
display: file.display,
|
|
758
|
+
added: file.added,
|
|
759
|
+
deleted: file.deleted,
|
|
760
|
+
turns: (previous?.turns ?? 0) + 1,
|
|
761
|
+
lastTurn: turn,
|
|
762
|
+
lastSeq: seq,
|
|
763
|
+
lastIndex: index,
|
|
764
|
+
...file.binary === true ? { binary: true } : {},
|
|
765
|
+
...file.oversized === true ? { oversized: true } : {}
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
const files = [...folded.values()].sort((left, right) => left.display < right.display ? -1 : left.display > right.display ? 1 : 0);
|
|
770
|
+
return {
|
|
771
|
+
cwd: this.cwd,
|
|
772
|
+
files,
|
|
773
|
+
total: files.length,
|
|
774
|
+
added,
|
|
775
|
+
deleted
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Compare one listed file's contents at turn start and turn end.
|
|
780
|
+
* @param seq - the announcing event's sequence number.
|
|
781
|
+
* @param index - the file's index in the summary's `files`.
|
|
782
|
+
* @param signal - cancels the reads.
|
|
783
|
+
* @returns the comparison, or undefined for an unknown sequence or index, or once disposed.
|
|
784
|
+
* @throws when a read fails while the recorder lives.
|
|
785
|
+
*/
|
|
786
|
+
async diff(seq, index, signal) {
|
|
787
|
+
const record = this.records.get(seq);
|
|
788
|
+
const file = record?.summary.files[index];
|
|
789
|
+
const sources = record?.sources[index];
|
|
790
|
+
if (file === void 0 || sources === void 0) return void 0;
|
|
791
|
+
const { path, display } = file;
|
|
792
|
+
if (sources.refusal !== void 0) return {
|
|
793
|
+
kind: sources.refusal,
|
|
794
|
+
path,
|
|
795
|
+
display
|
|
796
|
+
};
|
|
797
|
+
const combined = AbortSignal.any([signal, this.lifetime.signal]);
|
|
798
|
+
try {
|
|
799
|
+
const [before, after] = await Promise.all([this.readSide(sources.before, combined), this.readSide(sources.after, combined)]);
|
|
800
|
+
if (before === OVERSIZED || after === OVERSIZED) return {
|
|
801
|
+
kind: "oversized",
|
|
802
|
+
path,
|
|
803
|
+
display
|
|
804
|
+
};
|
|
805
|
+
const { hunks, coarse } = compareText(before, after, this.env.diffTimeoutMs);
|
|
806
|
+
return {
|
|
807
|
+
kind: "text",
|
|
808
|
+
path,
|
|
809
|
+
display,
|
|
810
|
+
before: before !== null,
|
|
811
|
+
after: after !== null,
|
|
812
|
+
hunks,
|
|
813
|
+
coarse
|
|
814
|
+
};
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (this.lifetime.signal.aborted) return void 0;
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Abort queued work, forget every record, and remove the temporary directory.
|
|
822
|
+
* @returns once the temporary directory is gone.
|
|
823
|
+
*/
|
|
824
|
+
async dispose() {
|
|
825
|
+
this.lifetime.abort();
|
|
826
|
+
this.records.clear();
|
|
827
|
+
await this.chain;
|
|
828
|
+
if (this.scratch !== void 0) await rm(await this.scratch, {
|
|
829
|
+
recursive: true,
|
|
830
|
+
force: true
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
enqueue(task) {
|
|
834
|
+
const run = this.chain.then(async () => {
|
|
835
|
+
if (this.lifetime.signal.aborted) return;
|
|
836
|
+
try {
|
|
837
|
+
await task(this.lifetime.signal);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.warnUnlessDisposed(error);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
this.chain = run;
|
|
843
|
+
return run;
|
|
844
|
+
}
|
|
845
|
+
/** A failure after disposal is expected cancellation and stays silent. */
|
|
846
|
+
warnUnlessDisposed(error) {
|
|
847
|
+
if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`);
|
|
848
|
+
}
|
|
849
|
+
/** This Session's temporary directory, created on first use. */
|
|
850
|
+
scratchDir() {
|
|
851
|
+
this.scratch ??= mkdtemp(join(this.env.tempRoot, "qilin-workspace-changes-"));
|
|
852
|
+
return this.scratch;
|
|
853
|
+
}
|
|
854
|
+
/** The repository enclosing the working directory, located once; null keeps retrying each turn. */
|
|
855
|
+
async locate(cwd, signal) {
|
|
856
|
+
if (this.repository !== null) return this.repository;
|
|
857
|
+
const git = await this.env.git;
|
|
858
|
+
if (git === null) return null;
|
|
859
|
+
const workspace = await locateGitWorkspace(git, cwd, () => this.scratchDir(), signal);
|
|
860
|
+
if (workspace === null) return null;
|
|
861
|
+
this.repository = {
|
|
862
|
+
git,
|
|
863
|
+
workspace
|
|
864
|
+
};
|
|
865
|
+
return this.repository;
|
|
866
|
+
}
|
|
867
|
+
/** One side's text, null for an absent file, or {@link OVERSIZED} for a snapshot side beyond the byte cap. */
|
|
868
|
+
async readSide(source, signal) {
|
|
869
|
+
switch (source.kind) {
|
|
870
|
+
case "absent": return null;
|
|
871
|
+
case "file": return readFile(source.file, {
|
|
872
|
+
encoding: "utf8",
|
|
873
|
+
signal
|
|
874
|
+
});
|
|
875
|
+
case "snapshot": {
|
|
876
|
+
const { git, workspace } = source.repository;
|
|
877
|
+
const blob = await treeBlob(git, workspace, source.tree, source.path, signal);
|
|
878
|
+
if (blob === null) return null;
|
|
879
|
+
if (blob.size > this.env.maxFileBytes) return OVERSIZED;
|
|
880
|
+
return blobText(git, workspace, blob.oid, this.env.maxFileBytes, signal);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async record(state, signal) {
|
|
885
|
+
const paths = this.paths;
|
|
886
|
+
const { baseline } = state;
|
|
887
|
+
if (paths === void 0 || baseline === "failed" || state.lastToolResultSeq < 0) return;
|
|
888
|
+
state.attemptedAfterSeq = state.lastToolResultSeq;
|
|
889
|
+
const root = baseline?.workspace.root ?? paths.cwd;
|
|
890
|
+
const listed = /* @__PURE__ */ new Map();
|
|
891
|
+
let snapshot;
|
|
892
|
+
if (baseline !== null) {
|
|
893
|
+
const after = await snapshotTree(baseline.git, baseline.workspace, signal);
|
|
894
|
+
snapshot = {
|
|
895
|
+
before: baseline.tree,
|
|
896
|
+
after
|
|
897
|
+
};
|
|
898
|
+
const repository = {
|
|
899
|
+
git: baseline.git,
|
|
900
|
+
workspace: baseline.workspace
|
|
901
|
+
};
|
|
902
|
+
for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) {
|
|
903
|
+
const absolute = resolve(root, entry.path);
|
|
904
|
+
listed.set(absolute, {
|
|
905
|
+
file: changedFile(paths, root, absolute, entry),
|
|
906
|
+
sources: entry.binary ? { refusal: "binary" } : {
|
|
907
|
+
before: {
|
|
908
|
+
kind: "snapshot",
|
|
909
|
+
repository,
|
|
910
|
+
tree: baseline.tree,
|
|
911
|
+
path: entry.oldPath ?? entry.path
|
|
912
|
+
},
|
|
913
|
+
after: {
|
|
914
|
+
kind: "snapshot",
|
|
915
|
+
repository,
|
|
916
|
+
tree: after,
|
|
917
|
+
path: entry.path
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const captured = [...state.captures.keys()].filter((absolute) => !listed.has(absolute));
|
|
924
|
+
const workTreePath = (absolute) => toPosix(relative(root, absolute));
|
|
925
|
+
let inWorkspace = captured.filter((absolute) => isInside(root, absolute));
|
|
926
|
+
if (baseline !== null && inWorkspace.length > 0) {
|
|
927
|
+
const gitlinks = await gitlinkPaths(baseline.git, baseline.workspace, signal);
|
|
928
|
+
inWorkspace = inWorkspace.filter((absolute) => ![...gitlinks].some((link) => isInside(resolve(root, link), absolute)));
|
|
929
|
+
}
|
|
930
|
+
const uncoveredInWorkspace = baseline === null ? new Set(inWorkspace.map(workTreePath)) : await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal);
|
|
931
|
+
for (const absolute of captured) {
|
|
932
|
+
if (!(isInside(root, absolute) ? uncoveredInWorkspace.has(workTreePath(absolute)) : !isTemporaryPath(absolute, paths.temporaryRoots))) continue;
|
|
933
|
+
const before = state.captures.get(absolute);
|
|
934
|
+
const after = await captureFile(absolute, join(await this.scratchDir(), "captures"), this.env.maxFileBytes);
|
|
935
|
+
if (after === void 0 || sameCapture(before, after)) continue;
|
|
936
|
+
listed.set(absolute, await this.compared(paths, root, absolute, before, after));
|
|
937
|
+
}
|
|
938
|
+
const sorted = [...listed.values()].sort((a, b) => compareDisplay(a.file, b.file));
|
|
939
|
+
if (sorted.length === 0 && state.recordedAfterSeq < 0) return;
|
|
940
|
+
const event = this.session.append("workspace/changes", { turn: state.turn });
|
|
941
|
+
const kept = sorted.slice(0, this.env.maxFiles);
|
|
942
|
+
this.records.set(event.seq, {
|
|
943
|
+
summary: {
|
|
944
|
+
turn: state.turn,
|
|
945
|
+
cwd: this.cwd,
|
|
946
|
+
files: kept.map((entry) => entry.file),
|
|
947
|
+
total: sorted.length,
|
|
948
|
+
added: sorted.reduce((sum, entry) => sum + entry.file.added, 0),
|
|
949
|
+
deleted: sorted.reduce((sum, entry) => sum + entry.file.deleted, 0),
|
|
950
|
+
...snapshot === void 0 ? {} : { snapshot }
|
|
951
|
+
},
|
|
952
|
+
sources: kept.map((entry) => entry.sources)
|
|
953
|
+
});
|
|
954
|
+
state.recordedAfterSeq = event.seq;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The listing of a captured pair: an oversized side lists the file without
|
|
958
|
+
* counts and refuses its comparison, a binary side likewise, and two text
|
|
959
|
+
* sides carry the counts of their line comparison.
|
|
960
|
+
*/
|
|
961
|
+
async compared(paths, root, absolute, before, after) {
|
|
962
|
+
const list = (counts, sources) => ({
|
|
963
|
+
file: changedFile(paths, root, absolute, counts),
|
|
964
|
+
sources
|
|
965
|
+
});
|
|
966
|
+
if (before.kind === "oversized" || after.kind === "oversized") return list({
|
|
967
|
+
added: 0,
|
|
968
|
+
deleted: 0,
|
|
969
|
+
binary: false,
|
|
970
|
+
oversized: true
|
|
971
|
+
}, { refusal: "oversized" });
|
|
972
|
+
if (isBinary(before) || isBinary(after)) return list({
|
|
973
|
+
added: 0,
|
|
974
|
+
deleted: 0,
|
|
975
|
+
binary: true
|
|
976
|
+
}, { refusal: "binary" });
|
|
977
|
+
const text = async (side) => side.kind === "file" ? readFile(side.file, "utf8") : null;
|
|
978
|
+
const { added, deleted } = compareText(await text(before), await text(after), this.env.diffTimeoutMs);
|
|
979
|
+
return list({
|
|
980
|
+
added,
|
|
981
|
+
deleted,
|
|
982
|
+
binary: false
|
|
983
|
+
}, {
|
|
984
|
+
before,
|
|
985
|
+
after
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
/** Whether a captured side holds binary content. */
|
|
990
|
+
function isBinary(capture) {
|
|
991
|
+
return capture.kind === "file" && capture.binary;
|
|
992
|
+
}
|
|
993
|
+
function changedFile({ cwd, home }, root, absolute, counts) {
|
|
994
|
+
return {
|
|
995
|
+
path: durablePathOf(absolute, cwd),
|
|
996
|
+
display: displayPathOf(absolute, cwd, root, home),
|
|
997
|
+
added: counts.added,
|
|
998
|
+
deleted: counts.deleted,
|
|
999
|
+
...counts.binary ? { binary: true } : {},
|
|
1000
|
+
...counts.oversized === true ? { oversized: true } : {}
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region lib/types/index.js
|
|
1005
|
+
/**
|
|
1006
|
+
* Summarizes the files each top-level turn changed from git working-tree
|
|
1007
|
+
* snapshots taken at turn start and turn end, plus whole-file captures taken
|
|
1008
|
+
* around each file-tool edit for paths git does not cover, and serves each
|
|
1009
|
+
* listed file's before-and-after comparison on demand. Each summary is
|
|
1010
|
+
* announced by a `workspace/changes` Session event that carries only the turn
|
|
1011
|
+
* number; summaries and comparisons are served through the `workspaceChanges`
|
|
1012
|
+
* service until the Session is disposed. Outside a git repository, or without
|
|
1013
|
+
* git, the summary lists file-tool edits only.
|
|
1014
|
+
*/
|
|
1015
|
+
/** Stable Loader identity. */
|
|
1016
|
+
const name = "workspace-changes";
|
|
1017
|
+
/** Services used to run git and observe turns. */
|
|
1018
|
+
const inject = ["subprocess"];
|
|
1019
|
+
/** Schemastery validation for {@link Config}. */
|
|
1020
|
+
const Config = z.object({
|
|
1021
|
+
timeoutMs: z.number().default(3e4),
|
|
1022
|
+
outputMaxBytes: z.number().default(8 * 1024 * 1024),
|
|
1023
|
+
maxFiles: z.number().default(500),
|
|
1024
|
+
maxFileBytes: z.number().default(2 * 1024 * 1024),
|
|
1025
|
+
diffTimeoutMs: z.number().default(100)
|
|
1026
|
+
});
|
|
1027
|
+
function eligible(session) {
|
|
1028
|
+
const { cwd, origin, delegationDepth } = session.header;
|
|
1029
|
+
return origin === "subagent" || (delegationDepth ?? 0) > 0 ? void 0 : cwd;
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Resolve the git executable once. On macOS the Xcode stub at `/usr/bin/git`
|
|
1033
|
+
* opens an installer dialog instead of running, so it counts as absent until
|
|
1034
|
+
* developer tools are selected.
|
|
1035
|
+
* @param ctx - subprocess capability.
|
|
1036
|
+
* @param signal - plugin lifetime.
|
|
1037
|
+
* @returns the executable path, or null when git is unavailable.
|
|
1038
|
+
*/
|
|
1039
|
+
async function resolveGit(ctx, signal) {
|
|
1040
|
+
let executable;
|
|
1041
|
+
try {
|
|
1042
|
+
executable = await ctx.subprocess.resolveExecutable("git", void 0, signal);
|
|
1043
|
+
} catch {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
if (process.platform !== "darwin" || executable !== "/usr/bin/git") return executable;
|
|
1047
|
+
return (await ctx.subprocess.spawn({
|
|
1048
|
+
argv: ["/usr/bin/xcode-select", "-p"],
|
|
1049
|
+
cwd: homedir(),
|
|
1050
|
+
stdio: {
|
|
1051
|
+
stdin: "ignore",
|
|
1052
|
+
stdout: { maxBytes: 4096 },
|
|
1053
|
+
stderr: { maxBytes: 4096 }
|
|
1054
|
+
},
|
|
1055
|
+
graceMs: 1e3,
|
|
1056
|
+
signal
|
|
1057
|
+
}).done.catch(() => ({ exitCode: null }))).exitCode === 0 ? executable : null;
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Observe top-level turns of every Session with a working directory, capture
|
|
1061
|
+
* file-tool edits, announce change summaries, and serve them with their
|
|
1062
|
+
* comparisons as `workspaceChanges`.
|
|
1063
|
+
* @param ctx - host context with `subprocess`.
|
|
1064
|
+
* @param config - validated bounds.
|
|
1065
|
+
*/
|
|
1066
|
+
function apply(ctx, config) {
|
|
1067
|
+
for (const [field, value] of [
|
|
1068
|
+
["timeoutMs", config.timeoutMs],
|
|
1069
|
+
["outputMaxBytes", config.outputMaxBytes],
|
|
1070
|
+
["maxFiles", config.maxFiles],
|
|
1071
|
+
["maxFileBytes", config.maxFileBytes],
|
|
1072
|
+
["diffTimeoutMs", config.diffTimeoutMs]
|
|
1073
|
+
]) if (!Number.isSafeInteger(value) || value < 1) throw new Error(`workspace-changes requires a positive integer ${field}`);
|
|
1074
|
+
const lifetime = new AbortController();
|
|
1075
|
+
const recorders = /* @__PURE__ */ new Map();
|
|
1076
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1077
|
+
const forget = (session) => {
|
|
1078
|
+
const recorder = recorders.get(session);
|
|
1079
|
+
recorders.delete(session);
|
|
1080
|
+
byId.delete(session.id);
|
|
1081
|
+
return recorder?.dispose() ?? Promise.resolve();
|
|
1082
|
+
};
|
|
1083
|
+
ctx.effect(() => async () => {
|
|
1084
|
+
lifetime.abort();
|
|
1085
|
+
await Promise.all([...recorders.keys()].map(forget));
|
|
1086
|
+
});
|
|
1087
|
+
ctx.provide("workspaceChanges", {
|
|
1088
|
+
summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq),
|
|
1089
|
+
session: (sessionId) => {
|
|
1090
|
+
const recorder = byId.get(sessionId);
|
|
1091
|
+
if (recorder === void 0) return void 0;
|
|
1092
|
+
const folded = recorder.sessionChanges();
|
|
1093
|
+
return folded.files.length === 0 ? void 0 : folded;
|
|
1094
|
+
},
|
|
1095
|
+
diff: (sessionId, seq, index, signal) => byId.get(sessionId)?.diff(seq, index, signal) ?? Promise.resolve(void 0)
|
|
1096
|
+
});
|
|
1097
|
+
let runner;
|
|
1098
|
+
const gitRunner = () => {
|
|
1099
|
+
runner ??= resolveGit(ctx, lifetime.signal).then((executable) => {
|
|
1100
|
+
if (executable === null) {
|
|
1101
|
+
ctx.logger.info("workspace-changes: git is unavailable; only file-tool edits are summarized");
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
return new GitRunner(ctx.subprocess, executable, {
|
|
1105
|
+
timeoutMs: config.timeoutMs,
|
|
1106
|
+
outputMaxBytes: config.outputMaxBytes
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
return runner;
|
|
1110
|
+
};
|
|
1111
|
+
const recorderFor = (session, cwd) => {
|
|
1112
|
+
let recorder = recorders.get(session);
|
|
1113
|
+
if (recorder === void 0) {
|
|
1114
|
+
recorder = new TurnRecorder(session, cwd, {
|
|
1115
|
+
git: gitRunner(),
|
|
1116
|
+
tempRoot: tmpdir(),
|
|
1117
|
+
maxFiles: config.maxFiles,
|
|
1118
|
+
maxFileBytes: config.maxFileBytes,
|
|
1119
|
+
diffTimeoutMs: config.diffTimeoutMs,
|
|
1120
|
+
warn: (message) => {
|
|
1121
|
+
ctx.logger.warn(message);
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
recorders.set(session, recorder);
|
|
1125
|
+
byId.set(session.id, recorder);
|
|
1126
|
+
}
|
|
1127
|
+
return recorder;
|
|
1128
|
+
};
|
|
1129
|
+
ctx.on("session/event", (session, event) => {
|
|
1130
|
+
if (event.type === "turn/start") {
|
|
1131
|
+
const cwd = eligible(session);
|
|
1132
|
+
if (cwd !== void 0) recorderFor(session, cwd).start(event.data.turn);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (event.type === "tool/result") recorders.get(session)?.observe(event);
|
|
1136
|
+
else if (event.type === "turn/end") recorders.get(session)?.end(event.data.turn);
|
|
1137
|
+
});
|
|
1138
|
+
ctx.on("session/disposed", (session) => {
|
|
1139
|
+
forget(session);
|
|
1140
|
+
});
|
|
1141
|
+
ctx.on("agent/turn-stopping", async ({ agent, turn }) => {
|
|
1142
|
+
await recorders.get(agent.session)?.stopping(turn);
|
|
1143
|
+
});
|
|
1144
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
1145
|
+
const session = exec.agent?.session;
|
|
1146
|
+
const recorder = session === void 0 ? void 0 : recorders.get(session);
|
|
1147
|
+
if (recorder !== void 0) {
|
|
1148
|
+
recorder.capture(exec.name, exec.arguments);
|
|
1149
|
+
await recorder.settled();
|
|
1150
|
+
}
|
|
1151
|
+
return next();
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
//#endregion
|
|
1155
|
+
export { Config, apply, inject, name };
|