@tanstack/ai-sandbox 0.2.2 → 0.2.4

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