@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
text/typescript
/**
* 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
}