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.

407 lines (383 loc) 17.1 kB
/** * Bound the journal directory: delete the journals nobody will ever read again, * and — far more importantly — refuse to delete anything else. * * `journalCleanupCommand` already deletes ONE run's journal at the moment its * `{"__exit":N}` sentinel is observed. That covers every run a host watched to * completion and covers nothing else: a run that reaches its sentinel while * DETACHED has no host reading its journal, so nothing observes the sentinel and * nothing calls the cleanup. Those journals accumulate in * {@link DEFAULT_JOURNAL_DIR} until the sandbox dies, which on a `keepAlive` * sandbox may be never. This module is the sweep that bounds them, driven from a * cron or a reaper rather than from a run. * * **Why deleting is dangerous, and therefore why almost every branch keeps.** * The journal is the ONLY copy of the bytes a successor host needs to replay a * run a dead host abandoned mid-flight. Delete a live run's journal and that run * becomes unresumable — silently, because the reader will simply deliver nothing. * There is no undo and no second copy. So the decision procedure here is not * "delete unless I have a reason to keep"; it is the opposite, and every arm that * is not a PROVEN-safe deletion keeps: * * | the store says… | action | why | * | ---------------------------------- | ------ | --- | * | terminal (`isTerminalRunStatus`) | DELETE | the delivery log, not the journal, is the record | * | non-terminal, INCLUDING `'interrupted'` | KEEP | an interrupt-resume continues from it | * | nothing (unknown runId) | KEEP until `orphanTtlMs` | the reader creates the journal BEFORE the record exists | * | the lookup threw | KEEP | never delete on an unanswered question | * | (the name did not decode) | KEEP | a truncated name decodes to a plausible WRONG runId | * | (no mtime listing) | KEEP every age-gated entry | cannot age-gate ⇒ cannot expire | * * Deleting a TERMINAL run's journal is safe because a late takeover of a terminal * run aligns against the delivery LOG, not the journal: `align.ts`'s * `alignToStoredLog` takes a `StreamDurability` plus an * `AsyncIterable<StreamChunk>`, has no `SandboxHandle` and no `JournalPaths` in * its signature, and reads the already-delivered prefix with * `durability.snapshot()`. It *cannot* read a journal, so removing one cannot * break it. A non-zero exit is terminal too — `{"__exit":7}` is as final as * `{"__exit":0}`. * * The unknown-runId arm is the subtle one, and it is why an age gate exists at * all. `journalFollowCommand` opens the journal with `: >> file`, which CREATES * it; the reader and the run record are written by two independent code paths and * nothing orders them. So "a journal exists whose runId the store has never heard * of" is the NORMAL state of a run that started moments ago, not an anomaly. * Treating unknown as deletable would race every single run start. The journal is * therefore kept until it has been untouched for `orphanTtlMs`, which is the only * evidence available that no one is writing to it. * * **The fail-closed trap this module exists to not fall into.** BusyBox `find` * prints its "unrecognized option" diagnostic to *stderr* and exits **1 with * empty stdout**. A capability probe that ignores the exit code reads that as "no * files matched", i.e. "no file is newer than the cutoff" — and code that then * concludes "therefore every file is old" **deletes the entire directory**, live * runs included. {@link parseJournalMtimeListing} is built to make that * impossible: it passes the directory as `stat`'s own first operand as a * self-witness and returns `{ kind: 'unavailable' }` when that witness line is * absent, never `[]`. This module's whole obligation on that front is to honor * `unavailable` as "I cannot age-gate, so I keep" rather than as an empty * listing. See the `age-gate-unavailable` reason. * * **Shell only, never `handle.fs.*`.** On local-process, `fs.*` resolves `/tmp` * under the sandbox root while a shell redirect hits the real host `/tmp`, so an * `fs.remove` would delete a DIFFERENT path than the one `journaledCommand` * wrote — silently doing nothing while reporting success. Every filesystem touch * here goes through `handle.process.exec` with a command composed in * `journal.ts`. */ import { isTerminalRunStatus } from '@tanstack/ai' import { DEFAULT_JOURNAL_DIR, decodeJournalRunId, journalCleanupCommand, journalListCommand, journalMtimeListCommand, journalPaths, parseJournalMtimeListing, } from './journal' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { RunStore } from '@tanstack/ai' import type { SandboxHandle } from './contracts' /** * How long a journal whose runId the store does not know must go untouched * before the sweep will delete it. * * One hour, chosen against what the window actually protects: the gap between a * reader creating the journal with `: >> file` and the run record appearing in * the store. That gap is milliseconds in the normal case and seconds in the worst * case (a slow store, a retried write). An hour is three orders of magnitude of * headroom on the race, while still bounding a leaked journal to something a * sandbox's disk survives. Erring long is the cheap direction: the cost of too * long is bytes, the cost of too short is a destroyed live run. */ export const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000 /** * Ceiling on deletions per sweep. A cron-driven sweep runs unattended, so a * mistake — a store that answers `terminal` for everything, a misconfigured * directory — is bounded by this rather than by how many journals happen to * exist. The remainder is reported as kept with reason `max-deletes` and picked * up by the next sweep. */ export const DEFAULT_MAX_DELETES = 200 /** Why {@link pruneJournals} left a journal in place. */ export type KeptJournalReason = /** The store answered with a non-terminal status (`'running'`, `'interrupted'`). */ | 'non-terminal' /** The store has never heard of this runId and the journal is still fresh. */ | 'orphan-too-recent' /** * The store has never heard of this runId and the age gate could not run at * all — {@link parseJournalMtimeListing} returned `unavailable`. THE * FAIL-CLOSED ARM: an unavailable listing is not an empty one and says nothing * about any file's age. */ | 'age-gate-unavailable' /** * The age gate ran but reported no mtime for this file, so its age is unknown. * (A file created between the two `exec`s, or a name the glob missed.) */ | 'age-gate-missing-entry' /** {@link decodeJournalRunId} refused the name (`truncated` or `malformed`). */ | 'undecodable-name' /** The store lookup threw. A question that was not answered is not a licence to delete. */ | 'store-error' /** The `rm` itself failed or exited non-zero. */ | 'delete-failed' /** {@link PruneJournalsOptions.maxDeletes} was already reached this sweep. */ | 'max-deletes' /** One journal (or one runId's journal + sidecar) the sweep declined to delete. */ export interface KeptJournal { /** The decoded runId; absent exactly when `reason` is `'undecodable-name'`. */ runId?: string /** Every listed filename this entry covers — the journal and its `.err` sidecar. */ names: Array<string> reason: KeptJournalReason } /** A non-fatal failure the sweep folded into its result instead of throwing. */ export interface PruneJournalsFailure { stage: 'list' | 'mtime-list' | 'store' | 'delete' /** Present when the failure is attributable to one run. */ runId?: string message: string } /** What one {@link pruneJournals} sweep did. */ export interface PruneJournalsResult { /** Filenames `ls -1` reported, before de-duplication by runId. */ listed: number /** Distinct runIds those filenames decoded to. */ runIds: number /** runIds whose journal AND sidecar were deleted, in the order deleted. */ deleted: Array<string> /** Everything left in place, with the reason. */ kept: Array<KeptJournal> /** * Whether the mtime age gate was usable this sweep. `'unavailable'` means no * orphan could be expired, by design. */ ageGate: 'listed' | 'unavailable' failures: Array<PruneJournalsFailure> } export interface PruneJournalsOptions { /** Sandbox holding the journal directory. Touched only via `process.exec`. */ handle: SandboxHandle /** * Run lookup. Only `get` is used: the sweep asks about the runIds it found on * disk and never enumerates the store, so no optional `RunStore` method is * required of a backend. */ runs: Pick<RunStore, 'get'> /** Journal directory. Defaults to {@link DEFAULT_JOURNAL_DIR}. */ dir?: string /** Age-gate reference time. Defaults to `Date.now()`; injectable for tests. */ now?: number /** See {@link DEFAULT_ORPHAN_TTL_MS}. */ orphanTtlMs?: number /** See {@link DEFAULT_MAX_DELETES}. */ maxDeletes?: number logger?: InternalLogger } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } /** * Group listed filenames by the runId they decode to, so a journal and its * `.err` sidecar are ONE decision and ONE `rm`, not two. * * De-duplication is not a tidiness measure: `journalCleanupCommand` deletes both * paths for a runId at once, so iterating raw names would ask the store twice per * run and then issue a second `rm` for files the first one already removed — * doubling the store load and reporting one run as two deletions. */ function groupByRunId(names: Array<string>): { byRunId: Map<string, Array<string>> undecodable: Array<string> } { const byRunId = new Map<string, Array<string>>() const undecodable: Array<string> = [] for (const name of names) { const decoded = decodeJournalRunId(name) if (decoded.kind !== 'runId') { undecodable.push(name) continue } const existing = byRunId.get(decoded.runId) if (existing === undefined) byRunId.set(decoded.runId, [name]) else existing.push(name) } return { byRunId, undecodable } } /** * Sweep the journal directory, deleting only journals whose runs the store * reports terminal (plus orphans that have been untouched past `orphanTtlMs`). * * **Never rejects.** This runs unattended from a cron, where a rejected promise * is an unhandled rejection and, worse, hides which journals were and were not * swept. Every failure — a listing that errored, a store that threw, an `rm` that * exited non-zero — is folded into * {@link PruneJournalsResult.failures} and the sweep continues with the entries * it can still decide about. */ export async function pruneJournals( options: PruneJournalsOptions, ): Promise<PruneJournalsResult> { const dir = options.dir ?? DEFAULT_JOURNAL_DIR const now = options.now ?? Date.now() const orphanTtlMs = options.orphanTtlMs ?? DEFAULT_ORPHAN_TTL_MS const maxDeletes = options.maxDeletes ?? DEFAULT_MAX_DELETES const logger = options.logger const deleted: Array<string> = [] const kept: Array<KeptJournal> = [] const failures: Array<PruneJournalsFailure> = [] // `ls -1` is the authoritative name list. It is a SEPARATE command from the // mtime listing on purpose: `stat -c` may not exist on the provider's // busybox, and a sweep that could not enumerate at all when the age gate is // unavailable would never delete the terminal journals it is safe to delete. let names: Array<string> = [] try { const listing = await options.handle.process.exec(journalListCommand(dir)) names = listing.stdout .split('\n') .map((line) => line.trim()) .filter((line) => line !== '') } catch (error) { // Nothing was enumerated, so nothing can be deleted. Report and stop — // there is no partial-listing arm, because a partial listing is // indistinguishable from a complete one and we only ever DELETE from it. failures.push({ stage: 'list', message: errorMessage(error) }) logger?.warn('journal sweep: listing the journal directory failed', { dir, error, }) return { listed: 0, runIds: 0, deleted, kept, ageGate: 'unavailable', failures, } } // The age gate. `unavailable` is a first-class outcome, NOT an empty listing: // see the module doc's BusyBox `find` trap. It disables orphan expiry for this // sweep and disables nothing else. let ageGate: 'listed' | 'unavailable' = 'unavailable' const mtimes = new Map<string, number>() try { const probe = await options.handle.process.exec( journalMtimeListCommand(dir), ) const parsed = parseJournalMtimeListing(probe.stdout, dir) if (parsed.kind === 'listed') { ageGate = 'listed' for (const entry of parsed.entries) mtimes.set(entry.name, entry.mtimeMs) } else { logger?.warn( 'journal sweep: mtime listing unavailable; keeping every orphan', { dir }, ) } } catch (error) { failures.push({ stage: 'mtime-list', message: errorMessage(error) }) logger?.warn('journal sweep: mtime listing failed; keeping every orphan', { dir, error, }) } const { byRunId, undecodable } = groupByRunId(names) // Undecodable names are kept unconditionally and without asking the store. // A truncated name decodes to a PLAUSIBLE BUT WRONG runId, so consulting the // store about it would answer a question about some other run — possibly a // live one — and a `terminal` answer would then delete this run's journal. for (const name of undecodable) { kept.push({ names: [name], reason: 'undecodable-name' }) } const orphanCutoff = now - orphanTtlMs for (const [runId, runNames] of byRunId) { if (deleted.length >= maxDeletes) { kept.push({ runId, names: runNames, reason: 'max-deletes' }) continue } let record: Awaited<ReturnType<RunStore['get']>> try { record = await options.runs.get(runId) } catch (error) { failures.push({ stage: 'store', runId, message: errorMessage(error) }) logger?.warn('journal sweep: run lookup failed; keeping the journal', { runId, error, }) kept.push({ runId, names: runNames, reason: 'store-error' }) continue } if (record === null) { // Unknown to the store: either the record has not been written yet (the // normal case for a run that just started) or it was deleted after the // run ended. Only age distinguishes them. if (ageGate === 'unavailable') { kept.push({ runId, names: runNames, reason: 'age-gate-unavailable' }) continue } const observed = runNames.map((name) => mtimes.get(name)) if (observed.some((mtimeMs) => mtimeMs === undefined)) { kept.push({ runId, names: runNames, reason: 'age-gate-missing-entry' }) continue } // The NEWEST of the run's files decides: a journal whose sidecar was // written a second ago is being written to, whatever the journal's own // mtime says. const newest = Math.max(...observed.filter(isDefined)) if (newest > orphanCutoff) { kept.push({ runId, names: runNames, reason: 'orphan-too-recent' }) continue } } else if (!isTerminalRunStatus(record.status)) { // `'interrupted'` lands here, and must: it is a human-in-the-loop PAUSE // that interrupt-resume continues from, not an end state. kept.push({ runId, names: runNames, reason: 'non-terminal' }) continue } // Shell `rm`, never `handle.fs.remove`: module doc, and `journalPaths` // re-derives byte-identical paths from the runId alone. const command = journalCleanupCommand(journalPaths(runId, dir)) try { const result = await options.handle.process.exec(command) if (result.exitCode !== 0) { failures.push({ stage: 'delete', runId, message: `rm exited ${result.exitCode}`, }) kept.push({ runId, names: runNames, reason: 'delete-failed' }) continue } } catch (error) { // A failed cleanup must never fail the sweep: the journal is still there // and the next sweep will see it again. failures.push({ stage: 'delete', runId, message: errorMessage(error) }) logger?.warn('journal sweep: deleting a journal failed', { runId, error }) kept.push({ runId, names: runNames, reason: 'delete-failed' }) continue } deleted.push(runId) } logger?.sandbox('journal sweep complete', { dir, listed: names.length, runIds: byRunId.size, deleted: deleted.length, kept: kept.length, ageGate, }) return { listed: names.length, runIds: byRunId.size, deleted, kept, ageGate, failures, } } /** Narrowing predicate: `Array<number | undefined>` → `Array<number>`. */ function isDefined(value: number | undefined): value is number { return value !== undefined }