@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.
459 lines (434 loc) • 18.3 kB
text/typescript
/**
* 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.
*/
import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
import type { SandboxHandle } from './contracts'
import type { SandboxFileEvent } from '@tanstack/ai'
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
export type { SandboxFileEvent } from '@tanstack/ai'
/** @deprecated alias retained for the low-level watch API. */
export type FileEvent = SandboxFileEvent
export type FileEventType = SandboxFileEvent['type']
export interface WatchOptions {
/** Called for every observed file event. */
onEvent: (event: SandboxFileEvent) => void
/** Workspace root to watch. Defaults to `/workspace`. */
root?: string
/** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */
intervalMs?: number
/**
* Directory-name fragments to ignore (a path containing `/<entry>/` is
* skipped). Defaults to `['.git', 'node_modules']`.
*/
ignore?: Array<string>
/** Stop watching when this signal aborts. */
signal?: AbortSignal
/**
* Optional logger. When present, a failed `find` poll (non-zero exit or a
* thrown exec) is logged instead of silently degrading the snapshot — the
* failure mode a plain exec-poll watcher hides.
*/
logger?: InternalLogger
}
export interface SandboxWatchHandle {
/** Stop the watcher and release its resources. */
stop: () => Promise<void>
}
const DEFAULT_INTERVAL_MS = 700
const DEFAULT_IGNORE = ['.git', 'node_modules']
/** POSIX single-quote escape for embedding values in a shell command. */
function q(value: string): string {
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.
*/
export function diffSnapshots(
prev: Map<string, string>,
next: Map<string, string>,
timestamp: number,
): Array<SandboxFileEvent> {
const events: Array<SandboxFileEvent> = []
for (const [path, sig] of next) {
const before = prev.get(path)
if (before === undefined) 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: Array<string>): string {
const prunes = ignore
.map((entry) => `-not -path ${q(`*/${entry}/*`)}`)
.join(' ')
return `find . -type f ${prunes} -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: string, root: string): Map<string, string> {
const base = root.replace(/\/+$/, '')
const snapshot = new Map<string, string>()
for (const line of stdout.split('\n')) {
if (line === '') continue
const firstTab = line.indexOf('\t')
const secondTab = line.indexOf('\t', 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: string, ignore: Array<string>): boolean {
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.
*/
export async function watchWorkspace(
handle: SandboxHandle,
options: WatchOptions,
): Promise<SandboxWatchHandle> {
const root = options.root ?? DEFAULT_WORKSPACE_ROOT
const ignore = options.ignore ?? DEFAULT_IGNORE
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
// Already aborted before we start — don't begin any async work.
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: SandboxHandle,
options: WatchOptions & { root: string; ignore: Array<string> },
): Promise<SandboxWatchHandle> {
const { onEvent, root, ignore, logger } = options
const watch = handle.fs.watch
if (!watch) throw new Error('native watch is unavailable on this provider')
// Seed the set of existing files so the first event per path is classified
// correctly (create vs change).
const seed = await collectPaths(handle, root, ignore, logger)
const known = seed.files
// If the ROOT list failed, `known` is untrustworthy — every pre-existing
// file would misclassify as `create` on its first edit. Re-seed lazily on
// the next event(s): by the time real activity arrives the fs has usually
// recovered, and re-listing then establishes the baseline. Dedupe concurrent
// re-seeds behind a single in-flight promise.
// ponytail: a file genuinely CREATED in the narrow window between the failed
// seed and the first event gets picked up by the re-seed and so mislabels as
// `change` once. That's strictly better than the whole-run mislabel a
// never-recovered empty seed causes, and `diff()` is correct regardless.
let seeded = seed.rootOk
let reseeding: Promise<void> | null = null
const ensureSeeded = (): Promise<void> => {
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
void (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: unknown) => {
// A failed classify (e.g. `fs.exists` threw) drops this file's event —
// log it so a missing diff isn't silent (the whole point of the watcher).
logger?.warn('sandbox watch: native event classify failed', {
path,
error,
})
})
})
// A failed `subscription.stop()` can leak an OS-level watch — log rather
// than swallow it silently.
const logStopFailure = (error: unknown): void =>
logger?.warn('sandbox watch: native subscription.stop() failed', {
root,
error,
})
const onAbort = (): void => void subscription.stop().catch(logStopFailure)
options.signal?.addEventListener('abort', onAbort, { once: true })
// The signal may have aborted during the awaits above (the once-listener
// would have missed it) — tear down now if so.
if (options.signal?.aborted) void 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: SandboxHandle,
options: WatchOptions & {
root: string
ignore: Array<string>
intervalMs: number
},
): Promise<SandboxWatchHandle> {
const { onEvent, root, ignore, intervalMs, logger } = options
const command = buildFindCommand(ignore)
const controller = new AbortController()
// A poll result: the parsed snapshot plus whether `find` completed cleanly.
// `null` means the poll produced no usable output at all (thrown exec, or a
// non-zero exit with empty stdout) — callers preserve the previous snapshot.
// Collapsing a failed poll to `{}` would make the next diff fabricate a
// `delete` for every tracked file (and a `create` for each on recovery) —
// one transient `find` blip would fan a phantom storm out to hooks/stream.
interface Poll {
map: Map<string, string>
/** `false` when `find` exited non-zero but still printed rows (partial). */
complete: boolean
}
// Escalate a steady-state poll throw to `warn` after this many in a row.
const STEADY_STATE_THROW_WARN_AFTER = 3
let consecutiveThrows = 0
const snapshot = async (isInitial = false): Promise<Poll | null> => {
let result
try {
result = await handle.process.exec(command, {
cwd: root,
signal: controller.signal,
})
consecutiveThrows = 0 // exec returned (any exit code) — the seam is alive
} catch (error) {
// Thrown exec — container not ready, `find` seam rejects, or a
// mid-teardown abort. Treat as a failed poll so BOTH the initial seed
// and every tick preserve `previous` instead of rejecting setup (which
// would crash the run and leak the sandbox) or the interval.
if (isInitial) {
// The INITIAL poll can't be a teardown (a pre-aborted signal is guarded
// in `watchWorkspace`), so a throw here is an unambiguous anomaly (`find`
// missing, container never ready) that leaves the watcher dead for the
// whole run — surface it at `warn`.
logger?.warn('sandbox watch: initial `find` poll threw', {
root,
error,
})
} else if (controller.signal.aborted) {
// Mid-teardown abort — expected, stay quiet.
logger?.sandbox('sandbox watch: `find` poll threw during teardown', {
root,
error,
})
} else {
// Steady-state throw while NOT tearing down. One is usually a transient
// blip (→ `sandbox`), but a run of them means the exec seam is wedged:
// every poll returns null and the watcher emits nothing for the rest of
// the run. That silent-death case escalates to `warn` (on by default).
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 }
}
// Non-zero exit doesn't mean "no data": GNU `find` exits >0 on the first
// permission-denied entry it hits mid-traversal (common in containers, and
// the ignore list is a `-not -path` filter, not `-prune`, so `find` still
// descends into unreadable dirs) yet still prints every readable file. Use
// that partial output — marked `complete: false` so the tick merges rather
// than diffs it — instead of blinding the watcher for the whole run. Only a
// non-zero exit with NO output is a truly failed poll.
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
}
// `null` until the first poll that yields usable output. A failed INITIAL
// poll must NOT seed an empty baseline — the first successful poll would then
// diff against `{}` and fabricate a `create` for every pre-existing file. So
// the first non-null snapshot is adopted as the baseline WITHOUT diffing.
let previous: Map<string, string> | null = null
// Whether `previous` was established from a COMPLETE poll. A baseline seeded
// from a PARTIAL poll is provisional — files unreadable during that poll are
// absent from it and would later fabricate `create`s when they recover — so
// the first complete poll re-baselines without diffing.
let seededFromComplete = false
{
const poll = await snapshot(true)
if (poll) {
previous = poll.map
seededFromComplete = poll.complete
}
}
const state = { running: true }
const tick = async (): Promise<void> => {
if (!state.running) return
try {
const poll = await snapshot()
// Failed poll — keep `previous` and retry next tick (see `snapshot`).
if (poll === null) return
if (previous === null) {
// First usable snapshot after a failed initial poll — seed, don't diff.
previous = poll.map
seededFromComplete = poll.complete
return
}
if (!seededFromComplete && poll.complete) {
// First complete poll after a provisional (partial) seed — re-baseline
// WITHOUT diffing, so files merely unreadable at seed time don't
// fabricate `create`s. (Real creates during this degraded-startup
// window are missed — an acceptable trade for not fabricating events.)
logger?.sandbox(
'sandbox watch: re-baselined after provisional partial seed',
{ root },
)
previous = poll.map
seededFromComplete = true
return
}
// A partial (non-`complete`) poll can't distinguish "deleted" from
// "transiently unreadable this poll", so MERGE it over `previous`: pick
// up new/changed files without fabricating a `delete` for a path this
// poll simply couldn't see. A real deletion still surfaces on the next
// complete poll.
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) {
// Defensive: a throw from diff dispatch — preserve `previous`, retry.
logger?.sandbox('sandbox watch: tick failed', { root, error })
}
}
const timer = setInterval(() => void tick(), intervalMs)
// Don't keep the event loop alive on the watcher alone.
if (typeof timer.unref === 'function') timer.unref()
const stop = (): Promise<void> => {
if (state.running) {
state.running = false
clearInterval(timer)
controller.abort()
options.signal?.removeEventListener('abort', onAbort)
}
return Promise.resolve()
}
const onAbort = (): void => void stop()
options.signal?.addEventListener('abort', onAbort, { once: true })
// The signal may have aborted during the initial `await snapshot()` above
// (the once-listener would have missed it) — tear down now if so.
if (options.signal?.aborted) void 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: SandboxHandle,
root: string,
ignore: Array<string>,
logger?: InternalLogger,
): Promise<{ files: Set<string>; rootOk: boolean }> {
const files = new Set<string>()
let rootOk = true
const walk = async (dir: string, isRoot: boolean): Promise<void> => {
let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
try {
entries = await handle.fs.list(dir)
} catch (error) {
// A dir we can't list is seeded as empty, so its existing files would
// later misclassify as `create` on first edit — log rather than hide it.
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 }
}