mixdog 0.9.150 → 0.9.151
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 +28 -11
- package/package.json +1 -1
- package/src/headless-exec.mjs +16 -3
- package/src/headless-exec.test.mjs +44 -1
- package/src/rules/shared/30-exploration.md +2 -1
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +7 -6
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +5 -2
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/tui/session/turn.mjs +12 -0
|
@@ -69,6 +69,10 @@ import { runGrepPathFanout } from './lib/grep-path-fanout.mjs';
|
|
|
69
69
|
import { runGrepPatternFanout } from './lib/grep-pattern-fanout.mjs';
|
|
70
70
|
import { runGrepChunkMerge } from './lib/grep-chunk-merge.mjs';
|
|
71
71
|
import { runGrepFixedStringFallback } from './lib/grep-fixed-fallback.mjs';
|
|
72
|
+
import {
|
|
73
|
+
MAX_RESCUE_BYTES as GREP_RESCUE_MAX_BYTES,
|
|
74
|
+
runGrepSingleFileRescue,
|
|
75
|
+
} from './lib/grep-single-file-rescue.mjs';
|
|
72
76
|
|
|
73
77
|
// A single glob string may pack multiple filters
|
|
74
78
|
// separated by whitespace or commas, e.g. "*.ts,*.tsx" or "*.ts *.tsx". Split
|
|
@@ -95,6 +99,15 @@ function _grepDefaultHeadLimit() {
|
|
|
95
99
|
return parsed > 0 ? parsed : 250;
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
// One explicit FILE is scanned directly by the native server — there is no tree
|
|
103
|
+
// to walk — so a long deadline on a file scope can only ever be spent QUEUED
|
|
104
|
+
// behind another request's walk. A scope rooted in a virtual filesystem
|
|
105
|
+
// (/proc, /sys) is exactly that walk, and it starved the searches behind it:
|
|
106
|
+
// a six-line /proc/self/status failed after 17.7s while a plain read of the
|
|
107
|
+
// same file answered in 7ms. Bound the file case to a fraction of the turn and
|
|
108
|
+
// let the JS rescue below answer it instead of spending the turn waiting.
|
|
109
|
+
const SINGLE_FILE_SEARCH_DEADLINE_MS = 2_500;
|
|
110
|
+
|
|
98
111
|
function _grepContextCharBudget(options = {}) {
|
|
99
112
|
const explicit = Number(options?._grepContextCharBudget);
|
|
100
113
|
if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
|
|
@@ -542,7 +555,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
542
555
|
fileType,
|
|
543
556
|
onlyMatching: false,
|
|
544
557
|
});
|
|
545
|
-
const probeOut = await runRg(probeArgs,
|
|
558
|
+
const probeOut = await runRg(probeArgs, searchExecOptions);
|
|
546
559
|
if (String(probeOut).split('\n').some(Boolean)) {
|
|
547
560
|
return ' (case-insensitive would match — try -i)';
|
|
548
561
|
}
|
|
@@ -560,6 +573,18 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
560
573
|
: null;
|
|
561
574
|
|
|
562
575
|
const GREP_CONTENT_HARD_CAP = 300;
|
|
576
|
+
const singleFileScope = grepStat.isFile();
|
|
577
|
+
// Bound ONLY the file sizes the JS rescue below can actually answer. A
|
|
578
|
+
// bigger file keeps the full budget, so the short deadline can never cost
|
|
579
|
+
// a caller an answer it would otherwise have received. (A procfs file
|
|
580
|
+
// reports size 0 and is therefore always bounded — exactly the case that
|
|
581
|
+
// motivated the bound.)
|
|
582
|
+
const boundSingleFile = singleFileScope && Number(grepStat.size) <= GREP_RESCUE_MAX_BYTES;
|
|
583
|
+
const searchExecOptions = {
|
|
584
|
+
cwd: rgSpawnCwd,
|
|
585
|
+
signal: sharedSignal,
|
|
586
|
+
...(boundSingleFile ? { timeout: SINGLE_FILE_SEARCH_DEADLINE_MS } : {}),
|
|
587
|
+
};
|
|
563
588
|
try {
|
|
564
589
|
const callerExplicitUnlimited = headLimitCoerced === 0;
|
|
565
590
|
const effectiveHeadLimit = headLimit === Infinity
|
|
@@ -620,7 +645,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
620
645
|
let ctxPartialSuffix = '';
|
|
621
646
|
const streamed = await runRgWindowedLines(
|
|
622
647
|
anchorArgs,
|
|
623
|
-
|
|
648
|
+
searchExecOptions,
|
|
624
649
|
{ offset: 0, limit: anchorCap, summaryLimit: 0 },
|
|
625
650
|
);
|
|
626
651
|
let ctxTotalKnown = streamed.complete;
|
|
@@ -689,7 +714,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
689
714
|
? GREP_CONTEXT_LINE_HARD_CAP
|
|
690
715
|
: Math.min(GREP_CONTEXT_LINE_HARD_CAP, Math.max(200, blockBudget * Math.max(1, perBlock) + 8));
|
|
691
716
|
let ctxPartialSuffix = '';
|
|
692
|
-
const streamed = await runRgWindowedLines(rgArgs,
|
|
717
|
+
const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, { offset: 0, limit: lineCap, summaryLimit: 0 });
|
|
693
718
|
const allLines = streamed.lines;
|
|
694
719
|
let ctxTotalKnown = streamed.complete;
|
|
695
720
|
if (streamed.partial) {
|
|
@@ -739,7 +764,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
739
764
|
let rgPartialSuffix = '';
|
|
740
765
|
if (effectiveHeadLimit !== Infinity) {
|
|
741
766
|
const summaryLimit = outputMode === 'content' ? 120 : 0;
|
|
742
|
-
const streamed = await runRgWindowedLines(rgArgs,
|
|
767
|
+
const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, {
|
|
743
768
|
offset,
|
|
744
769
|
limit: effectiveHeadLimit,
|
|
745
770
|
summaryLimit,
|
|
@@ -757,7 +782,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
757
782
|
: '\n[warning] rg exit 2 (partial results)';
|
|
758
783
|
}
|
|
759
784
|
} else {
|
|
760
|
-
const stdout = await runRg(rgArgs,
|
|
785
|
+
const stdout = await runRg(rgArgs, searchExecOptions);
|
|
761
786
|
const allLines = String(stdout).split('\n').filter(Boolean);
|
|
762
787
|
windowed = offset > 0 ? allLines.slice(offset) : allLines;
|
|
763
788
|
totalWindowed = windowed.length;
|
|
@@ -857,6 +882,36 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
|
|
|
857
882
|
}
|
|
858
883
|
catch (err) {
|
|
859
884
|
if (sharedSignal?.aborted) throw err;
|
|
885
|
+
// A file scope the native server could not serve is answered by reading
|
|
886
|
+
// the file; only a scanner that cannot reproduce the request declines.
|
|
887
|
+
const nativeMissed = err?.code === 'NATIVE_SEARCH_TIMEOUT'
|
|
888
|
+
|| err?.code === 'NATIVE_SEARCH_UNAVAILABLE'
|
|
889
|
+
|| err?.code === 'NATIVE_SEARCH_UNSUPPORTED';
|
|
890
|
+
if (nativeMissed && singleFileScope) {
|
|
891
|
+
const rescued = await runGrepSingleFileRescue({
|
|
892
|
+
filePath: grepResolvedPath,
|
|
893
|
+
searchPath,
|
|
894
|
+
patterns,
|
|
895
|
+
caseInsensitive,
|
|
896
|
+
multilineMode,
|
|
897
|
+
onlyMatching: args['-o'] === true,
|
|
898
|
+
fileType,
|
|
899
|
+
outputMode,
|
|
900
|
+
showLineNumbers,
|
|
901
|
+
withFilename: forceGrepFilename,
|
|
902
|
+
filenameOmitted,
|
|
903
|
+
beforeN,
|
|
904
|
+
afterN,
|
|
905
|
+
contextN,
|
|
906
|
+
headLimit,
|
|
907
|
+
offset,
|
|
908
|
+
workDir,
|
|
909
|
+
grepResolvedPath,
|
|
910
|
+
patternCapNote,
|
|
911
|
+
globPatterns: normalizedGlobPatterns,
|
|
912
|
+
});
|
|
913
|
+
if (rescued !== null) return rescued;
|
|
914
|
+
}
|
|
860
915
|
if (isRgRegexParseError(err) && !multilineMode) {
|
|
861
916
|
// Fixed-string rescue lives in lib/grep-fixed-fallback.mjs;
|
|
862
917
|
// null falls through to the original rg error below.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* thin executor; search-tool.mjs re-exports these for unchanged importers.
|
|
5
5
|
*/
|
|
6
6
|
import { statSync } from 'fs';
|
|
7
|
-
import { basename, isAbsolute, join, resolve } from 'path';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
8
8
|
import { findBySuffixStrip, findDirectoryByBasename, findFileByBasename, listSiblings } from './path-diagnostics.mjs';
|
|
9
9
|
import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
|
|
10
10
|
|
|
@@ -18,6 +18,9 @@ import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
|
|
|
18
18
|
// indexed file exists or the glob child is unavailable.
|
|
19
19
|
export async function _suggestIndexedPaths(missingPath, executeChildBuiltinTool, workDir) {
|
|
20
20
|
if (typeof executeChildBuiltinTool !== 'function') return '';
|
|
21
|
+
// An outside path has no indexed twin worth proposing; the project-wide
|
|
22
|
+
// glob would only spend the turn.
|
|
23
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) return '';
|
|
21
24
|
const base = String(missingPath).replace(/\\/g, '/').split('/').pop();
|
|
22
25
|
// Skip when there is no usable basename or it carries glob magic (the
|
|
23
26
|
// pattern, not a literal filename, would not map to a real file).
|
|
@@ -90,6 +93,50 @@ function cachedFileByBasename(workDir, missingPath, cache) {
|
|
|
90
93
|
return cache.fileHits;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
// A missing ABSOLUTE path that resolves outside the project cannot be
|
|
97
|
+
// corrected by anything inside it: `/sys/block/sd*/size` is not a misplaced
|
|
98
|
+
// project file, and scanning the tree for a file named `size` only spends the
|
|
99
|
+
// turn (measured: three such reads burned 17.5s each and left the shared
|
|
100
|
+
// search server saturated for the calls behind them). The honest recovery for
|
|
101
|
+
// an outside path is its own filesystem: name the nearest directory that does
|
|
102
|
+
// exist and what it holds.
|
|
103
|
+
function missingPathIsOutsideProject(workDir, missingPath) {
|
|
104
|
+
const raw = String(missingPath || '');
|
|
105
|
+
if (!raw || !isAbsolute(raw)) return false;
|
|
106
|
+
if (typeof workDir !== 'string' || !workDir) return false;
|
|
107
|
+
const rel = relative(resolve(workDir), resolve(raw));
|
|
108
|
+
return !rel || rel === '..' || rel.startsWith(`..${sep}`) || rel.startsWith('../') || isAbsolute(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nearestExistingRealParent(missingPath) {
|
|
112
|
+
try {
|
|
113
|
+
let dir = dirname(resolve(String(missingPath)));
|
|
114
|
+
for (let depth = 0; depth < 24; depth += 1) {
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(dir).isDirectory()) return dir;
|
|
117
|
+
} catch { /* keep climbing */ }
|
|
118
|
+
const parent = dirname(dir);
|
|
119
|
+
if (!parent || parent === dir) break;
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
} catch { /* fall through */ }
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function outsideProjectNotFoundHint(missingPath, actionVerb) {
|
|
127
|
+
const parent = nearestExistingRealParent(missingPath);
|
|
128
|
+
if (!parent) return ' Not found, and no parent directory of it exists either.';
|
|
129
|
+
const entries = listSiblings(parent, 12).slice(0, 5);
|
|
130
|
+
const where = `"${normalizeOutputPath(parent)}"`;
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
return ` Not found; ${where} exists but is empty — nothing under it to ${actionVerb.toLowerCase()}.`;
|
|
133
|
+
}
|
|
134
|
+
const magic = /[*?[\]{}]/.test(String(missingPath))
|
|
135
|
+
? ' The path carries a glob pattern; name one of those entries directly.'
|
|
136
|
+
: '';
|
|
137
|
+
return ` Not found; the nearest existing directory ${where} holds: ${entries.map((n) => `"${n}"`).join(', ')}.${magic}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
function isDirectoryPathGuess(missingPath) {
|
|
94
141
|
const base = basename(String(missingPath || '').replace(/\\/g, '/'));
|
|
95
142
|
if (!base || /[*?[\]{}]/.test(base)) return false;
|
|
@@ -137,6 +184,9 @@ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENO
|
|
|
137
184
|
|
|
138
185
|
function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT', cache = null) {
|
|
139
186
|
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
|
|
187
|
+
// Redirecting an outside path INTO the project would answer a different
|
|
188
|
+
// question than the one asked.
|
|
189
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) return null;
|
|
140
190
|
const suffixHit = cachedSuffixStrip(workDir, missingPath, cache);
|
|
141
191
|
if (suffixHit) return suffixHit;
|
|
142
192
|
const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
|
|
@@ -183,6 +233,9 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
|
|
|
183
233
|
if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing.replace(/\\/g, '\\'))) {
|
|
184
234
|
return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
|
|
185
235
|
}
|
|
236
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) {
|
|
237
|
+
return outsideProjectNotFoundHint(missingPath, actionVerb);
|
|
238
|
+
}
|
|
186
239
|
if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode, cache)) return '';
|
|
187
240
|
const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
|
|
188
241
|
if (elsewhere.length) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Writing through a symlink must land on the file the link points at and leave
|
|
2
|
+
// the link in place. The atomic rename would otherwise replace the link with a
|
|
3
|
+
// regular file and silently detach layouts like nginx's sites-enabled/default
|
|
4
|
+
// from sites-available/default.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
symlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
|
|
21
|
+
import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
|
|
22
|
+
|
|
23
|
+
function makeDir() {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), 'mixdog-write-symlink-'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Windows without Developer Mode / admin rights refuses symlink creation
|
|
28
|
+
// (EPERM), so the host capability decides whether these cases can run.
|
|
29
|
+
function symlinkSupported(dir) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(join(dir, 'probe.txt'), 'x');
|
|
32
|
+
symlinkSync(join(dir, 'probe.txt'), join(dir, 'probe.link'));
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('symlinkWriteTarget resolves a live link and nothing else', (t) => {
|
|
40
|
+
const dir = makeDir();
|
|
41
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
42
|
+
if (!symlinkSupported(dir)) {
|
|
43
|
+
t.skip('symlink creation is not permitted on this host');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const real = join(dir, 'real.txt');
|
|
47
|
+
const link = join(dir, 'link.txt');
|
|
48
|
+
writeFileSync(real, 'a\n');
|
|
49
|
+
symlinkSync(real, link);
|
|
50
|
+
|
|
51
|
+
assert.equal(symlinkWriteTarget(link), realpathSync(real));
|
|
52
|
+
assert.equal(symlinkWriteTarget(real), null);
|
|
53
|
+
assert.equal(symlinkWriteTarget(join(dir, 'missing.txt')), null);
|
|
54
|
+
// A dangling link has no target to resolve, so the caller keeps its path.
|
|
55
|
+
const dangling = join(dir, 'dangling.link');
|
|
56
|
+
symlinkSync(join(dir, 'gone.txt'), dangling);
|
|
57
|
+
assert.equal(symlinkWriteTarget(dangling), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('atomicWrite writes through a symlink and keeps the link', async (t) => {
|
|
61
|
+
const dir = makeDir();
|
|
62
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
63
|
+
if (!symlinkSupported(dir)) {
|
|
64
|
+
t.skip('symlink creation is not permitted on this host');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const real = join(dir, 'real.conf');
|
|
68
|
+
const link = join(dir, 'link.conf');
|
|
69
|
+
writeFileSync(real, 'old\n');
|
|
70
|
+
symlinkSync(real, link);
|
|
71
|
+
|
|
72
|
+
await atomicWrite(link, 'new\n');
|
|
73
|
+
|
|
74
|
+
assert.equal(readFileSync(real, 'utf8'), 'new\n');
|
|
75
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the expected-target guard still fires when the real file moved', async (t) => {
|
|
79
|
+
const dir = makeDir();
|
|
80
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
81
|
+
if (!symlinkSupported(dir)) {
|
|
82
|
+
t.skip('symlink creation is not permitted on this host');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const real = join(dir, 'guard.conf');
|
|
86
|
+
const link = join(dir, 'guard.link');
|
|
87
|
+
writeFileSync(real, 'old\n');
|
|
88
|
+
symlinkSync(real, link);
|
|
89
|
+
const before = statSync(link);
|
|
90
|
+
|
|
91
|
+
// Somebody else rewrites the real file after the snapshot was taken.
|
|
92
|
+
writeFileSync(real, 'rewritten by another writer\n');
|
|
93
|
+
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => atomicWrite(link, 'mine\n', {
|
|
96
|
+
expectedTargetSnapshot: {
|
|
97
|
+
exists: true,
|
|
98
|
+
size: before.size,
|
|
99
|
+
mtimeMs: before.mtimeMs,
|
|
100
|
+
ctimeMs: before.ctimeMs,
|
|
101
|
+
ino: before.ino,
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
(err) => err?.code === 'ESTALE_TARGET',
|
|
105
|
+
);
|
|
106
|
+
assert.equal(readFileSync(real, 'utf8'), 'rewritten by another writer\n');
|
|
107
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('edit updates through a symlink and keeps the link', async (t) => {
|
|
111
|
+
const dir = makeDir();
|
|
112
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
113
|
+
if (!symlinkSupported(dir)) {
|
|
114
|
+
t.skip('symlink creation is not permitted on this host');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const real = join(dir, 'sites-available.conf');
|
|
118
|
+
const link = join(dir, 'sites-enabled.conf');
|
|
119
|
+
writeFileSync(real, 'root /var/www/html;\nindex index.html;\n');
|
|
120
|
+
symlinkSync(real, link);
|
|
121
|
+
|
|
122
|
+
const result = String(await tryExecuteExternalToolAdapter('edit', {
|
|
123
|
+
file_path: link,
|
|
124
|
+
old_string: 'root /var/www/html;',
|
|
125
|
+
new_string: 'root /usr/share/novnc;',
|
|
126
|
+
}, dir, {}));
|
|
127
|
+
|
|
128
|
+
assert.match(result, /^Updated /);
|
|
129
|
+
assert.equal(readFileSync(real, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
|
|
130
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('write replaces content through a symlink and keeps the link', async (t) => {
|
|
134
|
+
const dir = makeDir();
|
|
135
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
136
|
+
if (!symlinkSupported(dir)) {
|
|
137
|
+
t.skip('symlink creation is not permitted on this host');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const real = join(dir, 'payload.txt');
|
|
141
|
+
const link = join(dir, 'payload.link');
|
|
142
|
+
writeFileSync(real, 'old\n');
|
|
143
|
+
symlinkSync(real, link);
|
|
144
|
+
|
|
145
|
+
const result = String(await tryExecuteExternalToolAdapter('write', {
|
|
146
|
+
file_path: link,
|
|
147
|
+
contents: 'fresh\n',
|
|
148
|
+
}, dir, {}));
|
|
149
|
+
|
|
150
|
+
assert.match(result, /^Updated /);
|
|
151
|
+
assert.equal(readFileSync(real, 'utf8'), 'fresh\n');
|
|
152
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
153
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A sentinel-free cwd that HOLDS one project answers an anchored call by
|
|
2
|
+
// adopting that project; a genuinely ambiguous tree still refuses, but names
|
|
3
|
+
// where to anchor instead.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
import { executeCodeGraphTool } from './dispatch.mjs';
|
|
11
|
+
|
|
12
|
+
const PY = 'class Mean:\n def compute(self):\n return 42\n';
|
|
13
|
+
|
|
14
|
+
function makeRepo(root, name) {
|
|
15
|
+
const repo = join(root, name);
|
|
16
|
+
mkdirSync(join(repo, '.git'), { recursive: true });
|
|
17
|
+
mkdirSync(join(repo, 'tools'), { recursive: true });
|
|
18
|
+
writeFileSync(join(repo, 'tools', 'compute_mean.py'), PY);
|
|
19
|
+
return repo;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('a relative anchor held by exactly one child project adopts that project', async (t) => {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-one-'));
|
|
24
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
25
|
+
makeRepo(root, 'caffe');
|
|
26
|
+
|
|
27
|
+
const out = String(await executeCodeGraphTool(
|
|
28
|
+
'code_graph',
|
|
29
|
+
{ mode: 'symbols', files: 'tools/compute_mean.py' },
|
|
30
|
+
root,
|
|
31
|
+
));
|
|
32
|
+
assert.doesNotMatch(out, /Refusing to index an arbitrary tree/);
|
|
33
|
+
assert.match(out, /compute/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('two child projects holding the same anchor stay refused, with the roots named', async (t) => {
|
|
37
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-many-'));
|
|
38
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
39
|
+
makeRepo(root, 'caffe');
|
|
40
|
+
makeRepo(root, 'caffe-fork');
|
|
41
|
+
|
|
42
|
+
let message = '';
|
|
43
|
+
try {
|
|
44
|
+
await executeCodeGraphTool(
|
|
45
|
+
'code_graph',
|
|
46
|
+
{ mode: 'symbols', files: 'tools/compute_mean.py' },
|
|
47
|
+
root,
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
message = String(error?.message || error);
|
|
51
|
+
}
|
|
52
|
+
assert.match(message, /Refusing to index an arbitrary tree/);
|
|
53
|
+
assert.match(message, /Project roots under this cwd/);
|
|
54
|
+
assert.match(message, /"caffe"/);
|
|
55
|
+
});
|
|
@@ -365,6 +365,47 @@ function _boundedExactFileAggregateRoot(args, baseCwd) {
|
|
|
365
365
|
return roots.size === 1 ? [...roots][0] : null;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
// A sentinel-free working directory that HOLDS a project (a container's /app
|
|
369
|
+
// with one cloned repo under it) answered every anchored call with a refusal:
|
|
370
|
+
// the caller wrote the anchor the way the repo's own docs do
|
|
371
|
+
// (`tools/compute_image_mean.cpp`), it does not exist under /app, and no root
|
|
372
|
+
// could be derived from a path that resolves nowhere. The relative anchor is
|
|
373
|
+
// still unambiguous whenever exactly ONE child project holds it — that project
|
|
374
|
+
// is the answer, and adopting it is the same recovery read and grep already
|
|
375
|
+
// perform for a misplaced path. Ambiguity (several holders, none, an absolute
|
|
376
|
+
// anchor, a wildcard) keeps refusing.
|
|
377
|
+
function _relocateAggregateAnchorsUnderChildProject(args, baseCwd) {
|
|
378
|
+
if (!_hasAggregateFileArgs(args)) return null;
|
|
379
|
+
const files = _collectGraphFileList(args);
|
|
380
|
+
if (files.length === 0) return null;
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
if (_AGGREGATE_FILE_WILDCARD_RE.test(file) || isAbsolute(file)) return null;
|
|
383
|
+
if (existsSync(pathResolve(baseCwd, file))) return null;
|
|
384
|
+
}
|
|
385
|
+
const holders = _childProjectRoots(baseCwd, { cap: 8 })
|
|
386
|
+
.filter((root) => pathResolve(root) !== pathResolve(baseCwd))
|
|
387
|
+
.filter((root) => files.every((file) => {
|
|
388
|
+
try {
|
|
389
|
+
return statSync(pathResolve(root, file)).isFile();
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}));
|
|
394
|
+
if (holders.length !== 1) return null;
|
|
395
|
+
const root = pathResolve(holders[0]);
|
|
396
|
+
const remap = (file) => pathResolve(root, String(file || '').trim());
|
|
397
|
+
return {
|
|
398
|
+
root,
|
|
399
|
+
args: {
|
|
400
|
+
...args,
|
|
401
|
+
file: typeof args?.file === 'string' && args.file.trim() ? remap(args.file) : args?.file,
|
|
402
|
+
files: Array.isArray(args?.files)
|
|
403
|
+
? args.files.map(remap)
|
|
404
|
+
: (typeof args?.files === 'string' && args.files.trim() ? remap(args.files) : args?.files),
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
368
409
|
export function _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd) {
|
|
369
410
|
if (!_hasAggregateFileArgs(args)) return null;
|
|
370
411
|
const base = pathResolve(baseCwd);
|
|
@@ -1045,13 +1086,23 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
|
|
|
1045
1086
|
stopAtUserBoundary: !explicitCwdArg,
|
|
1046
1087
|
}) || _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd)
|
|
1047
1088
|
|| _boundedExactFileAggregateRoot(args, baseCwd);
|
|
1048
|
-
|
|
1089
|
+
const relocated = aggregateRoot ? null : _relocateAggregateAnchorsUnderChildProject(args, baseCwd);
|
|
1090
|
+
if (!aggregateRoot && !relocated) {
|
|
1091
|
+
// Name the projects that DO sit under this cwd: the refusal is only
|
|
1092
|
+
// actionable if the caller learns where to anchor instead.
|
|
1093
|
+
const candidates = _childProjectRoots(baseCwd, { cap: 5 })
|
|
1094
|
+
.filter((root) => pathResolve(root) !== pathResolve(baseCwd))
|
|
1095
|
+
.map((root) => `"${pathBasename(root)}"`);
|
|
1049
1096
|
throw new Error(
|
|
1050
1097
|
`${name}: cwd '${baseCwd}' is not inside a project and aggregate file anchors do not all `
|
|
1051
|
-
+ `exist under exactly one detectable project root. Refusing to index an arbitrary tree
|
|
1098
|
+
+ `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`
|
|
1099
|
+
+ (candidates.length
|
|
1100
|
+
? ` Project roots under this cwd: ${candidates.join(', ')} — anchor the call inside one of them.`
|
|
1101
|
+
: ''),
|
|
1052
1102
|
);
|
|
1053
1103
|
}
|
|
1054
|
-
effectiveCwd = aggregateRoot;
|
|
1104
|
+
effectiveCwd = relocated ? relocated.root : aggregateRoot;
|
|
1105
|
+
if (relocated) args = relocated.args;
|
|
1055
1106
|
args = _absolutizeAggregateFileArgs(args, baseCwd);
|
|
1056
1107
|
}
|
|
1057
1108
|
if (fileArg && !hasAggregateFileArgs) {
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.20",
|
|
3
3
|
"_comment": "Synced from immutable graph-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "d059f2ab33cb7f97b7c96b4e21e3d16880cc44baf1400ce81374a1031a11ddd9"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "c91873322ce6390a7103daa4b872e31de1abbe7e922967727f41b5b7b2f21373"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "e8183e1a36aae1887245bd55b94fae6d3fa874bb350559de95ea6ca65a14c6a0"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "40142ad17359cc2eb490f639de7c358c4449ca36dc7bbb966bff9a21f81df40f"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "a727ca6ea385d715232ce246c2519037ccd74b509e57d97ad7c1b4a61c26aade"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// verbatim from patch.mjs; native protocol, cache/snapshot side effects, and
|
|
3
3
|
// output formatting are unchanged.
|
|
4
4
|
|
|
5
|
-
import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, lstatSync, statSync, realpathSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { unlink } from 'node:fs/promises';
|
|
7
7
|
import { dirname as pathDirname } from 'node:path';
|
|
8
8
|
import { performance } from 'node:perf_hooks';
|
|
@@ -393,6 +393,38 @@ function lstatRegularPatchFile(fullPath, displayPath) {
|
|
|
393
393
|
return st;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// A symlinked target is patched THROUGH the link. atomicWrite compares the
|
|
397
|
+
// live target with statSync, which FOLLOWS a leaf symlink, so the expected
|
|
398
|
+
// snapshot must be taken the same way: an lstat describes the LINK inode
|
|
399
|
+
// (size = target path length, its own mtime/ino) and can never equal the
|
|
400
|
+
// followed stat, which failed every patch aimed at a symlinked file with
|
|
401
|
+
// "changed on disk during the patch". Writing to the resolved real path also
|
|
402
|
+
// keeps the link intact — the atomic rename would otherwise replace the
|
|
403
|
+
// symlink itself with a regular file.
|
|
404
|
+
function resolvePatchWriteTarget(fullPath, displayPath, lst) {
|
|
405
|
+
if (!lst?.isSymbolicLink?.()) return { writePath: fullPath, snapshotStat: lst };
|
|
406
|
+
let writePath;
|
|
407
|
+
let snapshotStat;
|
|
408
|
+
try {
|
|
409
|
+
writePath = realpathSync(fullPath);
|
|
410
|
+
snapshotStat = statSync(fullPath);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`apply_patch: symlink target missing or unreadable: ${normalizeOutputPath(displayPath)}`
|
|
414
|
+
+ ` (${err?.code || err?.message || String(err)})`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (isSpecialFileStat(snapshotStat)) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (!snapshotStat.isFile()) {
|
|
423
|
+
throw new Error(`apply_patch: symlink target is not a regular file: ${normalizeOutputPath(displayPath)}`);
|
|
424
|
+
}
|
|
425
|
+
return { writePath, snapshotStat };
|
|
426
|
+
}
|
|
427
|
+
|
|
396
428
|
function assertAddTargetAbsent(fullPath, displayPath) {
|
|
397
429
|
try {
|
|
398
430
|
lstatSync(fullPath);
|
|
@@ -466,6 +498,7 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
466
498
|
}
|
|
467
499
|
|
|
468
500
|
const preMutationStat = lstatRegularPatchFile(fullPath, displayPath);
|
|
501
|
+
const { writePath, snapshotStat } = resolvePatchWriteTarget(fullPath, displayPath, preMutationStat);
|
|
469
502
|
// Rewrite path: decode by BOM, re-encode into the same codec, and keep every
|
|
470
503
|
// untouched line's own terminator (spliceTextLinesForPatch).
|
|
471
504
|
// `toString('utf8')` here transcoded non-UTF-8 files and mangled UTF-16.
|
|
@@ -482,14 +515,14 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
482
515
|
// rewrote this file after we read it, the write fails instead of silently
|
|
483
516
|
// clobbering it (and instead of inheriting a stale "body delivered" claim).
|
|
484
517
|
try {
|
|
485
|
-
await atomicWrite(
|
|
518
|
+
await atomicWrite(writePath, content, {
|
|
486
519
|
sessionId: readStateScope,
|
|
487
|
-
expectedTargetSnapshot:
|
|
520
|
+
expectedTargetSnapshot: snapshotStat ? {
|
|
488
521
|
exists: true,
|
|
489
|
-
size:
|
|
490
|
-
mtimeMs:
|
|
491
|
-
ctimeMs:
|
|
492
|
-
ino:
|
|
522
|
+
size: snapshotStat.size,
|
|
523
|
+
mtimeMs: snapshotStat.mtimeMs,
|
|
524
|
+
ctimeMs: snapshotStat.ctimeMs,
|
|
525
|
+
ino: snapshotStat.ino,
|
|
493
526
|
} : undefined,
|
|
494
527
|
});
|
|
495
528
|
} catch (err) {
|
|
@@ -498,12 +531,13 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
498
531
|
}
|
|
499
532
|
throw err;
|
|
500
533
|
}
|
|
501
|
-
|
|
502
|
-
|
|
534
|
+
const touchedPaths = writePath === fullPath ? [fullPath] : [fullPath, writePath];
|
|
535
|
+
invalidateBuiltinResultCache(touchedPaths);
|
|
536
|
+
markCodeGraphDirtyPaths(touchedPaths);
|
|
503
537
|
recordReadSnapshotForPath(fullPath, readStateScope, {
|
|
504
538
|
source: 'apply_patch_js',
|
|
505
539
|
isPartialView: false,
|
|
506
|
-
preMutationStat,
|
|
540
|
+
preMutationStat: snapshotStat,
|
|
507
541
|
});
|
|
508
542
|
}
|
|
509
543
|
const { added, removed } = countHunkChanges(entry.hunks);
|