@tanstack/ai-sandbox 0.2.2 → 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.
@@ -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;
@@ -5,20 +5,29 @@ function relTo(root, path) {
5
5
  function q(value) {
6
6
  return `'${value.replace(/'/g, `'\\''`)}'`;
7
7
  }
8
- function synthesizeAddPatch(path, content) {
9
- const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
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
- +++ ${path}
16
+ return header + `--- /dev/null
17
+ +++ b/${rel}
13
18
  @@ -0,0 +1,${lines.length} @@
14
- ${body}${body ? "\n" : ""}`;
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
- return res.exitCode === 0 ? res.stdout : "";
34
- } catch {
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
- return res.exitCode === 0 ? res.stdout : "";
52
- } catch {
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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"}
@@ -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) baseSha = shaRes.stdout.trim();
64
- } catch {
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(() => void 0)
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
- ...ctx.signal !== void 0 ? { signal: ctx.signal } : {}
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.watcher?.stop();
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.watcher?.stop();
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.watcher?.stop();
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":"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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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"}
@@ -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 known = await collectPaths(handle, root, ignore);
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(() => void 0);
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(() => void 0);
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(() => void 0);
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 snapshot = async () => {
89
- const result = await handle.process.exec(command, {
90
- cwd: root,
91
- signal: controller.signal
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 result.exitCode === 0 ? parseFindOutput(result.stdout, root) : /* @__PURE__ */ new Map();
170
+ return null;
94
171
  };
95
- let previous = await snapshot();
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 next = await snapshot();
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
- const walk = async (dir) => {
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,