@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.
- package/dist/esm/file-diff.d.ts +5 -2
- package/dist/esm/file-diff.js +90 -12
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/middleware.js +62 -16
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/watch.d.ts +7 -0
- package/dist/esm/watch.js +128 -20
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +157 -11
- package/src/middleware.ts +91 -15
- package/src/watch.ts +213 -24
package/dist/esm/watch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/**\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.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@ngrok/ngrok": "^1.0.0",
|
|
47
|
-
"@tanstack/ai": "^0.
|
|
47
|
+
"@tanstack/ai": "^0.41.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@ngrok/ngrok": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@ngrok/ngrok": "^1.7.0",
|
|
56
56
|
"@vitest/coverage-v8": "4.0.14",
|
|
57
|
-
"@tanstack/ai": "0.
|
|
57
|
+
"@tanstack/ai": "0.41.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build": "vite build",
|
package/src/file-diff.ts
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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)
|
|
138
|
-
|
|
139
|
-
|
|
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(() =>
|
|
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
|
|
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
|
|
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
|
|
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
|