@tanstack/ai-sandbox 0.2.2 → 0.2.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/esm/file-diff.d.ts +5 -2
- package/dist/esm/file-diff.js +90 -12
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/middleware.js +62 -16
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/watch.d.ts +7 -0
- package/dist/esm/watch.js +128 -20
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +157 -11
- package/src/middleware.ts +91 -15
- package/src/watch.ts +213 -24
package/dist/esm/file-diff.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai';
|
|
2
|
+
import { InternalLogger } from '@tanstack/ai/adapter-internals';
|
|
2
3
|
import { SandboxHandle } from './contracts.js';
|
|
3
4
|
/**
|
|
4
5
|
* Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
|
|
5
6
|
* the live handle. `baseSha` is the session baseline (`''` when the workspace
|
|
6
|
-
* isn't a git repo). Never throws
|
|
7
|
+
* isn't a git repo). Never throws — every git/fs failure falls back to `''`
|
|
8
|
+
* (or a synthesized add-patch), but is logged first via `logger` so a failure
|
|
9
|
+
* is observable instead of silently becoming empty data.
|
|
7
10
|
*/
|
|
8
|
-
export declare function buildFileHookEvent(handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent): SandboxFileHookEvent;
|
|
11
|
+
export declare function buildFileHookEvent(handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent, logger?: InternalLogger): SandboxFileHookEvent;
|
|
9
12
|
export interface ResolvedFileEvents {
|
|
10
13
|
enabled: boolean;
|
|
11
14
|
diff: boolean;
|
package/dist/esm/file-diff.js
CHANGED
|
@@ -5,20 +5,29 @@ function relTo(root, path) {
|
|
|
5
5
|
function q(value) {
|
|
6
6
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
7
|
}
|
|
8
|
-
function synthesizeAddPatch(
|
|
9
|
-
const
|
|
8
|
+
function synthesizeAddPatch(rel, content) {
|
|
9
|
+
const header = `diff --git a/${rel} b/${rel}
|
|
10
|
+
new file mode 100644
|
|
11
|
+
`;
|
|
12
|
+
if (content === "") return header;
|
|
13
|
+
const hasFinalNewline = content.endsWith("\n");
|
|
14
|
+
const lines = content.replace(/\n$/, "").split("\n");
|
|
10
15
|
const body = lines.map((l) => `+${l}`).join("\n");
|
|
11
|
-
return `--- /dev/null
|
|
12
|
-
+++
|
|
16
|
+
return header + `--- /dev/null
|
|
17
|
+
+++ b/${rel}
|
|
13
18
|
@@ -0,0 +1,${lines.length} @@
|
|
14
|
-
|
|
19
|
+
` + body + (hasFinalNewline ? "\n" : "\n\\n");
|
|
15
20
|
}
|
|
16
|
-
function buildFileHookEvent(handle, root, baseSha, event) {
|
|
21
|
+
function buildFileHookEvent(handle, root, baseSha, event, logger) {
|
|
17
22
|
const after = async () => {
|
|
18
23
|
if (event.type === "delete") return "";
|
|
19
24
|
try {
|
|
20
25
|
return await handle.fs.read(event.path);
|
|
21
|
-
} catch {
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger?.warn("sandbox after() failed to read file", {
|
|
28
|
+
path: event.path,
|
|
29
|
+
error
|
|
30
|
+
});
|
|
22
31
|
return "";
|
|
23
32
|
}
|
|
24
33
|
};
|
|
@@ -30,15 +39,71 @@ function buildFileHookEvent(handle, root, baseSha, event) {
|
|
|
30
39
|
`git show ${q(baseSha)}:${q(rel)}`,
|
|
31
40
|
{ cwd: root }
|
|
32
41
|
);
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
if (res.exitCode === 0) return res.stdout;
|
|
43
|
+
logger?.sandbox("before() git show non-zero exit", {
|
|
44
|
+
path: event.path,
|
|
45
|
+
exitCode: res.exitCode,
|
|
46
|
+
stderr: res.stderr
|
|
47
|
+
});
|
|
48
|
+
return "";
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger?.warn("sandbox before() git show failed", {
|
|
51
|
+
path: event.path,
|
|
52
|
+
error
|
|
53
|
+
});
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const synthesizeIfUntracked = async (rel) => {
|
|
58
|
+
const content = await after();
|
|
59
|
+
if (content === "") return "";
|
|
60
|
+
try {
|
|
61
|
+
const ignored = await handle.process.exec(
|
|
62
|
+
`git check-ignore -q -- ${q(rel)}`,
|
|
63
|
+
{ cwd: root }
|
|
64
|
+
);
|
|
65
|
+
if (ignored.exitCode === 0) {
|
|
66
|
+
logger?.sandbox("sandbox diff() withheld for git-ignored file", {
|
|
67
|
+
path: event.path
|
|
68
|
+
});
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
if (ignored.exitCode !== 1) {
|
|
72
|
+
logger?.warn("sandbox diff() git check-ignore non-zero exit", {
|
|
73
|
+
path: event.path,
|
|
74
|
+
exitCode: ignored.exitCode,
|
|
75
|
+
stderr: ignored.stderr
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger?.warn("sandbox diff() git check-ignore failed", {
|
|
80
|
+
path: event.path,
|
|
81
|
+
error
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const res = await handle.process.exec(
|
|
86
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
87
|
+
{ cwd: root }
|
|
88
|
+
);
|
|
89
|
+
if (res.exitCode === 0) return "";
|
|
90
|
+
logger?.sandbox(
|
|
91
|
+
"sandbox diff() tracked-ness probe non-zero exit (treating as untracked)",
|
|
92
|
+
{ path: event.path, exitCode: res.exitCode, stderr: res.stderr }
|
|
93
|
+
);
|
|
94
|
+
return synthesizeAddPatch(rel, content);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger?.warn("sandbox diff() tracked-ness probe failed", {
|
|
97
|
+
path: event.path,
|
|
98
|
+
error
|
|
99
|
+
});
|
|
35
100
|
return "";
|
|
36
101
|
}
|
|
37
102
|
};
|
|
38
103
|
const diff = async () => {
|
|
39
104
|
if (baseSha === "") {
|
|
40
105
|
if (event.type === "delete") return "";
|
|
41
|
-
return synthesizeAddPatch(event.path, await after());
|
|
106
|
+
return synthesizeAddPatch(relTo(root, event.path), await after());
|
|
42
107
|
}
|
|
43
108
|
const rel = relTo(root, event.path);
|
|
44
109
|
try {
|
|
@@ -48,8 +113,21 @@ function buildFileHookEvent(handle, root, baseSha, event) {
|
|
|
48
113
|
cwd: root
|
|
49
114
|
}
|
|
50
115
|
);
|
|
51
|
-
|
|
52
|
-
|
|
116
|
+
if (res.exitCode !== 0) {
|
|
117
|
+
logger?.warn("sandbox diff() git diff non-zero exit", {
|
|
118
|
+
path: event.path,
|
|
119
|
+
exitCode: res.exitCode,
|
|
120
|
+
stderr: res.stderr
|
|
121
|
+
});
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
if (res.stdout !== "") return res.stdout;
|
|
125
|
+
return synthesizeIfUntracked(rel);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger?.warn("sandbox diff() git diff failed", {
|
|
128
|
+
path: event.path,
|
|
129
|
+
error
|
|
130
|
+
});
|
|
53
131
|
return "";
|
|
54
132
|
}
|
|
55
133
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-diff.js","sources":["../../src/file-diff.ts"],"sourcesContent":["import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\n\n/** Path relative to the repo/workspace root, POSIX form. */\nfunction relTo(root: string, path: string): string {\n const prefix = root.endsWith('/') ? root : `${root}/`\n return path.startsWith(prefix) ? path.slice(prefix.length) : path\n}\n\n/**\n * POSIX single-quote escape for embedding a value in a shell command.\n */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** Minimal unified add-patch for a brand-new file (non-git workspaces). */\nfunction synthesizeAddPatch(path: string, content: string): string {\n const lines = content === '' ? [] : content.replace(/\\n$/, '').split('\\n')\n const body = lines.map((l) => `+${l}`).join('\\n')\n return `--- /dev/null\\n+++ ${path}\\n@@ -0,0 +1,${lines.length} @@\\n${body}${body ? '\\n' : ''}`\n}\n\n/**\n * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to\n * the live handle. `baseSha` is the session baseline (`''` when the workspace\n * isn't a git repo). Never throws.\n */\nexport function buildFileHookEvent(\n handle: SandboxHandle,\n root: string,\n baseSha: string,\n event: SandboxFileEvent,\n): SandboxFileHookEvent {\n const after = async (): Promise<string> => {\n if (event.type === 'delete') return ''\n try {\n return await handle.fs.read(event.path)\n } catch {\n return ''\n }\n }\n const before = async (): Promise<string> => {\n if (baseSha === '') return ''\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git show ${q(baseSha)}:${q(rel)}`,\n { cwd: root },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n const diff = async (): Promise<string> => {\n if (baseSha === '') {\n if (event.type === 'delete') return ''\n return synthesizeAddPatch(event.path, await after())\n }\n // Pathspec must be relative to `root` (like `before()` above) — a bare\n // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git\n // against the filesystem root, not the repo root, and fails with\n // \"fatal: Invalid path\" whenever the real repo root differs from\n // `/workspace` (e.g. every local-process sandbox).\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git diff ${q(baseSha)} -- ${q(rel)}`,\n {\n cwd: root,\n },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n return { ...event, before, after, diff }\n}\n\nexport interface ResolvedFileEvents {\n enabled: boolean\n diff: boolean\n}\n\n/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */\nexport function resolveFileEvents(\n opt: boolean | { diff?: boolean } | undefined,\n): ResolvedFileEvents {\n if (opt === false) return { enabled: false, diff: false }\n if (opt === undefined || opt === true) return { enabled: true, diff: false }\n return { enabled: true, diff: opt.diff === true }\n}\n"],"names":[],"mappings":"AAIA,SAAS,MAAM,MAAc,MAAsB;AACjD,QAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,IAAI;AAClD,SAAO,KAAK,WAAW,MAAM,IAAI,KAAK,MAAM,OAAO,MAAM,IAAI;AAC/D;AAKA,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAGA,SAAS,mBAAmB,MAAc,SAAyB;AACjE,QAAM,QAAQ,YAAY,KAAK,KAAK,QAAQ,QAAQ,OAAO,EAAE,EAAE,MAAM,IAAI;AACzE,QAAM,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,EAAE,KAAK,IAAI;AAChD,SAAO;AAAA,MAAsB,IAAI;AAAA,aAAgB,MAAM,MAAM;AAAA,EAAQ,IAAI,GAAG,OAAO,OAAO,EAAE;AAC9F;AAOO,SAAS,mBACd,QACA,MACA,SACA,OACsB;AACtB,QAAM,QAAQ,YAA6B;AACzC,QAAI,MAAM,SAAS,SAAU,QAAO;AACpC,QAAI;AACF,aAAO,MAAM,OAAO,GAAG,KAAK,MAAM,IAAI;AAAA,IACxC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,SAAS,YAA6B;AAC1C,QAAI,YAAY,GAAI,QAAO;AAC3B,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC;AAAA,QAChC,EAAE,KAAK,KAAA;AAAA,MAAK;AAEd,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,OAAO,YAA6B;AACxC,QAAI,YAAY,IAAI;AAClB,UAAI,MAAM,SAAS,SAAU,QAAO;AACpC,aAAO,mBAAmB,MAAM,MAAM,MAAM,OAAO;AAAA,IACrD;AAMA,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;AAAA,QACnC;AAAA,UACE,KAAK;AAAA,QAAA;AAAA,MACP;AAEF,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,EAAE,GAAG,OAAO,QAAQ,OAAO,KAAA;AACpC;AAQO,SAAS,kBACd,KACoB;AACpB,MAAI,QAAQ,MAAO,QAAO,EAAE,SAAS,OAAO,MAAM,MAAA;AAClD,MAAI,QAAQ,UAAa,QAAQ,aAAa,EAAE,SAAS,MAAM,MAAM,MAAA;AACrE,SAAO,EAAE,SAAS,MAAM,MAAM,IAAI,SAAS,KAAA;AAC7C;"}
|
|
1
|
+
{"version":3,"file":"file-diff.js","sources":["../../src/file-diff.ts"],"sourcesContent":["import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { SandboxHandle } from './contracts'\n\n/** Path relative to the repo/workspace root, POSIX form. */\nfunction relTo(root: string, path: string): string {\n const prefix = root.endsWith('/') ? root : `${root}/`\n return path.startsWith(prefix) ? path.slice(prefix.length) : path\n}\n\n/**\n * POSIX single-quote escape for embedding a value in a shell command.\n */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Unified add-patch for a brand-new file, closely following the shape `git\n * diff` produces for an added file (`diff --git` header + `new file mode` +\n * `--- /dev/null` + `+++ b/<rel>`), so synthesized `create` diffs align with\n * the real `git diff` output emitted for `change` events. `rel` must be the\n * repo-root-relative POSIX path (like git's). Reproduces git's `\\ No newline\n * at end of file` marker and the header-only form for a zero-byte file, so a\n * consumer applying the patch reconstructs the file byte-for-byte. It is not\n * byte-identical to git — it omits the `index <hash>..<hash>` line and always\n * writes the `+1,N` hunk count (git omits `,1`) — but both are valid\n * unified-diff and accepted by `git apply`/`patch`.\n */\nfunction synthesizeAddPatch(rel: string, content: string): string {\n const header = `diff --git a/${rel} b/${rel}\\nnew file mode 100644\\n`\n // A zero-byte new file has no hunk in git's output — just the header.\n if (content === '') return header\n const hasFinalNewline = content.endsWith('\\n')\n const lines = content.replace(/\\n$/, '').split('\\n')\n const body = lines.map((l) => `+${l}`).join('\\n')\n return (\n header +\n `--- /dev/null\\n` +\n `+++ b/${rel}\\n` +\n `@@ -0,0 +1,${lines.length} @@\\n` +\n body +\n (hasFinalNewline ? '\\n' : '\\n\\\\\n')\n )\n}\n\n/**\n * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to\n * the live handle. `baseSha` is the session baseline (`''` when the workspace\n * isn't a git repo). Never throws — every git/fs failure falls back to `''`\n * (or a synthesized add-patch), but is logged first via `logger` so a failure\n * is observable instead of silently becoming empty data.\n */\nexport function buildFileHookEvent(\n handle: SandboxHandle,\n root: string,\n baseSha: string,\n event: SandboxFileEvent,\n logger?: InternalLogger,\n): SandboxFileHookEvent {\n const after = async (): Promise<string> => {\n if (event.type === 'delete') return ''\n try {\n return await handle.fs.read(event.path)\n } catch (error) {\n logger?.warn('sandbox after() failed to read file', {\n path: event.path,\n error,\n })\n return ''\n }\n }\n const before = async (): Promise<string> => {\n if (baseSha === '') return ''\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git show ${q(baseSha)}:${q(rel)}`,\n { cwd: root },\n )\n if (res.exitCode === 0) return res.stdout\n // Non-zero exit is EXPECTED when the file didn't exist at the baseline\n // (a newly created file) — git exits 128 with \"exists on disk, but not\n // in <sha>\". Log under `sandbox` (off by default) rather than warn so a\n // create event's before() doesn't spam a warning on every new file.\n logger?.sandbox('before() git show non-zero exit', {\n path: event.path,\n exitCode: res.exitCode,\n stderr: res.stderr,\n })\n return ''\n } catch (error) {\n logger?.warn('sandbox before() git show failed', {\n path: event.path,\n error,\n })\n return ''\n }\n }\n // Empty `git diff` fallback: `git diff <sha> -- <path>` shows nothing for a\n // file git isn't tracking, so an untracked file (the common agent action —\n // and every subsequent edit to it, which arrives as a `change`, not just the\n // first `create`) yields empty stdout even though it has content. Synthesize\n // an add-patch, but ONLY when the file is genuinely untracked at the\n // baseline — an empty diff for a *tracked* file means \"identical to\n // baseline\", a real no-op that must stay empty. Presence at `baseSha`\n // distinguishes them, probed below via the `git show` EXIT CODE (NOT\n // `before()`'s `''`, which also means \"git show threw\" — see the inline note).\n // ponytail: reached only when `git diff` came back empty — the common case\n // for an untracked file (and every edit to it), rare for a tracked one. It\n // spends up to two extra subprocesses (`git check-ignore`, then `git show`)\n // plus one `after()` read per such event; both verdicts are invariant per\n // path across a run, but memoizing them would need cross-event state the\n // per-event accessor doesn't hold. Add a per-path cache in the watcher if\n // edit-burst latency matters.\n const synthesizeIfUntracked = async (rel: string): Promise<string> => {\n const content = await after()\n if (content === '') return '' // deleted / empty / unreadable — nothing to add\n // Don't expose the CONTENTS of a git-ignored file (`.env`, credentials,\n // build artifacts, …) in the diff feed. The file event still fires so\n // consumers are notified it changed — only its diff is withheld. A\n // force-added, ignored-yet-TRACKED file produces a non-empty `git diff`\n // above and never reaches here, so its real diff is unaffected.\n try {\n const ignored = await handle.process.exec(\n `git check-ignore -q -- ${q(rel)}`,\n { cwd: root },\n )\n // check-ignore: exit 0 ⇒ path is ignored; 1 ⇒ not ignored; 128 ⇒ error.\n if (ignored.exitCode === 0) {\n logger?.sandbox('sandbox diff() withheld for git-ignored file', {\n path: event.path,\n })\n return ''\n }\n if (ignored.exitCode !== 1) {\n // Not the expected \"not ignored\" (1) — a real check-ignore error (128:\n // corrupt repo, bad invocation). Same anomaly class as the throw below,\n // so `warn`. We still fall through and diff, so a broken probe never\n // silently withholds; but log it, or an error here would look exactly\n // like \"not ignored\" and could expose a would-be-withheld file's diff.\n logger?.warn('sandbox diff() git check-ignore non-zero exit', {\n path: event.path,\n exitCode: ignored.exitCode,\n stderr: ignored.stderr,\n })\n }\n } catch (error) {\n // check-ignore threw (git/exec broken) — same anomaly class as the other\n // git execs here, so `warn`. We fall through and diff as usual rather\n // than withhold, so a broken probe can't hide a legitimate diff.\n logger?.warn('sandbox diff() git check-ignore failed', {\n path: event.path,\n error,\n })\n }\n // Distinguish \"absent at the baseline (untracked)\" from \"present at the\n // baseline\" by the git-show EXIT CODE, not by before()'s `''` — which also\n // means \"git show threw\". Conflating a transient git-show failure with\n // untracked would fabricate a full-file add-patch for an unchanged tracked\n // file the agent never touched.\n try {\n const res = await handle.process.exec(\n `git show ${q(baseSha)}:${q(rel)}`,\n { cwd: root },\n )\n // exit 0 ⇒ tracked; `git diff` was already empty ⇒ identical to baseline\n // ⇒ genuine no-op.\n if (res.exitCode === 0) return ''\n // Non-zero is the EXPECTED \"absent at baseline ⇒ untracked\" case (exit\n // 128), but a genuine git-show error (bad object, corrupt repo, invalid\n // sha) also exits non-zero and would silently fabricate a full-file\n // add-patch. Log it under `sandbox` (like `before()` does) so a\n // persistent probe error is greppable, then fall back to synthesize.\n logger?.sandbox(\n 'sandbox diff() tracked-ness probe non-zero exit (treating as untracked)',\n { path: event.path, exitCode: res.exitCode, stderr: res.stderr },\n )\n return synthesizeAddPatch(rel, content)\n } catch (error) {\n // Uncertain — don't fabricate a full-file add-patch on a probe failure.\n logger?.warn('sandbox diff() tracked-ness probe failed', {\n path: event.path,\n error,\n })\n return ''\n }\n }\n const diff = async (): Promise<string> => {\n if (baseSha === '') {\n if (event.type === 'delete') return ''\n return synthesizeAddPatch(relTo(root, event.path), await after())\n }\n // Pathspec must be relative to `root` (like `before()` above) — a bare\n // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git\n // against the filesystem root, not the repo root, and fails with\n // \"fatal: Invalid path\" whenever the real repo root differs from\n // `/workspace` (e.g. every local-process sandbox).\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git diff ${q(baseSha)} -- ${q(rel)}`,\n {\n cwd: root,\n },\n )\n if (res.exitCode !== 0) {\n logger?.warn('sandbox diff() git diff non-zero exit', {\n path: event.path,\n exitCode: res.exitCode,\n stderr: res.stderr,\n })\n return ''\n }\n if (res.stdout !== '') return res.stdout\n return synthesizeIfUntracked(rel)\n } catch (error) {\n logger?.warn('sandbox diff() git diff failed', {\n path: event.path,\n error,\n })\n return ''\n }\n }\n return { ...event, before, after, diff }\n}\n\nexport interface ResolvedFileEvents {\n enabled: boolean\n diff: boolean\n}\n\n/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */\nexport function resolveFileEvents(\n opt: boolean | { diff?: boolean } | undefined,\n): ResolvedFileEvents {\n if (opt === false) return { enabled: false, diff: false }\n if (opt === undefined || opt === true) return { enabled: true, diff: false }\n return { enabled: true, diff: opt.diff === true }\n}\n"],"names":[],"mappings":"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"}
|
package/dist/esm/middleware.js
CHANGED
|
@@ -8,6 +8,15 @@ import { resolveSecret } from "./secrets.js";
|
|
|
8
8
|
import { watchWorkspace } from "./watch.js";
|
|
9
9
|
import { DEFAULT_WORKSPACE_ROOT } from "./bootstrap.js";
|
|
10
10
|
const runState = /* @__PURE__ */ new WeakMap();
|
|
11
|
+
async function drainWatcher(state, phase) {
|
|
12
|
+
try {
|
|
13
|
+
await state.watcher?.stop();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
state.logger?.warn("sandbox watcher stop failed", { phase, error });
|
|
16
|
+
}
|
|
17
|
+
await Promise.allSettled(state.pendingDiffs);
|
|
18
|
+
if (state.watcher) state.logger?.sandbox("sandbox watcher stopped", { phase });
|
|
19
|
+
}
|
|
11
20
|
function tenantFrom(context) {
|
|
12
21
|
if (context === null || typeof context !== "object") return void 0;
|
|
13
22
|
const c = context;
|
|
@@ -26,7 +35,7 @@ function buildEnsureCtx(ctx) {
|
|
|
26
35
|
signal: ctx.signal
|
|
27
36
|
};
|
|
28
37
|
}
|
|
29
|
-
async function dispatchDefinitionHooks(hooks, event) {
|
|
38
|
+
async function dispatchDefinitionHooks(hooks, event, logger) {
|
|
30
39
|
if (!hooks) return;
|
|
31
40
|
const typed = {
|
|
32
41
|
create: "onFileCreate",
|
|
@@ -37,7 +46,12 @@ async function dispatchDefinitionHooks(hooks, event) {
|
|
|
37
46
|
if (!fn) continue;
|
|
38
47
|
try {
|
|
39
48
|
await fn(event);
|
|
40
|
-
} catch {
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger?.errors("sandbox file hook failed", {
|
|
51
|
+
path: event.path,
|
|
52
|
+
type: event.type,
|
|
53
|
+
error
|
|
54
|
+
});
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
}
|
|
@@ -54,14 +68,32 @@ function withSandbox(definition) {
|
|
|
54
68
|
const handle = await definition.ensure(ensureCtx);
|
|
55
69
|
provideSandbox(ctx, handle);
|
|
56
70
|
if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
|
|
71
|
+
const runtime = getSandboxRuntime(ctx, { optional: true });
|
|
72
|
+
const logger = runtime?.logger;
|
|
57
73
|
const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
58
74
|
let baseSha = "";
|
|
59
75
|
try {
|
|
60
76
|
const shaRes = await handle.process.exec("git rev-parse HEAD", {
|
|
61
77
|
cwd: watchRoot
|
|
62
78
|
});
|
|
63
|
-
if (shaRes.exitCode === 0)
|
|
64
|
-
|
|
79
|
+
if (shaRes.exitCode === 0) {
|
|
80
|
+
baseSha = shaRes.stdout.trim();
|
|
81
|
+
logger?.sandbox("sandbox git baseline captured", {
|
|
82
|
+
root: watchRoot,
|
|
83
|
+
baseSha
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
logger?.sandbox("sandbox git baseline unavailable (non-zero exit)", {
|
|
87
|
+
root: watchRoot,
|
|
88
|
+
exitCode: shaRes.exitCode,
|
|
89
|
+
stderr: shaRes.stderr
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
logger?.warn("sandbox git baseline capture failed", {
|
|
94
|
+
root: watchRoot,
|
|
95
|
+
error
|
|
96
|
+
});
|
|
65
97
|
}
|
|
66
98
|
const workspace = definition.workspace;
|
|
67
99
|
if (workspace !== void 0) {
|
|
@@ -90,41 +122,57 @@ function withSandbox(definition) {
|
|
|
90
122
|
const pendingDiffs = [];
|
|
91
123
|
let watcher;
|
|
92
124
|
if (fe.enabled) {
|
|
93
|
-
const runtime = getSandboxRuntime(ctx, { optional: true });
|
|
94
125
|
watcher = await watchWorkspace(handle, {
|
|
95
126
|
onEvent: (event) => {
|
|
96
127
|
const enriched = buildFileHookEvent(
|
|
97
128
|
handle,
|
|
98
129
|
watchRoot,
|
|
99
130
|
baseSha,
|
|
100
|
-
event
|
|
131
|
+
event,
|
|
132
|
+
logger
|
|
101
133
|
);
|
|
102
|
-
void dispatchDefinitionHooks(hooks, enriched);
|
|
134
|
+
void dispatchDefinitionHooks(hooks, enriched, logger);
|
|
103
135
|
runtime?.emit(enriched);
|
|
104
136
|
if (fe.diff) {
|
|
105
137
|
pendingDiffs.push(
|
|
106
138
|
enriched.diff().then((diff) => {
|
|
107
139
|
runtime?.emitFileDiff({ path: event.path, diff });
|
|
108
|
-
}).catch(() =>
|
|
140
|
+
}).catch((error) => {
|
|
141
|
+
logger?.warn("sandbox file diff emit failed", {
|
|
142
|
+
path: event.path,
|
|
143
|
+
error
|
|
144
|
+
});
|
|
145
|
+
})
|
|
109
146
|
);
|
|
110
147
|
}
|
|
111
148
|
},
|
|
112
|
-
|
|
149
|
+
// Watch the SAME root the enrichment layer relativizes against
|
|
150
|
+
// (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`
|
|
151
|
+
// capture). Without this the watcher defaults to `/workspace` while
|
|
152
|
+
// enrichment uses `watchRoot`, so a custom `workspace.root` makes the
|
|
153
|
+
// two look at different directories and git pathspecs break.
|
|
154
|
+
root: watchRoot,
|
|
155
|
+
...ctx.signal !== void 0 ? { signal: ctx.signal } : {},
|
|
156
|
+
...logger !== void 0 ? { logger } : {}
|
|
157
|
+
});
|
|
158
|
+
logger?.sandbox("sandbox watcher started", {
|
|
159
|
+
root: watchRoot,
|
|
160
|
+
diff: fe.diff
|
|
113
161
|
});
|
|
114
162
|
}
|
|
115
163
|
runState.set(ctx, {
|
|
116
164
|
handle,
|
|
117
165
|
ensureCtx,
|
|
118
166
|
pendingDiffs,
|
|
119
|
-
...watcher ? { watcher } : {}
|
|
167
|
+
...watcher ? { watcher } : {},
|
|
168
|
+
...logger !== void 0 ? { logger } : {}
|
|
120
169
|
});
|
|
121
170
|
},
|
|
122
171
|
async onFinish(ctx) {
|
|
123
172
|
const state = runState.get(ctx);
|
|
124
173
|
if (!state) return;
|
|
125
174
|
const { handle, ensureCtx } = state;
|
|
126
|
-
await state
|
|
127
|
-
await Promise.allSettled(state.pendingDiffs);
|
|
175
|
+
await drainWatcher(state, "finish");
|
|
128
176
|
const lifecycle = definition.lifecycle;
|
|
129
177
|
if (lifecycle?.snapshot === "after-run" && handle.capabilities.snapshots && handle.snapshot) {
|
|
130
178
|
const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
|
|
@@ -149,16 +197,14 @@ function withSandbox(definition) {
|
|
|
149
197
|
async onAbort(ctx, _info) {
|
|
150
198
|
const state = runState.get(ctx);
|
|
151
199
|
if (!state) return;
|
|
152
|
-
await state
|
|
153
|
-
await Promise.allSettled(state.pendingDiffs);
|
|
200
|
+
await drainWatcher(state, "abort");
|
|
154
201
|
await definition.destroy(state.ensureCtx);
|
|
155
202
|
await definition.hooks?.onDestroy?.();
|
|
156
203
|
},
|
|
157
204
|
async onError(ctx, info) {
|
|
158
205
|
const state = runState.get(ctx);
|
|
159
206
|
if (!state) return;
|
|
160
|
-
await state
|
|
161
|
-
await Promise.allSettled(state.pendingDiffs);
|
|
207
|
+
await drainWatcher(state, "error");
|
|
162
208
|
await definition.hooks?.onError?.(info.error);
|
|
163
209
|
if (definition.lifecycle?.destroyOnComplete) {
|
|
164
210
|
await definition.destroy(state.ensureCtx);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run.\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch {\n // swallowed — one bad hook must not break the run\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim()\n } catch {\n // non-git workspace / exec rejects → baseSha stays '' (accessors fall back)\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n const pendingDiffs: Array<Promise<void>> = []\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n )\n void dispatchDefinitionHooks(hooks, enriched)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch(() => undefined),\n )\n }\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, {\n handle,\n ensureCtx,\n pendingDiffs,\n ...(watcher ? { watcher } : {}),\n })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":";;;;;;;;;AAyDA,MAAM,+BAAe,QAAA;AAGrB,SAAS,WACP,SACiD;AACjD,MAAI,YAAY,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC5D,QAAM,IAAI;AACV,QAAM,SAAS,OAAO,EAAE,WAAW,WAAW,EAAE,SAAS;AACzD,QAAM,QAAQ,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AACtD,MAAI,WAAW,UAAa,UAAU,OAAW,QAAO;AACxD,SAAO,EAAE,QAAQ,MAAA;AACnB;AAEA,SAAS,eAAe,KAAkD;AACxE,SAAO;AAAA,IACL,UAAU,IAAI;AAAA,IACd,OAAO,IAAI;AAAA,IACX,OAAO,IAAI,YAAY,sBAAsB;AAAA,IAC7C,OAAO,IAAI,YAAY,eAAe;AAAA,IACtC,QAAQ,WAAW,IAAI,OAAO;AAAA,IAC9B,QAAQ,IAAI;AAAA,EAAA;AAEhB;AAOA,eAAe,wBACb,OACA,OACe;AACf,MAAI,CAAC,MAAO;AACZ,QAAM,QACJ;AAAA,IACE,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,QAAQ;AAAA,EAAA,EAEV,MAAM,IAAI;AACZ,aAAW,MAAM,CAAC,MAAM,QAAQ,MAAM,KAAK,CAAC,GAAG;AAC7C,QAAI,CAAC,GAAI;AACT,QAAI;AACF,YAAM,GAAG,KAAK;AAAA,IAChB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEO,SAAS,YACd,YAKA;AACA,SAAO,qBAAqB;AAAA,IAC1B,MAAM;AAAA,IACN,UAAU,CAAC,mBAAmB,oBAAoB;AAAA;AAAA;AAAA;AAAA,IAIlD,kBAAkB,CAAC,wBAAwB,eAAe;AAAA,IAE1D,MAAM,MAAM,KAAK;AACf,YAAM,YAAY,eAAe,GAAG;AACpC,YAAM,SAAS,MAAM,WAAW,OAAO,SAAS;AAChD,qBAAe,KAAK,MAAM;AAC1B,UAAI,WAAW,OAAQ,sBAAqB,KAAK,WAAW,MAAM;AAElE,YAAM,YAAY,WAAW,WAAW,QAAQ;AAChD,UAAI,UAAU;AACd,UAAI;AACF,cAAM,SAAS,MAAM,OAAO,QAAQ,KAAK,sBAAsB;AAAA,UAC7D,KAAK;AAAA,QAAA,CACN;AACD,YAAI,OAAO,aAAa,EAAG,WAAU,OAAO,OAAO,KAAA;AAAA,MACrD,QAAQ;AAAA,MAER;AAEA,YAAM,YAAY,WAAW;AAC7B,UAAI,cAAc,QAAW;AAC3B,cAAM,OAAO,UAAU,QAAQ;AAC/B,cAAM,gBAAgB,qBAAqB,SAAS;AACpD,cAAM,UAAU,UAAU;AAC1B,mCAA2B,KAAK;AAAA,UAC9B,QAAQ,UAAU,UAAU,CAAA;AAAA,UAC5B,SAAS,UAAU,WAAW,CAAA;AAAA,UAC9B,eAAe,CAAC,QAAQ;AACtB,gBAAI,YAAY,QAAW;AACzB,oBAAM,IAAI;AAAA,gBACR,8DAA8D,IAAI,YAAY;AAAA,cAAA;AAAA,YAElF;AACA,mBAAO,cAAc,SAAS,GAAG;AAAA,UACnC;AAAA,UACA,YAAY,GAAG,IAAI,wBAAwB,aAAa;AAAA,UACxD;AAAA,UACA,GAAI,UAAU,YAAY,SACtB,EAAE,SAAS,UAAU,YACrB,CAAA;AAAA,QAAC,CACN;AAAA,MACH;AAEA,YAAM,QAAQ,WAAW;AACzB,YAAM,OAAO,UAAU,MAAM;AAE7B,YAAM,KAAK,kBAAkB,WAAW,UAAU;AAClD,YAAM,eAAqC,CAAA;AAC3C,UAAI;AACJ,UAAI,GAAG,SAAS;AACd,cAAM,UAAU,kBAAkB,KAAK,EAAE,UAAU,MAAM;AACzD,kBAAU,MAAM,eAAe,QAAQ;AAAA,UACrC,SAAS,CAAC,UAA4B;AACpC,kBAAM,WAAW;AAAA,cACf;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,YAAA;AAEF,iBAAK,wBAAwB,OAAO,QAAQ;AAC5C,qBAAS,KAAK,QAAQ;AACtB,gBAAI,GAAG,MAAM;AACX,2BAAa;AAAA,gBACX,SACG,KAAA,EACA,KAAK,CAAC,SAAS;AACd,2BAAS,aAAa,EAAE,MAAM,MAAM,MAAM,MAAM;AAAA,gBAClD,CAAC,EACA,MAAM,MAAM,MAAS;AAAA,cAAA;AAAA,YAE5B;AAAA,UACF;AAAA,UACA,GAAI,IAAI,WAAW,SAAY,EAAE,QAAQ,IAAI,WAAW,CAAA;AAAA,QAAC,CAC1D;AAAA,MACH;AAEA,eAAS,IAAI,KAAK;AAAA,QAChB;AAAA,QACA;AAAA,QACA;AAAA,QACA,GAAI,UAAU,EAAE,YAAY,CAAA;AAAA,MAAC,CAC9B;AAAA,IACH;AAAA,IAEA,MAAM,SAAS,KAAK;AAClB,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AACZ,YAAM,EAAE,QAAQ,UAAA,IAAc;AAE9B,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAE3C,YAAM,YAAY,WAAW;AAE7B,UACE,WAAW,aAAa,eACxB,OAAO,aAAa,aACpB,OAAO,UACP;AACA,cAAM,WAAW,MAAM,OAAO,SAAS,aAAa,IAAI,KAAK,EAAE;AAC/D,cAAM,QAAQ,UAAU;AACxB,YAAI,OAAO;AACT,gBAAM,MAAM,WAAW,IAAI,SAAS;AACpC,gBAAM,WAAW,MAAM,MAAM,IAAI,GAAG;AACpC,cAAI,UAAU;AACZ,kBAAM,MAAM,OAAO;AAAA,cACjB,GAAG;AAAA,cACH,kBAAkB,SAAS;AAAA,cAC3B,WAAW,KAAK,IAAA;AAAA,YAAI,CACrB;AAAA,UACH;AAAA,QACF;AAAA,MACF;AAEA,UAAI,WAAW,mBAAmB;AAChC,cAAM,WAAW,QAAQ,SAAS;AAClC,cAAM,WAAW,OAAO,YAAA;AAAA,MAC1B;AAAA,IACF;AAAA,IAEA,MAAM,QAAQ,KAAK,OAAkB;AACnC,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AAEZ,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAQ3C,YAAM,WAAW,QAAQ,MAAM,SAAS;AACxC,YAAM,WAAW,OAAO,YAAA;AAAA,IAC1B;AAAA,IAEA,MAAM,QAAQ,KAAK,MAAM;AACvB,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AAEZ,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAC3C,YAAM,WAAW,OAAO,UAAU,KAAK,KAAK;AAI5C,UAAI,WAAW,WAAW,mBAAmB;AAC3C,cAAM,WAAW,QAAQ,MAAM,SAAS;AACxC,cAAM,WAAW,OAAO,YAAA;AAAA,MAC1B;AAAA,IACF;AAAA,EAAA,CACD;AACH;"}
|
|
1
|
+
{"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n /** Logger captured at setup, so terminal hooks can log watcher teardown. */\n logger?: InternalLogger\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/**\n * Stop the watcher and drain any in-flight `diff()` promises before teardown,\n * so the final file's diff isn't dropped when a run finishes/aborts/errors\n * mid-computation. The `pendingDiffs` await is the load-bearing line — without\n * it a deferred diff resolves after the run is gone and its chunk is lost.\n */\nasync function drainWatcher(\n state: SandboxRunState,\n phase: 'finish' | 'abort' | 'error',\n): Promise<void> {\n // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of\n // here, or the caller skips the `definition.destroy(...)` that follows —\n // leaking the sandbox on exactly the abort path that must ALWAYS tear down.\n try {\n await state.watcher?.stop()\n } catch (error) {\n state.logger?.warn('sandbox watcher stop failed', { phase, error })\n }\n await Promise.allSettled(state.pendingDiffs)\n if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })\n}\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run — but are logged under the `errors` category first, so\n * a throwing hook is observable (matching the run-scoped path in the engine\n * and the behavior the observability docs promise).\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n logger?: InternalLogger,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch (error) {\n // swallowed — one bad hook must not break the run — but logged so the\n // failure isn't invisible.\n logger?.errors('sandbox file hook failed', {\n path: event.path,\n type: event.type,\n error,\n })\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n // Pull the runtime (and its logger) up front so `baseSha` capture and\n // hook dispatch below can log through the same `sandbox`/`errors`\n // categories the engine uses.\n const runtime = getSandboxRuntime(ctx, { optional: true })\n const logger = runtime?.logger\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) {\n baseSha = shaRes.stdout.trim()\n logger?.sandbox('sandbox git baseline captured', {\n root: watchRoot,\n baseSha,\n })\n } else {\n // Non-zero exit: either not a git repository (non-git workspace) or a\n // repo with no commits (no HEAD). Expected, but it silently degrades\n // every subsequent diff to a full-file add-patch, so surface it\n // under `sandbox` (with stderr) rather than leaving nothing to grep.\n logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {\n root: watchRoot,\n exitCode: shaRes.exitCode,\n stderr: shaRes.stderr,\n })\n }\n } catch (error) {\n // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''\n // and accessors fall back, but this is a real anomaly, not a plain\n // non-git workspace, so warn.\n logger?.warn('sandbox git baseline capture failed', {\n root: watchRoot,\n error,\n })\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n const pendingDiffs: Array<Promise<void>> = []\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n logger,\n )\n void dispatchDefinitionHooks(hooks, enriched, logger)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch((error: unknown) => {\n logger?.warn('sandbox file diff emit failed', {\n path: event.path,\n error,\n })\n }),\n )\n }\n },\n // Watch the SAME root the enrichment layer relativizes against\n // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`\n // capture). Without this the watcher defaults to `/workspace` while\n // enrichment uses `watchRoot`, so a custom `workspace.root` makes the\n // two look at different directories and git pathspecs break.\n root: watchRoot,\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n logger?.sandbox('sandbox watcher started', {\n root: watchRoot,\n diff: fe.diff,\n })\n }\n\n runState.set(ctx, {\n handle,\n ensureCtx,\n pendingDiffs,\n ...(watcher ? { watcher } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await drainWatcher(state, 'finish')\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'abort')\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'error')\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":"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"}
|
package/dist/esm/watch.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SandboxHandle } from './contracts.js';
|
|
2
2
|
import { SandboxFileEvent } from '@tanstack/ai';
|
|
3
|
+
import { InternalLogger } from '@tanstack/ai/adapter-internals';
|
|
3
4
|
export type { SandboxFileEvent } from '@tanstack/ai';
|
|
4
5
|
/** @deprecated alias retained for the low-level watch API. */
|
|
5
6
|
export type FileEvent = SandboxFileEvent;
|
|
@@ -18,6 +19,12 @@ export interface WatchOptions {
|
|
|
18
19
|
ignore?: Array<string>;
|
|
19
20
|
/** Stop watching when this signal aborts. */
|
|
20
21
|
signal?: AbortSignal;
|
|
22
|
+
/**
|
|
23
|
+
* Optional logger. When present, a failed `find` poll (non-zero exit or a
|
|
24
|
+
* thrown exec) is logged instead of silently degrading the snapshot — the
|
|
25
|
+
* failure mode a plain exec-poll watcher hides.
|
|
26
|
+
*/
|
|
27
|
+
logger?: InternalLogger;
|
|
21
28
|
}
|
|
22
29
|
export interface SandboxWatchHandle {
|
|
23
30
|
/** Stop the watcher and release its resources. */
|
package/dist/esm/watch.js
CHANGED
|
@@ -50,14 +50,37 @@ async function watchWorkspace(handle, options) {
|
|
|
50
50
|
return startPollWatch(handle, { ...options, root, ignore, intervalMs });
|
|
51
51
|
}
|
|
52
52
|
async function startNativeWatch(handle, options) {
|
|
53
|
-
const { onEvent, root, ignore } = options;
|
|
53
|
+
const { onEvent, root, ignore, logger } = options;
|
|
54
54
|
const watch = handle.fs.watch;
|
|
55
55
|
if (!watch) throw new Error("native watch is unavailable on this provider");
|
|
56
|
-
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
|
+
};
|
|
57
79
|
const subscription = await watch(root, (raw) => {
|
|
58
80
|
const path = raw.path;
|
|
59
81
|
if (isIgnored(path, ignore)) return;
|
|
60
82
|
void (async () => {
|
|
83
|
+
await ensureSeeded();
|
|
61
84
|
const exists = await handle.fs.exists(path);
|
|
62
85
|
const timestamp = Date.now();
|
|
63
86
|
if (!exists) {
|
|
@@ -69,11 +92,20 @@ async function startNativeWatch(handle, options) {
|
|
|
69
92
|
known.add(path);
|
|
70
93
|
onEvent({ type: "create", path, timestamp });
|
|
71
94
|
}
|
|
72
|
-
})().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
|
|
73
105
|
});
|
|
74
|
-
const onAbort = () => void subscription.stop().catch(
|
|
106
|
+
const onAbort = () => void subscription.stop().catch(logStopFailure);
|
|
75
107
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
76
|
-
if (options.signal?.aborted) void subscription.stop().catch(
|
|
108
|
+
if (options.signal?.aborted) void subscription.stop().catch(logStopFailure);
|
|
77
109
|
return {
|
|
78
110
|
stop: async () => {
|
|
79
111
|
options.signal?.removeEventListener("abort", onAbort);
|
|
@@ -82,27 +114,97 @@ async function startNativeWatch(handle, options) {
|
|
|
82
114
|
};
|
|
83
115
|
}
|
|
84
116
|
async function startPollWatch(handle, options) {
|
|
85
|
-
const { onEvent, root, ignore, intervalMs } = options;
|
|
117
|
+
const { onEvent, root, ignore, intervalMs, logger } = options;
|
|
86
118
|
const command = buildFindCommand(ignore);
|
|
87
119
|
const controller = new AbortController();
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
92
169
|
});
|
|
93
|
-
return
|
|
170
|
+
return null;
|
|
94
171
|
};
|
|
95
|
-
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
|
+
}
|
|
96
181
|
const state = { running: true };
|
|
97
182
|
const tick = async () => {
|
|
98
183
|
if (!state.running) return;
|
|
99
184
|
try {
|
|
100
|
-
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]);
|
|
101
202
|
for (const event of diffSnapshots(previous, next, Date.now())) {
|
|
102
203
|
onEvent(event);
|
|
103
204
|
}
|
|
104
205
|
previous = next;
|
|
105
|
-
} catch {
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger?.sandbox("sandbox watch: tick failed", { root, error });
|
|
106
208
|
}
|
|
107
209
|
};
|
|
108
210
|
const timer = setInterval(() => void tick(), intervalMs);
|
|
@@ -121,23 +223,29 @@ async function startPollWatch(handle, options) {
|
|
|
121
223
|
if (options.signal?.aborted) void stop();
|
|
122
224
|
return { stop };
|
|
123
225
|
}
|
|
124
|
-
async function collectPaths(handle, root, ignore) {
|
|
226
|
+
async function collectPaths(handle, root, ignore, logger) {
|
|
125
227
|
const files = /* @__PURE__ */ new Set();
|
|
126
|
-
|
|
228
|
+
let rootOk = true;
|
|
229
|
+
const walk = async (dir, isRoot) => {
|
|
127
230
|
let entries;
|
|
128
231
|
try {
|
|
129
232
|
entries = await handle.fs.list(dir);
|
|
130
|
-
} 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
|
+
});
|
|
131
239
|
return;
|
|
132
240
|
}
|
|
133
241
|
for (const entry of entries) {
|
|
134
242
|
if (ignore.includes(entry.name)) continue;
|
|
135
|
-
if (entry.type === "dir") await walk(entry.path);
|
|
243
|
+
if (entry.type === "dir") await walk(entry.path, false);
|
|
136
244
|
else files.add(entry.path);
|
|
137
245
|
}
|
|
138
246
|
};
|
|
139
|
-
await walk(root);
|
|
140
|
-
return files;
|
|
247
|
+
await walk(root, true);
|
|
248
|
+
return { files, rootOk };
|
|
141
249
|
}
|
|
142
250
|
export {
|
|
143
251
|
diffSnapshots,
|