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.

148 lines (136 loc) 7.19 kB
/** * Conformance for the DURABLE-RUN fields on a `RunStore`. * * These four fields (`sandboxKey`, `detachedSince`, `cancelRequested`, * `driverEpoch`) exist for durable sandboxed runs: detach on disconnect, takeover * by a later host, and the reaper. A chat-only app never writes them, so proving * them is NOT part of `runPersistenceConformance` in `@tanstack/ai-persistence`. * They live here, next to the takeover and reaper suites that depend on them. * * Run this when your app wires `withSandbox(sandbox, { runs, durability })`. The * fields round-trip through the REQUIRED `update`/`get` pair, so a backend can * pass every persistence case while silently dropping one of them, and the * failure then shows up as a run that looks permanently detached or a takeover * that cannot fence a superseded host. * * ```ts * import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit' * import { myPersistence } from './persistence' * * runDurableRunFieldsConformance('my postgres runs', () => myPersistence().stores.runs) * ``` */ import { describe, expect, it } from 'vitest' import type { RunStore } from '@tanstack/ai' /** Factory for the store under test. A fresh one per case keeps them isolated. */ export type MakeRunStore = () => RunStore | Promise<RunStore> export function runDurableRunFieldsConformance( name: string, makeStore: MakeRunStore, ): void { describe(`durable run fields conformance: ${name}`, () => { // One case, because the four fields share one failure mode: a backend that // filters `undefined` out of its `SET` clause, or coerces an absent column to // a falsy default, passes every other assertion while breaking detach and // takeover. Splitting it per field would hide that they must all behave the // same way through one `update`. it('round-trips the durable run fields, overwrites driverEpoch, and clears every one of them on explicit undefined', async () => { const store = await makeStore() await store.createOrResume({ runId: 'fc-1', threadId: 'fc-t', startedAt: 1, }) // 0. A fresh run that was never patched with these fields must read // back as undefined -- not null, not false, not 0. A backend that // coerces a NULL/absent column to a falsy default (e.g. // `cancelRequested: false`) is claiming knowledge ("explicitly not // cancelled") it does not have, and `toBeFalsy()` would not catch // it since `false` is falsy too. const fresh = await store.get('fc-1') expect(fresh?.cancelRequested).toBeUndefined() expect(fresh?.detachedSince).toBeUndefined() expect(fresh?.sandboxKey).toBeUndefined() expect(fresh?.driverEpoch).toBeUndefined() // 1. All four fields round-trip through update -> get. await store.update('fc-1', { sandboxKey: 'sandbox-abc', detachedSince: 500, cancelRequested: true, driverEpoch: 1, }) const afterFirstUpdate = await store.get('fc-1') expect(afterFirstUpdate?.sandboxKey).toBe('sandbox-abc') expect(afterFirstUpdate?.detachedSince).toBe(500) expect(afterFirstUpdate?.cancelRequested).toBe(true) expect(afterFirstUpdate?.driverEpoch).toBe(1) // 2. A monotonic driverEpoch bump overwrites, it is not ignored (a // takeover host bumping the fencing token must actually stick). await store.update('fc-1', { driverEpoch: 2 }) const afterEpochBump = await store.get('fc-1') expect(afterEpochBump?.driverEpoch).toBe(2) // Sibling fields untouched by an update that only names driverEpoch. expect(afterEpochBump?.sandboxKey).toBe('sandbox-abc') expect(afterEpochBump?.cancelRequested).toBe(true) // 3. update({ detachedSince: undefined }) actually CLEARS the field. // A backend whose SQL adapter filters `undefined` out of its `SET` // clause leaves the old value, and every re-attached run then looks // permanently detached to the reaper. await store.update('fc-1', { detachedSince: undefined }) const afterClear = await store.get('fc-1') expect(afterClear?.detachedSince).toBeUndefined() // Clearing detachedSince must not clobber the other durable fields. expect(afterClear?.sandboxKey).toBe('sandbox-abc') expect(afterClear?.cancelRequested).toBe(true) expect(afterClear?.driverEpoch).toBe(2) // 4. cancelRequested: false written EXPLICITLY must round-trip as // `false`, distinct from the fresh-run `undefined` checked in step 0. // A backend storing this boolean in an integer/NULL-able column has // to preserve the false/undefined distinction in both directions, // not just collapse both to falsy. await store.update('fc-1', { cancelRequested: false }) const afterExplicitFalse = await store.get('fc-1') expect(afterExplicitFalse?.cancelRequested).toBe(false) expect(afterExplicitFalse?.cancelRequested).not.toBeUndefined() // 5. An explicit `undefined` clears EVERY durable field, not just // `detachedSince`. Step 3 only exercised one of the four, so a backend // half-converted to `'field' in patch` -- `in` for `detachedSince`, // still `patch.field !== undefined` for the rest -- passed the whole // suite while its clears silently no-opped. Step 4's explicit `false` // also survives a `!== undefined` guard, so nothing else here bites // either. Re-populate first, so each clear has a value to remove and // an assertion that fails when the clear is dropped. await store.update('fc-1', { sandboxKey: 'sandbox-xyz', detachedSince: 900, cancelRequested: true, driverEpoch: 3, }) const beforeFullClear = await store.get('fc-1') expect(beforeFullClear?.sandboxKey).toBe('sandbox-xyz') expect(beforeFullClear?.detachedSince).toBe(900) expect(beforeFullClear?.cancelRequested).toBe(true) expect(beforeFullClear?.driverEpoch).toBe(3) await store.update('fc-1', { sandboxKey: undefined, detachedSince: undefined, cancelRequested: undefined, driverEpoch: undefined, }) const afterFullClear = await store.get('fc-1') expect(afterFullClear?.sandboxKey).toBeUndefined() expect(afterFullClear?.detachedSince).toBeUndefined() expect(afterFullClear?.cancelRequested).toBeUndefined() expect(afterFullClear?.driverEpoch).toBeUndefined() // Clearing the durable fields is not a delete: the run row survives, // and the fields the patch never named keep their values. expect(afterFullClear?.status).toBe('running') expect(afterFullClear?.startedAt).toBe(1) }) // `findActiveRun` is REQUIRED on the RunStore contract — every backend that // provides a `runs` store must satisfy these invariants (most-recent-running // wins, thread-scoped, null when idle). Reconnect is built on it, and a // backend that always answers `null` disables reconnect indistinguishably // from one that is merely idle, so this must never degrade to a skip. }) }