@tanstack/ai-sandbox 0.2.1 → 0.2.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/dist/esm/file-diff.d.ts +19 -0
- package/dist/esm/file-diff.js +145 -0
- package/dist/esm/file-diff.js.map +1 -0
- package/dist/esm/middleware.js +90 -11
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/sandbox.d.ts +15 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/watch.d.ts +7 -0
- package/dist/esm/watch.js +135 -25
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +240 -0
- package/src/middleware.ts +131 -12
- package/src/sandbox.ts +11 -9
- package/src/watch.ts +233 -31
package/dist/esm/watch.js
CHANGED
|
@@ -16,11 +16,12 @@ function diffSnapshots(prev, next, timestamp) {
|
|
|
16
16
|
}
|
|
17
17
|
return events;
|
|
18
18
|
}
|
|
19
|
-
function buildFindCommand(
|
|
19
|
+
function buildFindCommand(ignore) {
|
|
20
20
|
const prunes = ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ");
|
|
21
|
-
return `find
|
|
21
|
+
return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`;
|
|
22
22
|
}
|
|
23
|
-
function parseFindOutput(stdout) {
|
|
23
|
+
function parseFindOutput(stdout, root) {
|
|
24
|
+
const base = root.replace(/\/+$/, "");
|
|
24
25
|
const snapshot = /* @__PURE__ */ new Map();
|
|
25
26
|
for (const line of stdout.split("\n")) {
|
|
26
27
|
if (line === "") continue;
|
|
@@ -29,7 +30,8 @@ function parseFindOutput(stdout) {
|
|
|
29
30
|
if (firstTab === -1 || secondTab === -1) continue;
|
|
30
31
|
const mtime = line.slice(0, firstTab);
|
|
31
32
|
const size = line.slice(firstTab + 1, secondTab);
|
|
32
|
-
const
|
|
33
|
+
const rel = line.slice(secondTab + 1).replace(/^\.\/?/, "");
|
|
34
|
+
const path = rel === "" ? base : `${base}/${rel}`;
|
|
33
35
|
snapshot.set(path, `${mtime} ${size}`);
|
|
34
36
|
}
|
|
35
37
|
return snapshot;
|
|
@@ -48,14 +50,37 @@ async function watchWorkspace(handle, options) {
|
|
|
48
50
|
return startPollWatch(handle, { ...options, root, ignore, intervalMs });
|
|
49
51
|
}
|
|
50
52
|
async function startNativeWatch(handle, options) {
|
|
51
|
-
const { onEvent, root, ignore } = options;
|
|
53
|
+
const { onEvent, root, ignore, logger } = options;
|
|
52
54
|
const watch = handle.fs.watch;
|
|
53
55
|
if (!watch) throw new Error("native watch is unavailable on this provider");
|
|
54
|
-
const
|
|
56
|
+
const seed = await collectPaths(handle, root, ignore, logger);
|
|
57
|
+
const known = seed.files;
|
|
58
|
+
let seeded = seed.rootOk;
|
|
59
|
+
let reseeding = null;
|
|
60
|
+
const ensureSeeded = () => {
|
|
61
|
+
if (seeded) return Promise.resolve();
|
|
62
|
+
if (!reseeding) {
|
|
63
|
+
reseeding = collectPaths(handle, root, ignore, logger).then((r) => {
|
|
64
|
+
if (r.rootOk) {
|
|
65
|
+
for (const p of r.files) known.add(p);
|
|
66
|
+
seeded = true;
|
|
67
|
+
logger?.sandbox(
|
|
68
|
+
"sandbox watch: re-seeded after failed initial seed",
|
|
69
|
+
{
|
|
70
|
+
root
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
reseeding = null;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return reseeding;
|
|
78
|
+
};
|
|
55
79
|
const subscription = await watch(root, (raw) => {
|
|
56
80
|
const path = raw.path;
|
|
57
81
|
if (isIgnored(path, ignore)) return;
|
|
58
82
|
void (async () => {
|
|
83
|
+
await ensureSeeded();
|
|
59
84
|
const exists = await handle.fs.exists(path);
|
|
60
85
|
const timestamp = Date.now();
|
|
61
86
|
if (!exists) {
|
|
@@ -67,11 +92,20 @@ async function startNativeWatch(handle, options) {
|
|
|
67
92
|
known.add(path);
|
|
68
93
|
onEvent({ type: "create", path, timestamp });
|
|
69
94
|
}
|
|
70
|
-
})().catch(() =>
|
|
95
|
+
})().catch((error) => {
|
|
96
|
+
logger?.warn("sandbox watch: native event classify failed", {
|
|
97
|
+
path,
|
|
98
|
+
error
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const logStopFailure = (error) => logger?.warn("sandbox watch: native subscription.stop() failed", {
|
|
103
|
+
root,
|
|
104
|
+
error
|
|
71
105
|
});
|
|
72
|
-
const onAbort = () => void subscription.stop().catch(
|
|
106
|
+
const onAbort = () => void subscription.stop().catch(logStopFailure);
|
|
73
107
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
74
|
-
if (options.signal?.aborted) void subscription.stop().catch(
|
|
108
|
+
if (options.signal?.aborted) void subscription.stop().catch(logStopFailure);
|
|
75
109
|
return {
|
|
76
110
|
stop: async () => {
|
|
77
111
|
options.signal?.removeEventListener("abort", onAbort);
|
|
@@ -80,27 +114,97 @@ async function startNativeWatch(handle, options) {
|
|
|
80
114
|
};
|
|
81
115
|
}
|
|
82
116
|
async function startPollWatch(handle, options) {
|
|
83
|
-
const { onEvent, root, ignore, intervalMs } = options;
|
|
84
|
-
const command = buildFindCommand(
|
|
117
|
+
const { onEvent, root, ignore, intervalMs, logger } = options;
|
|
118
|
+
const command = buildFindCommand(ignore);
|
|
85
119
|
const controller = new AbortController();
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
const STEADY_STATE_THROW_WARN_AFTER = 3;
|
|
121
|
+
let consecutiveThrows = 0;
|
|
122
|
+
const snapshot = async (isInitial = false) => {
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = await handle.process.exec(command, {
|
|
126
|
+
cwd: root,
|
|
127
|
+
signal: controller.signal
|
|
128
|
+
});
|
|
129
|
+
consecutiveThrows = 0;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (isInitial) {
|
|
132
|
+
logger?.warn("sandbox watch: initial `find` poll threw", {
|
|
133
|
+
root,
|
|
134
|
+
error
|
|
135
|
+
});
|
|
136
|
+
} else if (controller.signal.aborted) {
|
|
137
|
+
logger?.sandbox("sandbox watch: `find` poll threw during teardown", {
|
|
138
|
+
root,
|
|
139
|
+
error
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
consecutiveThrows += 1;
|
|
143
|
+
if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {
|
|
144
|
+
logger?.warn("sandbox watch: `find` poll threw repeatedly", {
|
|
145
|
+
root,
|
|
146
|
+
error,
|
|
147
|
+
consecutiveThrows
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
logger?.sandbox("sandbox watch: `find` poll threw", { root, error });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (result.exitCode === 0) {
|
|
156
|
+
return { map: parseFindOutput(result.stdout, root), complete: true };
|
|
157
|
+
}
|
|
158
|
+
if (result.stdout !== "") {
|
|
159
|
+
logger?.sandbox(
|
|
160
|
+
"sandbox watch: `find` non-zero exit with partial output",
|
|
161
|
+
{ root, exitCode: result.exitCode, stderr: result.stderr }
|
|
162
|
+
);
|
|
163
|
+
return { map: parseFindOutput(result.stdout, root), complete: false };
|
|
164
|
+
}
|
|
165
|
+
logger?.warn("sandbox watch: `find` poll exited non-zero with no output", {
|
|
166
|
+
root,
|
|
167
|
+
exitCode: result.exitCode,
|
|
168
|
+
stderr: result.stderr
|
|
90
169
|
});
|
|
91
|
-
return
|
|
170
|
+
return null;
|
|
92
171
|
};
|
|
93
|
-
let previous =
|
|
172
|
+
let previous = null;
|
|
173
|
+
let seededFromComplete = false;
|
|
174
|
+
{
|
|
175
|
+
const poll = await snapshot(true);
|
|
176
|
+
if (poll) {
|
|
177
|
+
previous = poll.map;
|
|
178
|
+
seededFromComplete = poll.complete;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
94
181
|
const state = { running: true };
|
|
95
182
|
const tick = async () => {
|
|
96
183
|
if (!state.running) return;
|
|
97
184
|
try {
|
|
98
|
-
const
|
|
185
|
+
const poll = await snapshot();
|
|
186
|
+
if (poll === null) return;
|
|
187
|
+
if (previous === null) {
|
|
188
|
+
previous = poll.map;
|
|
189
|
+
seededFromComplete = poll.complete;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!seededFromComplete && poll.complete) {
|
|
193
|
+
logger?.sandbox(
|
|
194
|
+
"sandbox watch: re-baselined after provisional partial seed",
|
|
195
|
+
{ root }
|
|
196
|
+
);
|
|
197
|
+
previous = poll.map;
|
|
198
|
+
seededFromComplete = true;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const next = poll.complete ? poll.map : new Map([...previous, ...poll.map]);
|
|
99
202
|
for (const event of diffSnapshots(previous, next, Date.now())) {
|
|
100
203
|
onEvent(event);
|
|
101
204
|
}
|
|
102
205
|
previous = next;
|
|
103
|
-
} catch {
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger?.sandbox("sandbox watch: tick failed", { root, error });
|
|
104
208
|
}
|
|
105
209
|
};
|
|
106
210
|
const timer = setInterval(() => void tick(), intervalMs);
|
|
@@ -119,23 +223,29 @@ async function startPollWatch(handle, options) {
|
|
|
119
223
|
if (options.signal?.aborted) void stop();
|
|
120
224
|
return { stop };
|
|
121
225
|
}
|
|
122
|
-
async function collectPaths(handle, root, ignore) {
|
|
226
|
+
async function collectPaths(handle, root, ignore, logger) {
|
|
123
227
|
const files = /* @__PURE__ */ new Set();
|
|
124
|
-
|
|
228
|
+
let rootOk = true;
|
|
229
|
+
const walk = async (dir, isRoot) => {
|
|
125
230
|
let entries;
|
|
126
231
|
try {
|
|
127
232
|
entries = await handle.fs.list(dir);
|
|
128
|
-
} catch {
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (isRoot) rootOk = false;
|
|
235
|
+
logger?.warn("sandbox watch: failed to list directory while seeding", {
|
|
236
|
+
dir,
|
|
237
|
+
error
|
|
238
|
+
});
|
|
129
239
|
return;
|
|
130
240
|
}
|
|
131
241
|
for (const entry of entries) {
|
|
132
242
|
if (ignore.includes(entry.name)) continue;
|
|
133
|
-
if (entry.type === "dir") await walk(entry.path);
|
|
243
|
+
if (entry.type === "dir") await walk(entry.path, false);
|
|
134
244
|
else files.add(entry.path);
|
|
135
245
|
}
|
|
136
246
|
};
|
|
137
|
-
await walk(root);
|
|
138
|
-
return files;
|
|
247
|
+
await walk(root, true);
|
|
248
|
+
return { files, rootOk };
|
|
139
249
|
}
|
|
140
250
|
export {
|
|
141
251
|
diffSnapshots,
|
package/dist/esm/watch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/** Build the `find` command that prints `mtime\\tsize\\tpath` for every file. */\nfunction buildFindCommand(root: string, ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find ${q(root)} -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/** Parse `find -printf` output into a `Map<path, signature>`. */\nfunction parseFindOutput(stdout: string): Map<string, string> {\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const path = line.slice(secondTab + 1)\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const known = await collectPaths(handle, root, ignore)\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch(() => undefined)\n })\n\n const onAbort = (): void => void subscription.stop().catch(() => undefined)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(() => undefined)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs } = options\n const command = buildFindCommand(root, ignore)\n const controller = new AbortController()\n\n const snapshot = async (): Promise<Map<string, string>> => {\n const result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n return result.exitCode === 0\n ? parseFindOutput(result.stdout)\n : new Map<string, string>()\n }\n\n let previous = await snapshot()\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const next = await snapshot()\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch {\n // transient exec failure (e.g. mid-teardown) — try again next tick\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/** Recursively collect file paths under `root`, honoring `ignore`. */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n): Promise<Set<string>> {\n const files = new Set<string>()\n const walk = async (dir: string): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch {\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path)\n else files.add(entry.path)\n }\n }\n await walk(root)\n return files\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n /**\n * Optional logger. When present, a failed `find` poll (non-zero exit or a\n * thrown exec) is logged instead of silently degrading the snapshot — the\n * failure mode a plain exec-poll watcher hides.\n */\n logger?: InternalLogger\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/**\n * Build the `find` command that prints `mtime\\tsize\\tpath` for every file.\n * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a\n * provider's `exec` maps only `cwd` onto the real filesystem, not literal path\n * arguments, so `find <virtual-root>` would look at a non-existent host path on\n * mapped-root providers (e.g. local-process). Emitted `%p` values are\n * root-normalized in {@link parseFindOutput}.\n */\nfunction buildFindCommand(ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find . -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/**\n * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints\n * paths like `./sub/file`; map them back under `root` so event paths match the\n * native-watch shape (`<root>/sub/file`).\n */\nfunction parseFindOutput(stdout: string, root: string): Map<string, string> {\n const base = root.replace(/\\/+$/, '')\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const rel = line.slice(secondTab + 1).replace(/^\\.\\/?/, '')\n const path = rel === '' ? base : `${base}/${rel}`\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, logger } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const seed = await collectPaths(handle, root, ignore, logger)\n const known = seed.files\n // If the ROOT list failed, `known` is untrustworthy — every pre-existing\n // file would misclassify as `create` on its first edit. Re-seed lazily on\n // the next event(s): by the time real activity arrives the fs has usually\n // recovered, and re-listing then establishes the baseline. Dedupe concurrent\n // re-seeds behind a single in-flight promise.\n // ponytail: a file genuinely CREATED in the narrow window between the failed\n // seed and the first event gets picked up by the re-seed and so mislabels as\n // `change` once. That's strictly better than the whole-run mislabel a\n // never-recovered empty seed causes, and `diff()` is correct regardless.\n let seeded = seed.rootOk\n let reseeding: Promise<void> | null = null\n const ensureSeeded = (): Promise<void> => {\n if (seeded) return Promise.resolve()\n if (!reseeding) {\n reseeding = collectPaths(handle, root, ignore, logger).then((r) => {\n if (r.rootOk) {\n for (const p of r.files) known.add(p)\n seeded = true\n logger?.sandbox(\n 'sandbox watch: re-seeded after failed initial seed',\n {\n root,\n },\n )\n }\n reseeding = null\n })\n }\n return reseeding\n }\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n await ensureSeeded()\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch((error: unknown) => {\n // A failed classify (e.g. `fs.exists` threw) drops this file's event —\n // log it so a missing diff isn't silent (the whole point of the watcher).\n logger?.warn('sandbox watch: native event classify failed', {\n path,\n error,\n })\n })\n })\n\n // A failed `subscription.stop()` can leak an OS-level watch — log rather\n // than swallow it silently.\n const logStopFailure = (error: unknown): void =>\n logger?.warn('sandbox watch: native subscription.stop() failed', {\n root,\n error,\n })\n const onAbort = (): void => void subscription.stop().catch(logStopFailure)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(logStopFailure)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs, logger } = options\n const command = buildFindCommand(ignore)\n const controller = new AbortController()\n\n // A poll result: the parsed snapshot plus whether `find` completed cleanly.\n // `null` means the poll produced no usable output at all (thrown exec, or a\n // non-zero exit with empty stdout) — callers preserve the previous snapshot.\n // Collapsing a failed poll to `{}` would make the next diff fabricate a\n // `delete` for every tracked file (and a `create` for each on recovery) —\n // one transient `find` blip would fan a phantom storm out to hooks/stream.\n interface Poll {\n map: Map<string, string>\n /** `false` when `find` exited non-zero but still printed rows (partial). */\n complete: boolean\n }\n // Escalate a steady-state poll throw to `warn` after this many in a row.\n const STEADY_STATE_THROW_WARN_AFTER = 3\n let consecutiveThrows = 0\n const snapshot = async (isInitial = false): Promise<Poll | null> => {\n let result\n try {\n result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n consecutiveThrows = 0 // exec returned (any exit code) — the seam is alive\n } catch (error) {\n // Thrown exec — container not ready, `find` seam rejects, or a\n // mid-teardown abort. Treat as a failed poll so BOTH the initial seed\n // and every tick preserve `previous` instead of rejecting setup (which\n // would crash the run and leak the sandbox) or the interval.\n if (isInitial) {\n // The INITIAL poll can't be a teardown (a pre-aborted signal is guarded\n // in `watchWorkspace`), so a throw here is an unambiguous anomaly (`find`\n // missing, container never ready) that leaves the watcher dead for the\n // whole run — surface it at `warn`.\n logger?.warn('sandbox watch: initial `find` poll threw', {\n root,\n error,\n })\n } else if (controller.signal.aborted) {\n // Mid-teardown abort — expected, stay quiet.\n logger?.sandbox('sandbox watch: `find` poll threw during teardown', {\n root,\n error,\n })\n } else {\n // Steady-state throw while NOT tearing down. One is usually a transient\n // blip (→ `sandbox`), but a run of them means the exec seam is wedged:\n // every poll returns null and the watcher emits nothing for the rest of\n // the run. That silent-death case escalates to `warn` (on by default).\n consecutiveThrows += 1\n if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {\n logger?.warn('sandbox watch: `find` poll threw repeatedly', {\n root,\n error,\n consecutiveThrows,\n })\n } else {\n logger?.sandbox('sandbox watch: `find` poll threw', { root, error })\n }\n }\n return null\n }\n if (result.exitCode === 0) {\n return { map: parseFindOutput(result.stdout, root), complete: true }\n }\n // Non-zero exit doesn't mean \"no data\": GNU `find` exits >0 on the first\n // permission-denied entry it hits mid-traversal (common in containers, and\n // the ignore list is a `-not -path` filter, not `-prune`, so `find` still\n // descends into unreadable dirs) yet still prints every readable file. Use\n // that partial output — marked `complete: false` so the tick merges rather\n // than diffs it — instead of blinding the watcher for the whole run. Only a\n // non-zero exit with NO output is a truly failed poll.\n if (result.stdout !== '') {\n logger?.sandbox(\n 'sandbox watch: `find` non-zero exit with partial output',\n { root, exitCode: result.exitCode, stderr: result.stderr },\n )\n return { map: parseFindOutput(result.stdout, root), complete: false }\n }\n logger?.warn('sandbox watch: `find` poll exited non-zero with no output', {\n root,\n exitCode: result.exitCode,\n stderr: result.stderr,\n })\n return null\n }\n\n // `null` until the first poll that yields usable output. A failed INITIAL\n // poll must NOT seed an empty baseline — the first successful poll would then\n // diff against `{}` and fabricate a `create` for every pre-existing file. So\n // the first non-null snapshot is adopted as the baseline WITHOUT diffing.\n let previous: Map<string, string> | null = null\n // Whether `previous` was established from a COMPLETE poll. A baseline seeded\n // from a PARTIAL poll is provisional — files unreadable during that poll are\n // absent from it and would later fabricate `create`s when they recover — so\n // the first complete poll re-baselines without diffing.\n let seededFromComplete = false\n {\n const poll = await snapshot(true)\n if (poll) {\n previous = poll.map\n seededFromComplete = poll.complete\n }\n }\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const poll = await snapshot()\n // Failed poll — keep `previous` and retry next tick (see `snapshot`).\n if (poll === null) return\n if (previous === null) {\n // First usable snapshot after a failed initial poll — seed, don't diff.\n previous = poll.map\n seededFromComplete = poll.complete\n return\n }\n if (!seededFromComplete && poll.complete) {\n // First complete poll after a provisional (partial) seed — re-baseline\n // WITHOUT diffing, so files merely unreadable at seed time don't\n // fabricate `create`s. (Real creates during this degraded-startup\n // window are missed — an acceptable trade for not fabricating events.)\n logger?.sandbox(\n 'sandbox watch: re-baselined after provisional partial seed',\n { root },\n )\n previous = poll.map\n seededFromComplete = true\n return\n }\n // A partial (non-`complete`) poll can't distinguish \"deleted\" from\n // \"transiently unreadable this poll\", so MERGE it over `previous`: pick\n // up new/changed files without fabricating a `delete` for a path this\n // poll simply couldn't see. A real deletion still surfaces on the next\n // complete poll.\n const next = poll.complete\n ? poll.map\n : new Map([...previous, ...poll.map])\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch (error) {\n // Defensive: a throw from diff dispatch — preserve `previous`, retry.\n logger?.sandbox('sandbox watch: tick failed', { root, error })\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/**\n * Recursively collect file paths under `root`, honoring `ignore`. `rootOk` is\n * `false` when the ROOT `list` itself failed — the seed is then untrustworthy\n * (empty/partial), which the native watcher uses to trigger a lazy re-seed. A\n * failed *subdirectory* list is logged but doesn't flip `rootOk` (its files are\n * simply absent, a smaller misclassification surface).\n */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n logger?: InternalLogger,\n): Promise<{ files: Set<string>; rootOk: boolean }> {\n const files = new Set<string>()\n let rootOk = true\n const walk = async (dir: string, isRoot: boolean): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch (error) {\n // A dir we can't list is seeded as empty, so its existing files would\n // later misclassify as `create` on first edit — log rather than hide it.\n if (isRoot) rootOk = false\n logger?.warn('sandbox watch: failed to list directory while seeding', {\n dir,\n error,\n })\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path, false)\n else files.add(entry.path)\n }\n }\n await walk(root, true)\n return { files, rootOk }\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@ngrok/ngrok": "^1.0.0",
|
|
47
|
-
"@tanstack/ai": "^0.
|
|
47
|
+
"@tanstack/ai": "^0.41.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@ngrok/ngrok": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@ngrok/ngrok": "^1.7.0",
|
|
56
56
|
"@vitest/coverage-v8": "4.0.14",
|
|
57
|
-
"@tanstack/ai": "0.
|
|
57
|
+
"@tanstack/ai": "0.41.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build": "vite build",
|
package/src/file-diff.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'
|
|
2
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
3
|
+
import type { SandboxHandle } from './contracts'
|
|
4
|
+
|
|
5
|
+
/** Path relative to the repo/workspace root, POSIX form. */
|
|
6
|
+
function relTo(root: string, path: string): string {
|
|
7
|
+
const prefix = root.endsWith('/') ? root : `${root}/`
|
|
8
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POSIX single-quote escape for embedding a value in a shell command.
|
|
13
|
+
*/
|
|
14
|
+
function q(value: string): string {
|
|
15
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Unified add-patch for a brand-new file, closely following the shape `git
|
|
20
|
+
* diff` produces for an added file (`diff --git` header + `new file mode` +
|
|
21
|
+
* `--- /dev/null` + `+++ b/<rel>`), so synthesized `create` diffs align with
|
|
22
|
+
* the real `git diff` output emitted for `change` events. `rel` must be the
|
|
23
|
+
* repo-root-relative POSIX path (like git's). Reproduces git's `\ No newline
|
|
24
|
+
* at end of file` marker and the header-only form for a zero-byte file, so a
|
|
25
|
+
* consumer applying the patch reconstructs the file byte-for-byte. It is not
|
|
26
|
+
* byte-identical to git — it omits the `index <hash>..<hash>` line and always
|
|
27
|
+
* writes the `+1,N` hunk count (git omits `,1`) — but both are valid
|
|
28
|
+
* unified-diff and accepted by `git apply`/`patch`.
|
|
29
|
+
*/
|
|
30
|
+
function synthesizeAddPatch(rel: string, content: string): string {
|
|
31
|
+
const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n`
|
|
32
|
+
// A zero-byte new file has no hunk in git's output — just the header.
|
|
33
|
+
if (content === '') return header
|
|
34
|
+
const hasFinalNewline = content.endsWith('\n')
|
|
35
|
+
const lines = content.replace(/\n$/, '').split('\n')
|
|
36
|
+
const body = lines.map((l) => `+${l}`).join('\n')
|
|
37
|
+
return (
|
|
38
|
+
header +
|
|
39
|
+
`--- /dev/null\n` +
|
|
40
|
+
`+++ b/${rel}\n` +
|
|
41
|
+
`@@ -0,0 +1,${lines.length} @@\n` +
|
|
42
|
+
body +
|
|
43
|
+
(hasFinalNewline ? '\n' : '\n\\n')
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
|
|
49
|
+
* the live handle. `baseSha` is the session baseline (`''` when the workspace
|
|
50
|
+
* isn't a git repo). Never throws — every git/fs failure falls back to `''`
|
|
51
|
+
* (or a synthesized add-patch), but is logged first via `logger` so a failure
|
|
52
|
+
* is observable instead of silently becoming empty data.
|
|
53
|
+
*/
|
|
54
|
+
export function buildFileHookEvent(
|
|
55
|
+
handle: SandboxHandle,
|
|
56
|
+
root: string,
|
|
57
|
+
baseSha: string,
|
|
58
|
+
event: SandboxFileEvent,
|
|
59
|
+
logger?: InternalLogger,
|
|
60
|
+
): SandboxFileHookEvent {
|
|
61
|
+
const after = async (): Promise<string> => {
|
|
62
|
+
if (event.type === 'delete') return ''
|
|
63
|
+
try {
|
|
64
|
+
return await handle.fs.read(event.path)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger?.warn('sandbox after() failed to read file', {
|
|
67
|
+
path: event.path,
|
|
68
|
+
error,
|
|
69
|
+
})
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const before = async (): Promise<string> => {
|
|
74
|
+
if (baseSha === '') return ''
|
|
75
|
+
const rel = relTo(root, event.path)
|
|
76
|
+
try {
|
|
77
|
+
const res = await handle.process.exec(
|
|
78
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
79
|
+
{ cwd: root },
|
|
80
|
+
)
|
|
81
|
+
if (res.exitCode === 0) return res.stdout
|
|
82
|
+
// Non-zero exit is EXPECTED when the file didn't exist at the baseline
|
|
83
|
+
// (a newly created file) — git exits 128 with "exists on disk, but not
|
|
84
|
+
// in <sha>". Log under `sandbox` (off by default) rather than warn so a
|
|
85
|
+
// create event's before() doesn't spam a warning on every new file.
|
|
86
|
+
logger?.sandbox('before() git show non-zero exit', {
|
|
87
|
+
path: event.path,
|
|
88
|
+
exitCode: res.exitCode,
|
|
89
|
+
stderr: res.stderr,
|
|
90
|
+
})
|
|
91
|
+
return ''
|
|
92
|
+
} catch (error) {
|
|
93
|
+
logger?.warn('sandbox before() git show failed', {
|
|
94
|
+
path: event.path,
|
|
95
|
+
error,
|
|
96
|
+
})
|
|
97
|
+
return ''
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Empty `git diff` fallback: `git diff <sha> -- <path>` shows nothing for a
|
|
101
|
+
// file git isn't tracking, so an untracked file (the common agent action —
|
|
102
|
+
// and every subsequent edit to it, which arrives as a `change`, not just the
|
|
103
|
+
// first `create`) yields empty stdout even though it has content. Synthesize
|
|
104
|
+
// an add-patch, but ONLY when the file is genuinely untracked at the
|
|
105
|
+
// baseline — an empty diff for a *tracked* file means "identical to
|
|
106
|
+
// baseline", a real no-op that must stay empty. Presence at `baseSha`
|
|
107
|
+
// distinguishes them, probed below via the `git show` EXIT CODE (NOT
|
|
108
|
+
// `before()`'s `''`, which also means "git show threw" — see the inline note).
|
|
109
|
+
// ponytail: reached only when `git diff` came back empty — the common case
|
|
110
|
+
// for an untracked file (and every edit to it), rare for a tracked one. It
|
|
111
|
+
// spends up to two extra subprocesses (`git check-ignore`, then `git show`)
|
|
112
|
+
// plus one `after()` read per such event; both verdicts are invariant per
|
|
113
|
+
// path across a run, but memoizing them would need cross-event state the
|
|
114
|
+
// per-event accessor doesn't hold. Add a per-path cache in the watcher if
|
|
115
|
+
// edit-burst latency matters.
|
|
116
|
+
const synthesizeIfUntracked = async (rel: string): Promise<string> => {
|
|
117
|
+
const content = await after()
|
|
118
|
+
if (content === '') return '' // deleted / empty / unreadable — nothing to add
|
|
119
|
+
// Don't expose the CONTENTS of a git-ignored file (`.env`, credentials,
|
|
120
|
+
// build artifacts, …) in the diff feed. The file event still fires so
|
|
121
|
+
// consumers are notified it changed — only its diff is withheld. A
|
|
122
|
+
// force-added, ignored-yet-TRACKED file produces a non-empty `git diff`
|
|
123
|
+
// above and never reaches here, so its real diff is unaffected.
|
|
124
|
+
try {
|
|
125
|
+
const ignored = await handle.process.exec(
|
|
126
|
+
`git check-ignore -q -- ${q(rel)}`,
|
|
127
|
+
{ cwd: root },
|
|
128
|
+
)
|
|
129
|
+
// check-ignore: exit 0 ⇒ path is ignored; 1 ⇒ not ignored; 128 ⇒ error.
|
|
130
|
+
if (ignored.exitCode === 0) {
|
|
131
|
+
logger?.sandbox('sandbox diff() withheld for git-ignored file', {
|
|
132
|
+
path: event.path,
|
|
133
|
+
})
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
if (ignored.exitCode !== 1) {
|
|
137
|
+
// Not the expected "not ignored" (1) — a real check-ignore error (128:
|
|
138
|
+
// corrupt repo, bad invocation). Same anomaly class as the throw below,
|
|
139
|
+
// so `warn`. We still fall through and diff, so a broken probe never
|
|
140
|
+
// silently withholds; but log it, or an error here would look exactly
|
|
141
|
+
// like "not ignored" and could expose a would-be-withheld file's diff.
|
|
142
|
+
logger?.warn('sandbox diff() git check-ignore non-zero exit', {
|
|
143
|
+
path: event.path,
|
|
144
|
+
exitCode: ignored.exitCode,
|
|
145
|
+
stderr: ignored.stderr,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// check-ignore threw (git/exec broken) — same anomaly class as the other
|
|
150
|
+
// git execs here, so `warn`. We fall through and diff as usual rather
|
|
151
|
+
// than withhold, so a broken probe can't hide a legitimate diff.
|
|
152
|
+
logger?.warn('sandbox diff() git check-ignore failed', {
|
|
153
|
+
path: event.path,
|
|
154
|
+
error,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
// Distinguish "absent at the baseline (untracked)" from "present at the
|
|
158
|
+
// baseline" by the git-show EXIT CODE, not by before()'s `''` — which also
|
|
159
|
+
// means "git show threw". Conflating a transient git-show failure with
|
|
160
|
+
// untracked would fabricate a full-file add-patch for an unchanged tracked
|
|
161
|
+
// file the agent never touched.
|
|
162
|
+
try {
|
|
163
|
+
const res = await handle.process.exec(
|
|
164
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
165
|
+
{ cwd: root },
|
|
166
|
+
)
|
|
167
|
+
// exit 0 ⇒ tracked; `git diff` was already empty ⇒ identical to baseline
|
|
168
|
+
// ⇒ genuine no-op.
|
|
169
|
+
if (res.exitCode === 0) return ''
|
|
170
|
+
// Non-zero is the EXPECTED "absent at baseline ⇒ untracked" case (exit
|
|
171
|
+
// 128), but a genuine git-show error (bad object, corrupt repo, invalid
|
|
172
|
+
// sha) also exits non-zero and would silently fabricate a full-file
|
|
173
|
+
// add-patch. Log it under `sandbox` (like `before()` does) so a
|
|
174
|
+
// persistent probe error is greppable, then fall back to synthesize.
|
|
175
|
+
logger?.sandbox(
|
|
176
|
+
'sandbox diff() tracked-ness probe non-zero exit (treating as untracked)',
|
|
177
|
+
{ path: event.path, exitCode: res.exitCode, stderr: res.stderr },
|
|
178
|
+
)
|
|
179
|
+
return synthesizeAddPatch(rel, content)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Uncertain — don't fabricate a full-file add-patch on a probe failure.
|
|
182
|
+
logger?.warn('sandbox diff() tracked-ness probe failed', {
|
|
183
|
+
path: event.path,
|
|
184
|
+
error,
|
|
185
|
+
})
|
|
186
|
+
return ''
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const diff = async (): Promise<string> => {
|
|
190
|
+
if (baseSha === '') {
|
|
191
|
+
if (event.type === 'delete') return ''
|
|
192
|
+
return synthesizeAddPatch(relTo(root, event.path), await after())
|
|
193
|
+
}
|
|
194
|
+
// Pathspec must be relative to `root` (like `before()` above) — a bare
|
|
195
|
+
// leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git
|
|
196
|
+
// against the filesystem root, not the repo root, and fails with
|
|
197
|
+
// "fatal: Invalid path" whenever the real repo root differs from
|
|
198
|
+
// `/workspace` (e.g. every local-process sandbox).
|
|
199
|
+
const rel = relTo(root, event.path)
|
|
200
|
+
try {
|
|
201
|
+
const res = await handle.process.exec(
|
|
202
|
+
`git diff ${q(baseSha)} -- ${q(rel)}`,
|
|
203
|
+
{
|
|
204
|
+
cwd: root,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
if (res.exitCode !== 0) {
|
|
208
|
+
logger?.warn('sandbox diff() git diff non-zero exit', {
|
|
209
|
+
path: event.path,
|
|
210
|
+
exitCode: res.exitCode,
|
|
211
|
+
stderr: res.stderr,
|
|
212
|
+
})
|
|
213
|
+
return ''
|
|
214
|
+
}
|
|
215
|
+
if (res.stdout !== '') return res.stdout
|
|
216
|
+
return synthesizeIfUntracked(rel)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger?.warn('sandbox diff() git diff failed', {
|
|
219
|
+
path: event.path,
|
|
220
|
+
error,
|
|
221
|
+
})
|
|
222
|
+
return ''
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { ...event, before, after, diff }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ResolvedFileEvents {
|
|
229
|
+
enabled: boolean
|
|
230
|
+
diff: boolean
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
|
|
234
|
+
export function resolveFileEvents(
|
|
235
|
+
opt: boolean | { diff?: boolean } | undefined,
|
|
236
|
+
): ResolvedFileEvents {
|
|
237
|
+
if (opt === false) return { enabled: false, diff: false }
|
|
238
|
+
if (opt === undefined || opt === true) return { enabled: true, diff: false }
|
|
239
|
+
return { enabled: true, diff: opt.diff === true }
|
|
240
|
+
}
|