@jitsusama/agentic-harness.core 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command/tokenize.js +51 -5
- package/dist/internal/file-lock.d.ts +30 -0
- package/dist/internal/file-lock.js +183 -0
- package/dist/internal/quest/bash-effects.d.ts +26 -0
- package/dist/internal/quest/bash-effects.js +477 -0
- package/dist/internal/quest/bash-write.d.ts +37 -0
- package/dist/internal/quest/bash-write.js +311 -37
- package/dist/internal/quest/io.js +47 -22
- package/dist/internal/quest/record-audit.d.ts +43 -0
- package/dist/internal/quest/record-audit.js +126 -0
- package/dist/internal/quest/record-gate.d.ts +36 -0
- package/dist/internal/quest/record-gate.js +101 -0
- package/dist/internal/quest/record.d.ts +112 -0
- package/dist/internal/quest/record.js +284 -0
- package/dist/internal/quest/workspace.d.ts +32 -0
- package/dist/internal/quest/workspace.js +51 -0
- package/dist/review/ask/store.js +37 -19
- package/package.json +7 -1
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* does not trip the gate. This is a nudge toward the right stage,
|
|
7
7
|
* not a security boundary.
|
|
8
8
|
*/
|
|
9
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
9
10
|
import { tokenize } from "../../command/index.js";
|
|
10
11
|
import { stripHeredocBodies, stripShellData, unquote, } from "../../shell/index.js";
|
|
12
|
+
import { createdBy, removedBy, unwrap } from "./bash-effects.js";
|
|
11
13
|
/** Git subcommands that change repository or working-tree state. */
|
|
12
14
|
const GIT_MUTATING = /\bgit(?:\s+(?:-c\s+\S+|-C\s+\S+|--git-dir=\S+|--work-tree=\S+|--no-pager))*\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|switch|restore|am|format-patch)\b/i;
|
|
13
15
|
/** Shell patterns that write to the filesystem via redirection or in-place edit. */
|
|
@@ -69,6 +71,24 @@ export function bashWriteTargets(command) {
|
|
|
69
71
|
return;
|
|
70
72
|
targets.push(value);
|
|
71
73
|
};
|
|
74
|
+
for (const target of patternTargets(skeleton))
|
|
75
|
+
add(target);
|
|
76
|
+
for (const simple of tokenize(command).commands) {
|
|
77
|
+
for (const target of commandTargets(simple))
|
|
78
|
+
add(target);
|
|
79
|
+
}
|
|
80
|
+
return [...new Set(targets)];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Write destinations found by pattern in a data-stripped skeleton, for
|
|
84
|
+
* the grammar the command model declines, such as a loop or a subshell.
|
|
85
|
+
*/
|
|
86
|
+
function patternTargets(skeleton) {
|
|
87
|
+
const found = [];
|
|
88
|
+
const add = (token) => {
|
|
89
|
+
if (token)
|
|
90
|
+
found.push(token);
|
|
91
|
+
};
|
|
72
92
|
// Redirect destinations: the token following > or >>. A leading
|
|
73
93
|
// digit or & marks an fd redirect (2>, &>), which routes a stream
|
|
74
94
|
// rather than naming a content target, so it is skipped.
|
|
@@ -96,55 +116,309 @@ export function bashWriteTargets(command) {
|
|
|
96
116
|
add(token);
|
|
97
117
|
}
|
|
98
118
|
}
|
|
99
|
-
|
|
100
|
-
add(target);
|
|
101
|
-
return [...new Set(targets)];
|
|
119
|
+
return found;
|
|
102
120
|
}
|
|
103
121
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
122
|
+
* Resolve where a bash command writes, in the directory each writing
|
|
123
|
+
* command actually runs in.
|
|
106
124
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
125
|
+
* The session directory is only where the command starts: `cd <tree> &&
|
|
126
|
+
* cat >> x_test.go` writes inside the tree, and reading it against the
|
|
127
|
+
* session directory is how a write in a tracked tree came to be judged as
|
|
128
|
+
* one in the repository the session happened to open. So the command
|
|
129
|
+
* model is walked in order, each `cd` moving the directory for what
|
|
130
|
+
* follows, with the command's own variables filled in.
|
|
112
131
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
132
|
+
* A target whose location the command does not say is reported as
|
|
133
|
+
* unresolved rather than guessed: a `cd` into a command substitution, a
|
|
134
|
+
* variable from outside the command, or a relative target in a subshell
|
|
135
|
+
* or loop that changes directory. A guess judges a file nobody wrote,
|
|
136
|
+
* while an unresolved target can still be checked on disk afterwards.
|
|
137
|
+
*
|
|
138
|
+
* What a command removes is placed the same way and kept apart, since
|
|
139
|
+
* removing a document is a different question from writing one. It is
|
|
140
|
+
* only read where the command model applies; a removal inside a loop or
|
|
141
|
+
* a subshell is left to the check on disk.
|
|
117
142
|
*/
|
|
118
|
-
function
|
|
143
|
+
export function resolveBashWrites(command, place) {
|
|
144
|
+
const skeleton = stripShellData(stripHeredocBodies(command));
|
|
145
|
+
const known = assignmentsIn(skeleton, command);
|
|
146
|
+
const paths = [];
|
|
147
|
+
const removed = [];
|
|
148
|
+
const unresolved = [];
|
|
149
|
+
const locate = (raw, dir, into = paths) => {
|
|
150
|
+
const bare = raw.replace(/^['"]/, "").replace(/['"]$/, "");
|
|
151
|
+
if (!bare)
|
|
152
|
+
return;
|
|
153
|
+
const value = expand(bare, known);
|
|
154
|
+
const absolute = value === undefined ? undefined : absoluteIn(value, dir, place.home);
|
|
155
|
+
if (absolute === undefined)
|
|
156
|
+
unresolved.push(value ?? bare);
|
|
157
|
+
else
|
|
158
|
+
into.push(absolute);
|
|
159
|
+
};
|
|
119
160
|
const line = tokenize(command);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const
|
|
123
|
-
|
|
161
|
+
if (line.supported) {
|
|
162
|
+
let dir = place.cwd;
|
|
163
|
+
for (const simple of line.commands) {
|
|
164
|
+
const argv = argvOf(simple);
|
|
165
|
+
if (argv[0] === "cd") {
|
|
166
|
+
dir = changeDirectory(dir, argv[1], known, place.home);
|
|
124
167
|
continue;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
}
|
|
169
|
+
for (const target of commandTargets(simple))
|
|
170
|
+
locate(target, dir);
|
|
171
|
+
for (const target of removedBy(unwrap(argv))) {
|
|
172
|
+
locate(target, dir, removed);
|
|
173
|
+
}
|
|
128
174
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (/^(g?sed|perl)$/.test(name) && editsInPlace(simple.argv)) {
|
|
137
|
-
found.push(...rest);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Outside the grammar there is no order to follow, so a relative
|
|
178
|
+
// target is only placed when nothing in the command changes directory.
|
|
179
|
+
const moves = /(?:^|[\s;&|(])cd(?:\s|$)/.test(skeleton);
|
|
180
|
+
for (const target of patternTargets(skeleton)) {
|
|
181
|
+
locate(target, moves ? undefined : place.cwd);
|
|
138
182
|
}
|
|
139
183
|
}
|
|
184
|
+
return {
|
|
185
|
+
paths: [...new Set(paths)],
|
|
186
|
+
removed: [...new Set(removed)],
|
|
187
|
+
unresolved: [...new Set(unresolved)],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The directory after `cd <target>`, or undefined once it is no longer
|
|
192
|
+
* knowable: `cd -`, or a target the command's own variables cannot fill.
|
|
193
|
+
*/
|
|
194
|
+
function changeDirectory(dir, target, known, home) {
|
|
195
|
+
if (target === undefined)
|
|
196
|
+
return home;
|
|
197
|
+
if (target === "-")
|
|
198
|
+
return undefined;
|
|
199
|
+
const value = expand(target, known);
|
|
200
|
+
return value === undefined ? undefined : absoluteIn(value, dir, home);
|
|
201
|
+
}
|
|
202
|
+
/** A path made absolute, or undefined when it is relative to nothing known. */
|
|
203
|
+
function absoluteIn(path, dir, home) {
|
|
204
|
+
if (path === "~")
|
|
205
|
+
return home;
|
|
206
|
+
if (path.startsWith("~/"))
|
|
207
|
+
return join(home, path.slice(2));
|
|
208
|
+
if (isAbsolute(path))
|
|
209
|
+
return resolve(path);
|
|
210
|
+
return dir === undefined ? undefined : resolve(dir, path);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* One command's write destinations, read from the command model, where
|
|
214
|
+
* quoting is already understood.
|
|
215
|
+
*
|
|
216
|
+
* The three shapes `patternTargets` also looks for, a redirect's target,
|
|
217
|
+
* `tee`'s file arguments and the file arguments of an in-place editor,
|
|
218
|
+
* plus what a command creates without a redirect (see `createdBy`), read
|
|
219
|
+
* through wrappers such as `nohup` and `env`. A file-descriptor redirect
|
|
220
|
+
* (`2>`, `&>`) is skipped, matching what the pattern pass does, since it
|
|
221
|
+
* routes a stream rather than naming a content target.
|
|
222
|
+
*
|
|
223
|
+
* An editor's script is told apart from its files by reading its options
|
|
224
|
+
* the way the editor does, since a script reported as a file is a path
|
|
225
|
+
* nobody wrote, and one that happens to look like a quest document gets
|
|
226
|
+
* a write refused that never touched it.
|
|
227
|
+
*/
|
|
228
|
+
function commandTargets(simple) {
|
|
229
|
+
const found = [];
|
|
230
|
+
for (const redirect of simple.redirects) {
|
|
231
|
+
if (!redirect.target)
|
|
232
|
+
continue;
|
|
233
|
+
if (/^[0-9&]/.test(redirect.operator))
|
|
234
|
+
continue;
|
|
235
|
+
found.push(unquote(redirect.target.text));
|
|
236
|
+
}
|
|
237
|
+
const argv = unwrap(argvOf(simple));
|
|
238
|
+
const name = argv[0];
|
|
239
|
+
if (!name)
|
|
240
|
+
return found;
|
|
241
|
+
found.push(...createdBy(argv));
|
|
242
|
+
if (name === "tee") {
|
|
243
|
+
found.push(...argv.slice(1).filter((token) => !token.startsWith("-")));
|
|
244
|
+
}
|
|
245
|
+
if (/^g?sed$/.test(name))
|
|
246
|
+
found.push(...sedFiles(argv.slice(1)));
|
|
247
|
+
if (name === "perl")
|
|
248
|
+
found.push(...perlFiles(argv.slice(1)));
|
|
140
249
|
return found;
|
|
141
250
|
}
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
251
|
+
/** Short sed options that take the next word as their value. */
|
|
252
|
+
const SED_VALUED = new Set(["e", "f", "l"]);
|
|
253
|
+
/**
|
|
254
|
+
* The files a sed invocation edits in place, or none when it does not.
|
|
255
|
+
*
|
|
256
|
+
* Both spellings of the in-place flag are read. GNU attaches its optional
|
|
257
|
+
* suffix (`-i.bak`); BSD always takes one as the next word, and that word
|
|
258
|
+
* is only read as a suffix when it looks like one (empty, or starting with
|
|
259
|
+
* a dot), since `sed -i 's/a/b/' f` is the GNU spelling written on a Mac.
|
|
260
|
+
* Without `-e` or `-f`, the first operand is the script.
|
|
261
|
+
*/
|
|
262
|
+
function sedFiles(args) {
|
|
263
|
+
const operands = [];
|
|
264
|
+
let inPlace = false;
|
|
265
|
+
let scripted = false;
|
|
266
|
+
for (let at = 0; at < args.length; at++) {
|
|
267
|
+
const arg = args[at] ?? "";
|
|
268
|
+
if (arg === "--") {
|
|
269
|
+
operands.push(...args.slice(at + 1));
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (arg.startsWith("--")) {
|
|
273
|
+
const [option] = arg.split("=", 1);
|
|
274
|
+
if (option === "--in-place")
|
|
275
|
+
inPlace = true;
|
|
276
|
+
if (option === "--expression" || option === "--file") {
|
|
277
|
+
scripted = true;
|
|
278
|
+
if (!arg.includes("="))
|
|
279
|
+
at++;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
284
|
+
operands.push(arg);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (let char = 1; char < arg.length; char++) {
|
|
288
|
+
const flag = arg[char] ?? "";
|
|
289
|
+
if (flag === "i") {
|
|
290
|
+
inPlace = true;
|
|
291
|
+
const next = args[at + 1];
|
|
292
|
+
const bare = char === arg.length - 1;
|
|
293
|
+
if (bare && next !== undefined && /^(\.|$)/.test(next))
|
|
294
|
+
at++;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
if (SED_VALUED.has(flag)) {
|
|
298
|
+
if (flag !== "l")
|
|
299
|
+
scripted = true;
|
|
300
|
+
if (char === arg.length - 1)
|
|
301
|
+
at++;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!inPlace)
|
|
307
|
+
return [];
|
|
308
|
+
return scripted ? operands : operands.slice(1);
|
|
309
|
+
}
|
|
310
|
+
/** Perl switches whose value is the rest of the word, or the next word. */
|
|
311
|
+
const PERL_VALUED = new Set(["e", "E", "I", "M", "m", "x"]);
|
|
312
|
+
/** Perl switches whose optional value can only be attached. */
|
|
313
|
+
const PERL_ATTACHED = new Set(["i", "l", "0", "C", "d", "D", "F"]);
|
|
314
|
+
/**
|
|
315
|
+
* The files a perl invocation edits in place, or none when it does not.
|
|
316
|
+
*
|
|
317
|
+
* Switches cluster (`-pi`, `-ne`), so each word is read one switch at a
|
|
318
|
+
* time. Without `-e` or `-E`, the first operand is the program file.
|
|
319
|
+
*/
|
|
320
|
+
function perlFiles(args) {
|
|
321
|
+
const operands = [];
|
|
322
|
+
let inPlace = false;
|
|
323
|
+
let inline = false;
|
|
324
|
+
for (let at = 0; at < args.length; at++) {
|
|
325
|
+
const arg = args[at] ?? "";
|
|
326
|
+
if (arg === "--") {
|
|
327
|
+
operands.push(...args.slice(at + 1));
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
331
|
+
operands.push(...args.slice(at));
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
for (let char = 1; char < arg.length; char++) {
|
|
335
|
+
const flag = arg[char] ?? "";
|
|
336
|
+
if (flag === "i")
|
|
337
|
+
inPlace = true;
|
|
338
|
+
if (PERL_VALUED.has(flag)) {
|
|
339
|
+
if (flag === "e" || flag === "E")
|
|
340
|
+
inline = true;
|
|
341
|
+
if (char === arg.length - 1)
|
|
342
|
+
at++;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
if (PERL_ATTACHED.has(flag))
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (!inPlace)
|
|
350
|
+
return [];
|
|
351
|
+
return inline ? operands : operands.slice(1);
|
|
352
|
+
}
|
|
353
|
+
/** A command's arguments as the command receives them: braces expanded, quotes removed. */
|
|
354
|
+
function argvOf(simple) {
|
|
355
|
+
return simple.argv.flatMap((word) => expandBraces(word.text).map(unquote));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* A word with its brace lists expanded, the way bash does before a
|
|
359
|
+
* command sees its arguments: `lab/{a,b}` is `lab/a lab/b`.
|
|
360
|
+
*
|
|
361
|
+
* Only an unquoted list with a comma at its top level expands, so
|
|
362
|
+
* `'{a,b}'`, `{solo}` and a variable's `${NAME}` stay as written. A
|
|
363
|
+
* sequence such as `{1..3}` is left alone too; nothing that writes into a
|
|
364
|
+
* quest folder has been seen to use one.
|
|
365
|
+
*/
|
|
366
|
+
function expandBraces(word) {
|
|
367
|
+
for (let open = 0; open < word.length; open = skipQuoted(word, open) + 1) {
|
|
368
|
+
if (word[open] !== "{" || word[open - 1] === "$")
|
|
369
|
+
continue;
|
|
370
|
+
const list = braceList(word, open);
|
|
371
|
+
if (!list)
|
|
372
|
+
continue;
|
|
373
|
+
const prefix = word.slice(0, open);
|
|
374
|
+
const suffix = word.slice(list.close + 1);
|
|
375
|
+
return list.parts.flatMap((part) => expandBraces(prefix + part + suffix));
|
|
376
|
+
}
|
|
377
|
+
return [word];
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The alternatives of the brace list opening at `open`, and where it
|
|
381
|
+
* closes, or undefined when the braces hold no top-level comma or never
|
|
382
|
+
* close.
|
|
383
|
+
*/
|
|
384
|
+
function braceList(word, open) {
|
|
385
|
+
const parts = [];
|
|
386
|
+
let depth = 0;
|
|
387
|
+
let start = open + 1;
|
|
388
|
+
for (let at = open; at < word.length; at = skipQuoted(word, at) + 1) {
|
|
389
|
+
const char = word[at];
|
|
390
|
+
if (char === "{")
|
|
391
|
+
depth++;
|
|
392
|
+
else if (char === "}" && --depth === 0) {
|
|
393
|
+
if (parts.length === 0)
|
|
394
|
+
return undefined;
|
|
395
|
+
parts.push(word.slice(start, at));
|
|
396
|
+
return { parts, close: at };
|
|
397
|
+
}
|
|
398
|
+
else if (char === "," && depth === 1) {
|
|
399
|
+
parts.push(word.slice(start, at));
|
|
400
|
+
start = at + 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The last index of the quoted span or escape starting at `at`, or `at`
|
|
407
|
+
* itself when nothing starts there, so a scan can step past quoted text.
|
|
408
|
+
*/
|
|
409
|
+
function skipQuoted(word, at) {
|
|
410
|
+
const char = word[at];
|
|
411
|
+
if (char === "\\")
|
|
412
|
+
return at + 1;
|
|
413
|
+
if (char !== "'" && char !== '"')
|
|
414
|
+
return at;
|
|
415
|
+
let end = at + 1;
|
|
416
|
+
while (end < word.length && word[end] !== char) {
|
|
417
|
+
if (char === '"' && word[end] === "\\")
|
|
418
|
+
end++;
|
|
419
|
+
end++;
|
|
420
|
+
}
|
|
421
|
+
return end;
|
|
148
422
|
}
|
|
149
423
|
/**
|
|
150
424
|
* The variables a command assigns to itself, last assignment winning.
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* that bypasses it can still write through. We rely on
|
|
27
27
|
* every README write going through `withQuestLock`.
|
|
28
28
|
*/
|
|
29
|
-
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
29
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
/** Lock-acquire retry budget per call. */
|
|
32
32
|
const LOCK_TIMEOUT_MS = 5000;
|
|
@@ -102,30 +102,52 @@ function isProcessAlive(pid) {
|
|
|
102
102
|
return err.code === "EPERM";
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Take the lock over if its holder has gone or it is too old to be a
|
|
107
|
+
* write in progress.
|
|
108
|
+
*
|
|
109
|
+
* A lock with no readable record is judged by its file's age alone: a
|
|
110
|
+
* holder creates the lock and then writes its record, so for a moment
|
|
111
|
+
* every live lock is empty, and treating that as abandoned took locks
|
|
112
|
+
* from their holders. Taking over moves the lock aside and checks the
|
|
113
|
+
* move took the one judged stale; deleting by name could delete a lock
|
|
114
|
+
* another writer made after taking over first.
|
|
115
|
+
*/
|
|
105
116
|
function tryStealStaleLock(lockPath, now) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
catch {
|
|
113
|
-
// Another process beat us to the cleanup; that's fine.
|
|
114
|
-
}
|
|
117
|
+
let judged;
|
|
118
|
+
try {
|
|
119
|
+
judged = statSync(lockPath);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Released since the failed create; the next attempt takes it.
|
|
115
123
|
return true;
|
|
116
124
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
125
|
+
const record = readLockRecord(lockPath);
|
|
126
|
+
const stale = record
|
|
127
|
+
? now - record.startedAt >= STALE_LOCK_MS || !isProcessAlive(record.pid)
|
|
128
|
+
: now - judged.mtimeMs >= STALE_LOCK_MS;
|
|
129
|
+
if (!stale)
|
|
119
130
|
return false;
|
|
131
|
+
const aside = `${lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
120
132
|
try {
|
|
121
|
-
|
|
122
|
-
return true;
|
|
133
|
+
renameSync(lockPath, aside);
|
|
123
134
|
}
|
|
124
135
|
catch {
|
|
125
|
-
//
|
|
126
|
-
// next loop iteration retry the acquire.
|
|
136
|
+
// Somebody else took it over first; the next attempt sees theirs.
|
|
127
137
|
return false;
|
|
128
138
|
}
|
|
139
|
+
if (statSync(aside).ino !== judged.ino) {
|
|
140
|
+
// Moved a fresh lock another writer made after taking over first.
|
|
141
|
+
// Put it back, unless a third already made one, which then stands.
|
|
142
|
+
try {
|
|
143
|
+
linkSync(aside, lockPath);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// EEXIST: the newer lock stands, as above.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
unlinkSync(aside);
|
|
150
|
+
return true;
|
|
129
151
|
}
|
|
130
152
|
function acquireLock(lockPath) {
|
|
131
153
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
@@ -139,7 +161,7 @@ function acquireLock(lockPath) {
|
|
|
139
161
|
};
|
|
140
162
|
writeSync(fd, JSON.stringify(payload));
|
|
141
163
|
fsyncSync(fd);
|
|
142
|
-
return fd;
|
|
164
|
+
return { fd, ino: fstatSync(fd).ino };
|
|
143
165
|
}
|
|
144
166
|
catch (err) {
|
|
145
167
|
const code = err.code;
|
|
@@ -165,15 +187,18 @@ function acquireLock(lockPath) {
|
|
|
165
187
|
}
|
|
166
188
|
}
|
|
167
189
|
}
|
|
168
|
-
function releaseLock(lockPath,
|
|
190
|
+
function releaseLock(lockPath, held) {
|
|
169
191
|
try {
|
|
170
|
-
closeSync(fd);
|
|
192
|
+
closeSync(held.fd);
|
|
171
193
|
}
|
|
172
194
|
catch {
|
|
173
195
|
// Already closed; cleanup of the path below still runs.
|
|
174
196
|
}
|
|
175
197
|
try {
|
|
176
|
-
|
|
198
|
+
// Only this holder's own lock: one taken over from it belongs to
|
|
199
|
+
// whoever made the new one.
|
|
200
|
+
if (statSync(lockPath).ino === held.ino)
|
|
201
|
+
unlinkSync(lockPath);
|
|
177
202
|
}
|
|
178
203
|
catch {
|
|
179
204
|
// Lock file was stolen out from under us or never
|
|
@@ -203,12 +228,12 @@ export function withQuestLock(questDir, fn) {
|
|
|
203
228
|
// Stat failed; the acquire loop handles it.
|
|
204
229
|
}
|
|
205
230
|
}
|
|
206
|
-
const
|
|
231
|
+
const held = acquireLock(lockPath);
|
|
207
232
|
try {
|
|
208
233
|
return fn();
|
|
209
234
|
}
|
|
210
235
|
finally {
|
|
211
|
-
releaseLock(lockPath,
|
|
236
|
+
releaseLock(lockPath, held);
|
|
212
237
|
}
|
|
213
238
|
}
|
|
214
239
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record's rules, read from disk rather than from a single write.
|
|
3
|
+
*
|
|
4
|
+
* The gate judges a write by its path before it lands, which cannot see
|
|
5
|
+
* what a command made without saying so, how big a file turned out, or
|
|
6
|
+
* whether a document cites it. The audit reads the whole quest folder
|
|
7
|
+
* afterwards and reports what the record holds that it should not: the
|
|
8
|
+
* check after each write, conclude's refusal and the migration all ask
|
|
9
|
+
* it the same question.
|
|
10
|
+
*/
|
|
11
|
+
import { type Stats } from "node:fs";
|
|
12
|
+
import { type AttachmentFile, type AttachmentProblem } from "./record.js";
|
|
13
|
+
/** What a quest folder holds that its record should not. */
|
|
14
|
+
export interface RecordAudit {
|
|
15
|
+
/** Paths outside the record, each by its outermost folder or file. */
|
|
16
|
+
readonly strays: string[];
|
|
17
|
+
/** Attachments that break the limits, and how. */
|
|
18
|
+
readonly attachments: {
|
|
19
|
+
rel: string;
|
|
20
|
+
problem: AttachmentProblem;
|
|
21
|
+
}[];
|
|
22
|
+
/** Links into `attachments/` that name no attachment. */
|
|
23
|
+
readonly broken: {
|
|
24
|
+
document: string;
|
|
25
|
+
target: string;
|
|
26
|
+
}[];
|
|
27
|
+
/** Attachments no document cites, which the backup leaves out. */
|
|
28
|
+
readonly uncited: string[];
|
|
29
|
+
}
|
|
30
|
+
/** Read a quest folder and report what its record should not hold. */
|
|
31
|
+
export declare function auditQuestRecord(questDir: string): RecordAudit;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a record may conclude as it is. Uncited attachments do not
|
|
34
|
+
* stop it: the backup leaves them out, which is their only cost.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isRecordSound(audit: RecordAudit): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* What the attachment rules need to know about one path on disk, named
|
|
39
|
+
* by `rel` as it would sit in the record. The migration asks this of a
|
|
40
|
+
* file before it moves, so it is judged the same way the audit would
|
|
41
|
+
* judge it once it lands.
|
|
42
|
+
*/
|
|
43
|
+
export declare function attachmentFileAt(path: string, rel: string, stats?: Stats): AttachmentFile;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record's rules, read from disk rather than from a single write.
|
|
3
|
+
*
|
|
4
|
+
* The gate judges a write by its path before it lands, which cannot see
|
|
5
|
+
* what a command made without saying so, how big a file turned out, or
|
|
6
|
+
* whether a document cites it. The audit reads the whole quest folder
|
|
7
|
+
* afterwards and reports what the record holds that it should not: the
|
|
8
|
+
* check after each write, conclude's refusal and the migration all ask
|
|
9
|
+
* it the same question.
|
|
10
|
+
*/
|
|
11
|
+
import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync, } from "node:fs";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import { ATTACHMENT_LIMITS, attachmentCitations, attachmentProblem, questRecordPath, } from "./record.js";
|
|
14
|
+
/**
|
|
15
|
+
* Files the quest machinery and the system keep in a quest folder: the
|
|
16
|
+
* quest lock, the atomic writer's half-written files and Finder's notes.
|
|
17
|
+
*/
|
|
18
|
+
function isMachinery(name) {
|
|
19
|
+
return (name === ".quest.lock" ||
|
|
20
|
+
name === ".DS_Store" ||
|
|
21
|
+
/^\..+\.tmp-\d+-/.test(name));
|
|
22
|
+
}
|
|
23
|
+
/** Read a quest folder and report what its record should not hold. */
|
|
24
|
+
export function auditQuestRecord(questDir) {
|
|
25
|
+
const questsRoot = join(questDir, "..");
|
|
26
|
+
const quest = basename(questDir);
|
|
27
|
+
const strays = [];
|
|
28
|
+
const problems = [];
|
|
29
|
+
const documents = [];
|
|
30
|
+
const attachments = [];
|
|
31
|
+
const walk = (dir) => {
|
|
32
|
+
for (const name of readdirSync(dir).sort()) {
|
|
33
|
+
if (isMachinery(name))
|
|
34
|
+
continue;
|
|
35
|
+
const path = join(dir, name);
|
|
36
|
+
const place = questRecordPath(questsRoot, path);
|
|
37
|
+
if (!place)
|
|
38
|
+
continue;
|
|
39
|
+
const stats = lstatSync(path);
|
|
40
|
+
switch (place.place) {
|
|
41
|
+
case "stray":
|
|
42
|
+
strays.push(place.rel);
|
|
43
|
+
break;
|
|
44
|
+
case "readme":
|
|
45
|
+
case "document":
|
|
46
|
+
if (stats.isFile()) {
|
|
47
|
+
documents.push({
|
|
48
|
+
rel: place.rel,
|
|
49
|
+
text: readFileSync(path, "utf8"),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
strays.push(place.rel);
|
|
54
|
+
break;
|
|
55
|
+
case "folder":
|
|
56
|
+
if (stats.isDirectory())
|
|
57
|
+
walk(path);
|
|
58
|
+
else
|
|
59
|
+
strays.push(place.rel);
|
|
60
|
+
break;
|
|
61
|
+
case "attachment": {
|
|
62
|
+
const problem = attachmentProblem(attachmentFileAt(path, place.rel, stats));
|
|
63
|
+
if (problem)
|
|
64
|
+
problems.push({ rel: place.rel, problem });
|
|
65
|
+
else if (stats.isDirectory())
|
|
66
|
+
walk(path);
|
|
67
|
+
else
|
|
68
|
+
attachments.push(place.rel);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(questDir);
|
|
75
|
+
const { cited, broken } = attachmentCitations(quest, documents, attachments);
|
|
76
|
+
return {
|
|
77
|
+
strays: strays.sort(),
|
|
78
|
+
attachments: problems.sort((a, b) => compare(a.rel, b.rel)),
|
|
79
|
+
broken,
|
|
80
|
+
uncited: attachments.filter((rel) => !cited.has(rel)).sort(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a record may conclude as it is. Uncited attachments do not
|
|
85
|
+
* stop it: the backup leaves them out, which is their only cost.
|
|
86
|
+
*/
|
|
87
|
+
export function isRecordSound(audit) {
|
|
88
|
+
return (audit.strays.length === 0 &&
|
|
89
|
+
audit.attachments.length === 0 &&
|
|
90
|
+
audit.broken.length === 0);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* What the attachment rules need to know about one path on disk, named
|
|
94
|
+
* by `rel` as it would sit in the record. The migration asks this of a
|
|
95
|
+
* file before it moves, so it is judged the same way the audit would
|
|
96
|
+
* judge it once it lands.
|
|
97
|
+
*/
|
|
98
|
+
export function attachmentFileAt(path, rel, stats = lstatSync(path)) {
|
|
99
|
+
const kind = stats.isSymbolicLink()
|
|
100
|
+
? "symlink"
|
|
101
|
+
: stats.isDirectory()
|
|
102
|
+
? "directory"
|
|
103
|
+
: stats.isFile()
|
|
104
|
+
? "file"
|
|
105
|
+
: "other";
|
|
106
|
+
return {
|
|
107
|
+
rel,
|
|
108
|
+
kind,
|
|
109
|
+
size: stats.size,
|
|
110
|
+
head: kind === "file" ? headOf(path) : new Uint8Array(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** A file's first bytes, as many as the text sniff reads. */
|
|
114
|
+
function headOf(path) {
|
|
115
|
+
const buffer = new Uint8Array(ATTACHMENT_LIMITS.sniffBytes);
|
|
116
|
+
const fd = openSync(path, "r");
|
|
117
|
+
try {
|
|
118
|
+
return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function compare(a, b) {
|
|
125
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
126
|
+
}
|