@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai';
|
|
2
|
+
import { InternalLogger } from '@tanstack/ai/adapter-internals';
|
|
3
|
+
import { SandboxHandle } from './contracts.js';
|
|
4
|
+
/**
|
|
5
|
+
* Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
|
|
6
|
+
* the live handle. `baseSha` is the session baseline (`''` when the workspace
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildFileHookEvent(handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent, logger?: InternalLogger): SandboxFileHookEvent;
|
|
12
|
+
export interface ResolvedFileEvents {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
diff: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
|
|
17
|
+
export declare function resolveFileEvents(opt: boolean | {
|
|
18
|
+
diff?: boolean;
|
|
19
|
+
} | undefined): ResolvedFileEvents;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
function relTo(root, path) {
|
|
2
|
+
const prefix = root.endsWith("/") ? root : `${root}/`;
|
|
3
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
4
|
+
}
|
|
5
|
+
function q(value) {
|
|
6
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
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");
|
|
15
|
+
const body = lines.map((l) => `+${l}`).join("\n");
|
|
16
|
+
return header + `--- /dev/null
|
|
17
|
+
+++ b/${rel}
|
|
18
|
+
@@ -0,0 +1,${lines.length} @@
|
|
19
|
+
` + body + (hasFinalNewline ? "\n" : "\n\\n");
|
|
20
|
+
}
|
|
21
|
+
function buildFileHookEvent(handle, root, baseSha, event, logger) {
|
|
22
|
+
const after = async () => {
|
|
23
|
+
if (event.type === "delete") return "";
|
|
24
|
+
try {
|
|
25
|
+
return await handle.fs.read(event.path);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger?.warn("sandbox after() failed to read file", {
|
|
28
|
+
path: event.path,
|
|
29
|
+
error
|
|
30
|
+
});
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const before = async () => {
|
|
35
|
+
if (baseSha === "") return "";
|
|
36
|
+
const rel = relTo(root, event.path);
|
|
37
|
+
try {
|
|
38
|
+
const res = await handle.process.exec(
|
|
39
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
40
|
+
{ cwd: root }
|
|
41
|
+
);
|
|
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
|
+
});
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const diff = async () => {
|
|
104
|
+
if (baseSha === "") {
|
|
105
|
+
if (event.type === "delete") return "";
|
|
106
|
+
return synthesizeAddPatch(relTo(root, event.path), await after());
|
|
107
|
+
}
|
|
108
|
+
const rel = relTo(root, event.path);
|
|
109
|
+
try {
|
|
110
|
+
const res = await handle.process.exec(
|
|
111
|
+
`git diff ${q(baseSha)} -- ${q(rel)}`,
|
|
112
|
+
{
|
|
113
|
+
cwd: root
|
|
114
|
+
}
|
|
115
|
+
);
|
|
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
|
+
});
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return { ...event, before, after, diff };
|
|
135
|
+
}
|
|
136
|
+
function resolveFileEvents(opt) {
|
|
137
|
+
if (opt === false) return { enabled: false, diff: false };
|
|
138
|
+
if (opt === void 0 || opt === true) return { enabled: true, diff: false };
|
|
139
|
+
return { enabled: true, diff: opt.diff === true };
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
buildFileHookEvent,
|
|
143
|
+
resolveFileEvents
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=file-diff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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
|
@@ -2,11 +2,21 @@ import { defineChatMiddleware } from "@tanstack/ai";
|
|
|
2
2
|
import { getSandboxRuntime } from "@tanstack/ai/adapter-internals";
|
|
3
3
|
import { SandboxStoreCapability, LocksCapability, SandboxCapability, provideSandbox, provideSandboxPolicy } from "./capabilities.js";
|
|
4
4
|
import { computeWorkspaceHash } from "./key.js";
|
|
5
|
+
import { resolveFileEvents, buildFileHookEvent } from "./file-diff.js";
|
|
5
6
|
import { ProjectionCapability, provideWorkspaceProjection } from "./projection.js";
|
|
6
7
|
import { resolveSecret } from "./secrets.js";
|
|
7
8
|
import { watchWorkspace } from "./watch.js";
|
|
8
9
|
import { DEFAULT_WORKSPACE_ROOT } from "./bootstrap.js";
|
|
9
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
|
+
}
|
|
10
20
|
function tenantFrom(context) {
|
|
11
21
|
if (context === null || typeof context !== "object") return void 0;
|
|
12
22
|
const c = context;
|
|
@@ -25,7 +35,7 @@ function buildEnsureCtx(ctx) {
|
|
|
25
35
|
signal: ctx.signal
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
|
-
async function dispatchDefinitionHooks(hooks, event) {
|
|
38
|
+
async function dispatchDefinitionHooks(hooks, event, logger) {
|
|
29
39
|
if (!hooks) return;
|
|
30
40
|
const typed = {
|
|
31
41
|
create: "onFileCreate",
|
|
@@ -36,7 +46,12 @@ async function dispatchDefinitionHooks(hooks, event) {
|
|
|
36
46
|
if (!fn) continue;
|
|
37
47
|
try {
|
|
38
48
|
await fn(event);
|
|
39
|
-
} catch {
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger?.errors("sandbox file hook failed", {
|
|
51
|
+
path: event.path,
|
|
52
|
+
type: event.type,
|
|
53
|
+
error
|
|
54
|
+
});
|
|
40
55
|
}
|
|
41
56
|
}
|
|
42
57
|
}
|
|
@@ -53,6 +68,33 @@ function withSandbox(definition) {
|
|
|
53
68
|
const handle = await definition.ensure(ensureCtx);
|
|
54
69
|
provideSandbox(ctx, handle);
|
|
55
70
|
if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
|
|
71
|
+
const runtime = getSandboxRuntime(ctx, { optional: true });
|
|
72
|
+
const logger = runtime?.logger;
|
|
73
|
+
const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
74
|
+
let baseSha = "";
|
|
75
|
+
try {
|
|
76
|
+
const shaRes = await handle.process.exec("git rev-parse HEAD", {
|
|
77
|
+
cwd: watchRoot
|
|
78
|
+
});
|
|
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
|
+
});
|
|
97
|
+
}
|
|
56
98
|
const workspace = definition.workspace;
|
|
57
99
|
if (workspace !== void 0) {
|
|
58
100
|
const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
@@ -76,24 +118,61 @@ function withSandbox(definition) {
|
|
|
76
118
|
}
|
|
77
119
|
const hooks = definition.hooks;
|
|
78
120
|
await hooks?.onReady?.(handle);
|
|
121
|
+
const fe = resolveFileEvents(definition.fileEvents);
|
|
122
|
+
const pendingDiffs = [];
|
|
79
123
|
let watcher;
|
|
80
|
-
if (
|
|
81
|
-
const runtime = getSandboxRuntime(ctx, { optional: true });
|
|
124
|
+
if (fe.enabled) {
|
|
82
125
|
watcher = await watchWorkspace(handle, {
|
|
83
126
|
onEvent: (event) => {
|
|
84
|
-
|
|
85
|
-
|
|
127
|
+
const enriched = buildFileHookEvent(
|
|
128
|
+
handle,
|
|
129
|
+
watchRoot,
|
|
130
|
+
baseSha,
|
|
131
|
+
event,
|
|
132
|
+
logger
|
|
133
|
+
);
|
|
134
|
+
void dispatchDefinitionHooks(hooks, enriched, logger);
|
|
135
|
+
runtime?.emit(enriched);
|
|
136
|
+
if (fe.diff) {
|
|
137
|
+
pendingDiffs.push(
|
|
138
|
+
enriched.diff().then((diff) => {
|
|
139
|
+
runtime?.emitFileDiff({ path: event.path, diff });
|
|
140
|
+
}).catch((error) => {
|
|
141
|
+
logger?.warn("sandbox file diff emit failed", {
|
|
142
|
+
path: event.path,
|
|
143
|
+
error
|
|
144
|
+
});
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
}
|
|
86
148
|
},
|
|
87
|
-
|
|
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
|
|
88
161
|
});
|
|
89
162
|
}
|
|
90
|
-
runState.set(ctx, {
|
|
163
|
+
runState.set(ctx, {
|
|
164
|
+
handle,
|
|
165
|
+
ensureCtx,
|
|
166
|
+
pendingDiffs,
|
|
167
|
+
...watcher ? { watcher } : {},
|
|
168
|
+
...logger !== void 0 ? { logger } : {}
|
|
169
|
+
});
|
|
91
170
|
},
|
|
92
171
|
async onFinish(ctx) {
|
|
93
172
|
const state = runState.get(ctx);
|
|
94
173
|
if (!state) return;
|
|
95
174
|
const { handle, ensureCtx } = state;
|
|
96
|
-
await state
|
|
175
|
+
await drainWatcher(state, "finish");
|
|
97
176
|
const lifecycle = definition.lifecycle;
|
|
98
177
|
if (lifecycle?.snapshot === "after-run" && handle.capabilities.snapshots && handle.snapshot) {
|
|
99
178
|
const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
|
|
@@ -118,14 +197,14 @@ function withSandbox(definition) {
|
|
|
118
197
|
async onAbort(ctx, _info) {
|
|
119
198
|
const state = runState.get(ctx);
|
|
120
199
|
if (!state) return;
|
|
121
|
-
await state
|
|
200
|
+
await drainWatcher(state, "abort");
|
|
122
201
|
await definition.destroy(state.ensureCtx);
|
|
123
202
|
await definition.hooks?.onDestroy?.();
|
|
124
203
|
},
|
|
125
204
|
async onError(ctx, info) {
|
|
126
205
|
const state = runState.get(ctx);
|
|
127
206
|
if (!state) return;
|
|
128
|
-
await state
|
|
207
|
+
await drainWatcher(state, "error");
|
|
129
208
|
await definition.hooks?.onError?.(info.error);
|
|
130
209
|
if (definition.lifecycle?.destroyOnComplete) {
|
|
131
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 { 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} 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}\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: SandboxFileEvent,\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 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 let watcher: SandboxWatchHandle | undefined\n if (definition.fileEvents !== false) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n void dispatchDefinitionHooks(hooks, event)\n runtime?.emit(event)\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) })\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\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\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 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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"}
|
|
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/sandbox.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SandboxFileHookEvent } from '@tanstack/ai';
|
|
2
2
|
import { SandboxHandle, SandboxProvider } from './contracts.js';
|
|
3
3
|
import { LockStore, SandboxStore } from './store.js';
|
|
4
4
|
import { SandboxPolicy } from './policy.js';
|
|
@@ -8,10 +8,10 @@ import { WorkspaceDefinition } from './workspace.js';
|
|
|
8
8
|
* create/change/delete during a chat run; lifecycle hooks fire server-side.
|
|
9
9
|
*/
|
|
10
10
|
export interface SandboxHooks {
|
|
11
|
-
onFile?: (e:
|
|
12
|
-
onFileCreate?: (e:
|
|
13
|
-
onFileChange?: (e:
|
|
14
|
-
onFileDelete?: (e:
|
|
11
|
+
onFile?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
12
|
+
onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
13
|
+
onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
14
|
+
onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
15
15
|
onReady?: (handle: SandboxHandle) => void | Promise<void>;
|
|
16
16
|
onError?: (err: unknown) => void | Promise<void>;
|
|
17
17
|
onDestroy?: () => void | Promise<void>;
|
|
@@ -42,8 +42,11 @@ export interface SandboxConfig {
|
|
|
42
42
|
lifecycle?: SandboxLifecycle;
|
|
43
43
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
44
44
|
hooks?: SandboxHooks;
|
|
45
|
-
/** Watch the workspace for file events (default true).
|
|
46
|
-
|
|
45
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
46
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
47
|
+
fileEvents?: boolean | {
|
|
48
|
+
diff?: boolean;
|
|
49
|
+
};
|
|
47
50
|
}
|
|
48
51
|
/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
|
|
49
52
|
export interface SandboxEnsureContext {
|
|
@@ -67,8 +70,11 @@ export interface SandboxDefinition {
|
|
|
67
70
|
readonly lifecycle?: SandboxLifecycle;
|
|
68
71
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
69
72
|
readonly hooks?: SandboxHooks;
|
|
70
|
-
/** Watch the workspace for file events (default true).
|
|
71
|
-
|
|
73
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
74
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
75
|
+
readonly fileEvents?: boolean | {
|
|
76
|
+
diff?: boolean;
|
|
77
|
+
};
|
|
72
78
|
/** Compound instance key for a given run context. */
|
|
73
79
|
key: (ctx: SandboxEnsureContext) => string;
|
|
74
80
|
/** Resume-or-create the sandbox for this thread/run. */
|
package/dist/esm/sandbox.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n fileEvents?: boolean\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n readonly fileEvents?: boolean\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\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. */
|