UNPKG

@tanstack/ai-sandbox

Version:

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.

328 lines (327 loc) 10.2 kB
import "./bootstrap.js"; //#region src/watch.ts /** * Sandbox file-event hooks — observe create / change / delete of files inside a * sandbox (e.g. as an in-sandbox agent edits the workspace). * * Provider-agnostic: coded against the {@link SandboxHandle} contract only. * Two mechanisms, auto-selected: * * - **Native** — when a provider implements the optional `fs.watch` seam * (local-process does, via Node `fs.watch`), OS events drive the feed with low * latency. * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a * single `find … -printf` snapshot of `mtime\tsize\tpath` is taken every * `intervalMs` and diffed. Works on any Linux container with GNU findutils * (true for `node:*` / debian images) with no extra deps or image changes. * * The feed intentionally rides only the portable surface, so the same * `watchWorkspace` call behaves identically across providers. */ var DEFAULT_INTERVAL_MS = 700; var DEFAULT_IGNORE = [".git", "node_modules"]; /** POSIX single-quote escape for embedding values in a shell command. */ function q(value) { return `'${value.replace(/'/g, `'\\''`)}'`; } /** * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\tsize`). * Pure — the heart of the exec-poll path, unit-tested in isolation. */ function diffSnapshots(prev, next, timestamp) { const events = []; for (const [path, sig] of next) { const before = prev.get(path); if (before === void 0) events.push({ type: "create", path, timestamp }); else if (before !== sig) events.push({ type: "change", path, timestamp }); } for (const path of prev.keys()) if (!next.has(path)) events.push({ type: "delete", path, timestamp }); return events; } /** * Build the `find` command that prints `mtime\tsize\tpath` for every file. * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a * provider's `exec` maps only `cwd` onto the real filesystem, not literal path * arguments, so `find <virtual-root>` would look at a non-existent host path on * mapped-root providers (e.g. local-process). Emitted `%p` values are * root-normalized in {@link parseFindOutput}. */ function buildFindCommand(ignore) { return `find . -type f ${ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ")} -printf '%T@\\t%s\\t%p\\n'`; } /** * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints * paths like `./sub/file`; map them back under `root` so event paths match the * native-watch shape (`<root>/sub/file`). */ function parseFindOutput(stdout, root) { const base = root.replace(/\/+$/, ""); const snapshot = /* @__PURE__ */ new Map(); for (const line of stdout.split("\n")) { if (line === "") continue; const firstTab = line.indexOf(" "); const secondTab = line.indexOf(" ", firstTab + 1); if (firstTab === -1 || secondTab === -1) continue; const mtime = line.slice(0, firstTab); const size = line.slice(firstTab + 1, secondTab); const rel = line.slice(secondTab + 1).replace(/^\.\/?/, ""); const path = rel === "" ? base : `${base}/${rel}`; snapshot.set(path, `${mtime}\t${size}`); } return snapshot; } /** Whether a path should be ignored (contains a `/<entry>/` fragment). */ function isIgnored(path, ignore) { return ignore.some((entry) => path.includes(`/${entry}/`)); } /** * Start watching a sandbox workspace for file events. Picks the native * `fs.watch` fast-path when the provider advertises it, otherwise polls via * `find`. Returns a handle whose `stop()` tears everything down. */ async function watchWorkspace(handle, options) { const root = options.root ?? "/workspace"; const ignore = options.ignore ?? DEFAULT_IGNORE; const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; if (options.signal?.aborted) return { stop: () => Promise.resolve() }; if (handle.fs.watch) return startNativeWatch(handle, { ...options, root, ignore }); return startPollWatch(handle, { ...options, root, ignore, intervalMs }); } /** Native fs.watch path: OS events, disambiguated against a known-path set. */ async function startNativeWatch(handle, options) { const { onEvent, root, ignore, logger } = options; const watch = handle.fs.watch; if (!watch) throw new Error("native watch is unavailable on this provider"); const seed = await collectPaths(handle, root, ignore, logger); const known = seed.files; let seeded = seed.rootOk; let reseeding = null; const ensureSeeded = () => { if (seeded) return Promise.resolve(); if (!reseeding) reseeding = collectPaths(handle, root, ignore, logger).then((r) => { if (r.rootOk) { for (const p of r.files) known.add(p); seeded = true; logger?.sandbox("sandbox watch: re-seeded after failed initial seed", { root }); } reseeding = null; }); return reseeding; }; const subscription = await watch(root, (raw) => { const path = raw.path; if (isIgnored(path, ignore)) return; (async () => { await ensureSeeded(); const exists = await handle.fs.exists(path); const timestamp = Date.now(); if (!exists) { if (known.delete(path)) onEvent({ type: "delete", path, timestamp }); return; } if (known.has(path)) onEvent({ type: "change", path, timestamp }); else { known.add(path); onEvent({ type: "create", path, timestamp }); } })().catch((error) => { logger?.warn("sandbox watch: native event classify failed", { path, error }); }); }); const logStopFailure = (error) => logger?.warn("sandbox watch: native subscription.stop() failed", { root, error }); const onAbort = () => void subscription.stop().catch(logStopFailure); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.signal?.aborted) subscription.stop().catch(logStopFailure); return { stop: async () => { options.signal?.removeEventListener("abort", onAbort); await subscription.stop(); } }; } /** Exec-poll path: snapshot `find -printf` on an interval and diff. */ async function startPollWatch(handle, options) { const { onEvent, root, ignore, intervalMs, logger } = options; const command = buildFindCommand(ignore); const controller = new AbortController(); const STEADY_STATE_THROW_WARN_AFTER = 3; let consecutiveThrows = 0; const snapshot = async (isInitial = false) => { let result; try { result = await handle.process.exec(command, { cwd: root, signal: controller.signal }); consecutiveThrows = 0; } catch (error) { if (isInitial) logger?.warn("sandbox watch: initial `find` poll threw", { root, error }); else if (controller.signal.aborted) logger?.sandbox("sandbox watch: `find` poll threw during teardown", { root, error }); else { consecutiveThrows += 1; if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) logger?.warn("sandbox watch: `find` poll threw repeatedly", { root, error, consecutiveThrows }); else logger?.sandbox("sandbox watch: `find` poll threw", { root, error }); } return null; } if (result.exitCode === 0) return { map: parseFindOutput(result.stdout, root), complete: true }; if (result.stdout !== "") { logger?.sandbox("sandbox watch: `find` non-zero exit with partial output", { root, exitCode: result.exitCode, stderr: result.stderr }); return { map: parseFindOutput(result.stdout, root), complete: false }; } logger?.warn("sandbox watch: `find` poll exited non-zero with no output", { root, exitCode: result.exitCode, stderr: result.stderr }); return null; }; let previous = null; let seededFromComplete = false; { const poll = await snapshot(true); if (poll) { previous = poll.map; seededFromComplete = poll.complete; } } const state = { running: true }; const tick = async () => { if (!state.running) return; try { const poll = await snapshot(); if (poll === null) return; if (previous === null) { previous = poll.map; seededFromComplete = poll.complete; return; } if (!seededFromComplete && poll.complete) { logger?.sandbox("sandbox watch: re-baselined after provisional partial seed", { root }); previous = poll.map; seededFromComplete = true; return; } const next = poll.complete ? poll.map : new Map([...previous, ...poll.map]); for (const event of diffSnapshots(previous, next, Date.now())) onEvent(event); previous = next; } catch (error) { logger?.sandbox("sandbox watch: tick failed", { root, error }); } }; const timer = setInterval(() => void tick(), intervalMs); if (typeof timer.unref === "function") timer.unref(); const stop = () => { if (state.running) { state.running = false; clearInterval(timer); controller.abort(); options.signal?.removeEventListener("abort", onAbort); } return Promise.resolve(); }; const onAbort = () => void stop(); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.signal?.aborted) stop(); return { stop }; } /** * Recursively collect file paths under `root`, honoring `ignore`. `rootOk` is * `false` when the ROOT `list` itself failed — the seed is then untrustworthy * (empty/partial), which the native watcher uses to trigger a lazy re-seed. A * failed *subdirectory* list is logged but doesn't flip `rootOk` (its files are * simply absent, a smaller misclassification surface). */ async function collectPaths(handle, root, ignore, logger) { const files = /* @__PURE__ */ new Set(); let rootOk = true; const walk = async (dir, isRoot) => { let entries; try { entries = await handle.fs.list(dir); } catch (error) { if (isRoot) rootOk = false; logger?.warn("sandbox watch: failed to list directory while seeding", { dir, error }); return; } for (const entry of entries) { if (ignore.includes(entry.name)) continue; if (entry.type === "dir") await walk(entry.path, false); else files.add(entry.path); } }; await walk(root, true); return { files, rootOk }; } //#endregion export { diffSnapshots, watchWorkspace }; //# sourceMappingURL=watch.js.map