From 494f8fef3c86a506d2858511f0263bf47172e7ba Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 07:42:53 +0000 Subject: [PATCH] feat(state): node:sqlite workspace-durable driver + rsc-agent-runtime migrates off its JSONL kernel (#98 v1, PR-2) The workspace-durable driver ships on node:sqlite (G3: zero new dependencies; the ExperimentalWarning is documented in the module and both READMEs): WAL with full synchronous durability, every commit in one BEGIN IMMEDIATE transaction, bounded busy-timeout cross-process serialization, explicit migrations on open, corruption fail-closed. It passes the same conformance suite as the memory driver plus the two cross-process acceptance proofs (two independent writers over one store; a SIGKILLed writer cannot leave successful-but-corrupt state), and lives behind its own ./state/sqlite subpath sharing one kernel runtime instance with ./state so error identity holds across entries. examples/rsc-agent-runtime retires its 781-line JSONL kernel and lock machinery: the example now declares schema/events/reducer via defineState and adapts the framework store to its provider-facing RuntimeKernel contract, with eventId/recordedAt derived from journal revisions and commit timestamps so whole-payload idempotency matches the retired kernel's semantic dedupe identity. Tests and pnpm eval:spot stay green, with the spot check now also proving cross-process idempotent replay. --- .changeset/state-kernel-sqlite-driver.md | 17 + docs/architecture/rsc-runtime-workbench.md | 4 +- examples/rsc-agent-runtime/README.md | 37 +- examples/rsc-agent-runtime/package.json | 2 - .../rsc-agent-runtime/scripts/eval-hosts.mjs | 28 +- .../src/dev/rsbuild-runtime-session.ts | 2 +- .../src/runtime/contracts.ts | 14 - .../src/runtime/state-definition.ts | 80 ++ .../src/runtime/state-file-core.ts | 781 -------------- .../src/runtime/state-file-test-support.ts | 116 --- .../src/runtime/state-file.ts | 204 +++- .../tests/eval-evidence.test.ts | 4 +- .../tests/fixtures/state-lock-owner.mjs | 31 - .../tests/fixtures/state-settlement-exit.ts | 27 - .../tests/host-artifacts.test.ts | 8 +- .../tests/mcp-transports.integration.test.ts | 33 +- .../tests/micro-eval.spot.test.ts | 37 +- .../tests/rsc-hook.integration.test.ts | 14 +- .../tests/state-and-definition.test.ts | 982 ++---------------- .../tests/support/state-driver-warnings.ts | 16 + packages/rsc-runtime/package.json | 5 + packages/rsc-runtime/rslib.config.ts | 41 +- packages/rsc-runtime/src/state/index.ts | 16 + packages/rsc-runtime/src/state/sqlite.ts | 584 +++++++++++ .../tests/fixtures/state-sqlite-writer.mjs | 59 ++ .../rsc-runtime/tests/state-packaging.test.ts | 77 ++ .../tests/state-sqlite-cross-process.test.ts | 161 +++ .../rsc-runtime/tests/state-sqlite.test.ts | 197 ++++ pnpm-lock.yaml | 43 - rstest.integration-tests.ts | 2 + 30 files changed, 1625 insertions(+), 1997 deletions(-) create mode 100644 .changeset/state-kernel-sqlite-driver.md create mode 100644 examples/rsc-agent-runtime/src/runtime/state-definition.ts delete mode 100644 examples/rsc-agent-runtime/src/runtime/state-file-core.ts delete mode 100644 examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts delete mode 100644 examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs delete mode 100644 examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts create mode 100644 examples/rsc-agent-runtime/tests/support/state-driver-warnings.ts create mode 100644 packages/rsc-runtime/src/state/sqlite.ts create mode 100644 packages/rsc-runtime/tests/fixtures/state-sqlite-writer.mjs create mode 100644 packages/rsc-runtime/tests/state-packaging.test.ts create mode 100644 packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts create mode 100644 packages/rsc-runtime/tests/state-sqlite.test.ts diff --git a/.changeset/state-kernel-sqlite-driver.md b/.changeset/state-kernel-sqlite-driver.md new file mode 100644 index 000000000..41e2109c8 --- /dev/null +++ b/.changeset/state-kernel-sqlite-driver.md @@ -0,0 +1,17 @@ +--- +"@agent-bundle/runtime": minor +--- + +Ship the workspace-durable state driver on `node:sqlite` (#98 v1, G3) +behind the dedicated `./state/sqlite` subpath: WAL journal mode with full +synchronous durability, every commit in one immediate transaction +(idempotency lookup, compare-and-swap, reducer, journal append, head update +commit atomically), cross-process writers serialized on the database lock +with a bounded busy timeout, explicit migrations on open, and corruption +failing closed with typed errors. The driver passes the same conformance +suite as the in-memory driver, plus cross-process proofs: two independent +processes updating one store, and a SIGKILLed writer never leaving a +successful-but-corrupt state. The subpath split keeps `node:sqlite` (and +its ExperimentalWarning) away from volatile-state and stateless consumers, +and the package now declares `"sideEffects": false` so bundlers can +tree-shake unused kernel exports. diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 74be82ea3..2c4d87892 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -145,6 +145,7 @@ examples/ src/flight/request-render.ts src/hook/cli.ts src/hook/normalize.ts + src/hook/project-document.ts src/mcp/create-server.ts src/mcp/handlers.ts src/mcp/host-metadata.ts @@ -157,8 +158,7 @@ examples/ src/rsc/routes.tsx src/rsc/worker.tsx src/runtime/contracts.ts - src/runtime/state-file-core.ts - src/runtime/state-file-test-support.ts + src/runtime/state-definition.ts src/runtime/state-file.ts src/types/mcp-ext-apps-react.d.ts src/types/react-server-dom-rspack.d.ts diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 4efebbd04..595f53e25 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -7,7 +7,7 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha | Plane | Responsibility | Lifetime | | --- | --- | --- | | Definition | Static hook matchers, tool schemas, resource URIs, and metadata | Build/startup | -| Kernel | Append-only JSONL events and snapshots | Cross-process | +| Kernel | Framework state kernel (#98): typed events, monotonic revisions, workspace-durable `node:sqlite` storage | Cross-process | | RSC render | Hook and MCP result component trees, lowered from Flight | One request | | MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance | @@ -96,7 +96,7 @@ pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --jso To exercise one hook manually, give it an explicit state file and native Claude-shaped JSON: ```bash -AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \ +AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \ node examples/rsc-agent-runtime/dist/runtime/hook/index.js --host claude <= 22.13), +harmless for hooks and MCP servers (protocol output uses stdout), and absent +for stateless consumers because the driver lives behind its own subpath. It is +suitable local single-workspace storage, not concurrent/distributed production +storage. The RSC-facing packages are exact pins because their framework-facing +surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`, +`react-server-dom-rspack` `0.1.0`, Rsbuild `2.2.1`, and `rsbuild-plugin-rsc` +`0.1.1`. Existing Agent Bundle skills, static MCPs, evaluations, and normal hooks neither require nor activate this runtime. Nothing under `packages/agent-bundle` imports the example or React/RSC runtime packages. diff --git a/examples/rsc-agent-runtime/package.json b/examples/rsc-agent-runtime/package.json index 8dfe67676..cbc3ec0e4 100644 --- a/examples/rsc-agent-runtime/package.json +++ b/examples/rsc-agent-runtime/package.json @@ -16,7 +16,6 @@ "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/sdk": "1.30.0", "express": "5.2.1", - "proper-lockfile": "^4.1.2", "react": "19.2.8", "react-dom": "19.2.8", "react-server-dom-rspack": "0.1.0", @@ -27,7 +26,6 @@ "@rsbuild/plugin-react": "2.1.0", "@rstest/core": "0.11.10", "@types/express": "5.0.6", - "@types/proper-lockfile": "^4.1.4", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "agent-bundle": "workspace:*", diff --git a/examples/rsc-agent-runtime/scripts/eval-hosts.mjs b/examples/rsc-agent-runtime/scripts/eval-hosts.mjs index 3aeebe067..dc5036a0a 100644 --- a/examples/rsc-agent-runtime/scripts/eval-hosts.mjs +++ b/examples/rsc-agent-runtime/scripts/eval-hosts.mjs @@ -6,6 +6,7 @@ import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/pro import { once } from 'node:events'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { classifyNativeEvidence, evidenceFromTranscript, hookEvidenceFromProbe, summarizeHookProbe } from './eval-evidence.mjs'; import { sanitizedHostEnvironment } from './eval-host-environment.mjs'; @@ -65,10 +66,29 @@ const hookProbeSummary = async (probeFile) => { return summarizeHookProbe(records); }; +/** + * Reads committed edit events from the framework state kernel's sqlite + * journal (#98) in the legacy record shape `evidenceFromTranscript` matches + * on. Read-only: host evidence collection never mutates runtime state. + */ +const readStateRecords = (stateFile) => { + try { + const db = new DatabaseSync(stateFile, { readOnly: true }); + try { + return db + .prepare("SELECT payload FROM agent_state_journal WHERE kind = 'event' ORDER BY revision") + .all() + .map(({ payload }) => ({ event: JSON.parse(payload), kind: 'edit' })); + } finally { + db.close(); + } + } catch { + return []; + } +}; + const evidenceFrom = async (host, fixture, stateFile, probeFile, transcript, correlation) => { - const stateRecords = await readFile(stateFile, 'utf8') - .then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line))) - .catch(() => []); + const stateRecords = readStateRecords(stateFile); const transcriptEvidence = evidenceFromTranscript(host, transcript, { ...correlation, stateRecords }); const editObserved = await stat(join(fixture, correlation.editPath)).then(() => true).catch(() => false); const hookProbe = await hookProbeSummary(probeFile); @@ -102,7 +122,7 @@ const evaluateHost = async (host, capturedAt) => { finalMarker: `HOST_EVAL_FINAL host=${host} marker=${marker}`, marker, }; - const stateFile = join(fixture, '.agent-runtime-demo', 'events.jsonl'); + const stateFile = join(fixture, '.agent-runtime-demo', 'state.sqlite'); const probeFile = join(fixture, 'hook-probe.jsonl'); const sharedEnv = sanitizedHostEnvironment(process.env, { hookProbeFile: probeFile, stateFile }); let temporaryCodexHome; diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index 7b6db6e1e..08c301d3e 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -721,7 +721,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { this.#activationPhaseBudgetMs = input.testing.activationPhaseBudgetMs ?? defaultActivationPhaseBudgetMs; this.#ownedRunsRoot = input.ownedRunsRoot; this.#runRoot = input.ownedRunsRoot.root; - this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.jsonl`); + this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.sqlite`); this.#stateKernel = createFileRuntimeKernel({ stateFile: this.#stateFile }); this.#preparedRevisions.add(input.preparedRuntime.sourceRevision); this.#status = Object.freeze({ diff --git a/examples/rsc-agent-runtime/src/runtime/contracts.ts b/examples/rsc-agent-runtime/src/runtime/contracts.ts index bfb5dc9c7..a69998996 100644 --- a/examples/rsc-agent-runtime/src/runtime/contracts.ts +++ b/examples/rsc-agent-runtime/src/runtime/contracts.ts @@ -22,20 +22,6 @@ export type JsonValue = | readonly JsonValue[] | Readonly<{ [key: string]: JsonValue }>; -export type RuntimeStateRecord = - | Readonly<{ - event: EditEvent; - idempotencyKey: string; - kind: 'edit'; - stateVersion: number; - }> - | Readonly<{ - idempotencyKey: string; - kind: 'reset'; - seed?: JsonValue; - stateVersion: number; - }>; - export interface RuntimeSnapshot { stateVersion: number; edits: EditEvent[]; diff --git a/examples/rsc-agent-runtime/src/runtime/state-definition.ts b/examples/rsc-agent-runtime/src/runtime/state-definition.ts new file mode 100644 index 000000000..4fcd00e69 --- /dev/null +++ b/examples/rsc-agent-runtime/src/runtime/state-definition.ts @@ -0,0 +1,80 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +import type { JsonValue } from './contracts.js'; + +/** + * The edit-timeline state, declared once against the framework state kernel + * (#98). This replaces the example's retired hand-rolled JSONL kernel + * (`state-file-core.ts`): the framework owns revisions, idempotency + * replay/conflict, atomicity, exact-revision reads, migrations, and + * corruption fail-closed behavior; the example declares its schema, events, + * and pure reducer. + * + * The event payload carries exactly the caller-owned semantic fields — + * host, path, sessionId, toolName — which makes the kernel's whole-payload + * idempotency identity match the retired kernel's canonical dedupe input. + * Presentation fields are derived, not stored: `eventId` comes from the + * committed revision and `recordedAt` from the journal's commit timestamp + * (see `state-file.ts`), so retries of one native tool event replay cleanly + * instead of conflicting over generated values. + */ + +const nonEmpty = (): z.ZodType => z.string().refine((value) => value.trim() !== '', 'must be non-empty'); + +export const RecordedEditSchema = z + .object({ + host: z.enum(['claude', 'codex']), + path: nonEmpty(), + sessionId: nonEmpty(), + toolName: nonEmpty(), + }) + .strict(); + +export type RecordedEdit = z.output; + +const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(JsonValueSchema), + z.record(z.string(), JsonValueSchema), + ]), +); + +const TimelineStateSchema = z + .object({ + edits: z.array(RecordedEditSchema), + seed: JsonValueSchema.optional(), + }) + .strict(); + +export type EditTimelineState = z.output; + +const timelineEvents = { + editRecorded: RecordedEditSchema, +} as const; + +export type EditTimelineEvents = typeof timelineEvents; + +export const editTimelineDefinition = defineState({ + events: timelineEvents, + id: 'rsc-agent-runtime/edit-timeline', + initial: { edits: [] }, + lifetime: 'workspace-durable', + reduce: (state, event): EditTimelineState => { + switch (event.name) { + case 'editRecorded': + return state.seed === undefined + ? { edits: [...state.edits, event.payload] } + : { edits: [...state.edits, event.payload], seed: state.seed }; + default: { + const unreachable: never = event.name; + throw new Error(`Unhandled edit-timeline event ${String(unreachable)}`); + } + } + }, + schema: TimelineStateSchema, +}); diff --git a/examples/rsc-agent-runtime/src/runtime/state-file-core.ts b/examples/rsc-agent-runtime/src/runtime/state-file-core.ts deleted file mode 100644 index a2e69df41..000000000 --- a/examples/rsc-agent-runtime/src/runtime/state-file-core.ts +++ /dev/null @@ -1,781 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -import { lock as acquireLockfile } from 'proper-lockfile'; - -import type { - EditEvent, - JsonValue, - RuntimeKernel, - RuntimeMutationOptions, - RuntimeSnapshot, - RuntimeSnapshotReadOptions, - RuntimeStateRecord, -} from './contracts.js'; - -export const MAX_STATE_BYTES = 16 * 1024 * 1024; - -export class RuntimeStateCorruptionError extends Error { - readonly line: number; - readonly offset: number; - - constructor({ line, message, offset }: { line: number; message: string; offset: number }) { - super(`Runtime state corruption at line ${line}, byte ${offset}: ${message}`); - this.name = 'RuntimeStateCorruptionError'; - this.line = line; - this.offset = offset; - } -} - -export class RuntimeStateLockError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'RuntimeStateLockError'; - } -} - -export interface StateKernelPolicy { - readonly acquireLimitMs: number; - readonly mutationMs: number; - readonly ownerSettlementMs: number; - readonly releaseMs: number; - readonly retryDelayMs: number; - readonly staleMs: number; - readonly updateMs: number; - readonly terminateOwner: (error: RuntimeStateLockError) => void; -} - -export type StateLeaseRelease = () => Promise; - -export interface StateStorage { - readonly acquire: (input: Readonly<{ - onCompromised: (error: Error) => void; - stale: number; - stateFile: string; - update: number; - }>) => Promise; - readonly append: (stateFile: string, contents: Buffer, signal: AbortSignal) => Promise; - readonly prepare: (stateFile: string, signal: AbortSignal) => Promise; - readonly read: (stateFile: string, signal: AbortSignal) => Promise; - readonly readOwnerStaleMs: (stateFile: string, signal: AbortSignal) => Promise; - readonly removeOwner: (stateFile: string, signal: AbortSignal) => Promise; - readonly repair: (stateFile: string, completeBytes: number, signal: AbortSignal) => Promise; - readonly writeOwner: (stateFile: string, staleMs: number, signal: AbortSignal) => Promise; -} - -export interface StateKernelInput { - readonly createId?: () => string; - readonly now?: () => Date; - readonly policy: StateKernelPolicy; - readonly stateFile: string; - readonly storage: StateStorage; -} - -interface ParsedState { - readonly completeBytes: number; - readonly records: readonly RuntimeStateRecord[]; - readonly snapshot: RuntimeSnapshot; -} - -interface OperationOwner { - readonly controller: AbortController; - unsafeToRelease: boolean; -} - -type Settled = - | Readonly<{ type: 'error'; error: Error }> - | Readonly<{ type: 'value'; value: T }>; - -const asRecord = (value: unknown): Record | undefined => - value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; - -const hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => { - const actualKeys = Object.keys(value).sort(); - const expectedKeys = [...keys].sort(); - return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); -}; - -const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''; - -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - const record = asRecord(value); - return record !== undefined && Object.values(record).every(isJsonValue); -}; - -const isEditEvent = (value: unknown): value is EditEvent => { - const event = asRecord(value); - return ( - event !== undefined && - hasOnlyKeys(event, ['eventId', 'host', 'path', 'recordedAt', 'sessionId', 'toolName']) && - isNonEmptyString(event.eventId) && - (event.host === 'claude' || event.host === 'codex') && - isNonEmptyString(event.sessionId) && - isNonEmptyString(event.toolName) && - isNonEmptyString(event.path) && - isNonEmptyString(event.recordedAt) - ); -}; - -const canonicalize = (value: JsonValue): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; - const object = value as Readonly>; - return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`).join(',')}}`; -}; - -const canonicalRecordInput = (record: RuntimeStateRecord): string => - record.kind === 'edit' - ? canonicalize({ - event: { - host: record.event.host, - path: record.event.path, - sessionId: record.event.sessionId, - toolName: record.event.toolName, - }, - kind: 'edit', - }) - : canonicalize(record.seed === undefined ? { kind: 'reset' } : { kind: 'reset', seed: record.seed }); - -const parseStateRecord = ({ line, offset, value }: { line: number; offset: number; value: unknown }): RuntimeStateRecord => { - const record = asRecord(value); - const stateVersion = record?.stateVersion; - if ( - record === undefined || - !isNonEmptyString(record.idempotencyKey) || - typeof stateVersion !== 'number' || - !Number.isInteger(stateVersion) || - stateVersion < 1 - ) { - throw new RuntimeStateCorruptionError({ line, message: 'record shape is invalid', offset }); - } - if (record.kind === 'edit') { - if (!hasOnlyKeys(record, ['event', 'idempotencyKey', 'kind', 'stateVersion']) || !isEditEvent(record.event)) { - throw new RuntimeStateCorruptionError({ line, message: 'edit record shape is invalid', offset }); - } - return { event: record.event, idempotencyKey: record.idempotencyKey, kind: 'edit', stateVersion }; - } - if (record.kind === 'reset') { - if ( - !hasOnlyKeys(record, record.seed === undefined - ? ['idempotencyKey', 'kind', 'stateVersion'] - : ['idempotencyKey', 'kind', 'seed', 'stateVersion']) || - (record.seed !== undefined && !isJsonValue(record.seed)) - ) { - throw new RuntimeStateCorruptionError({ line, message: 'reset record shape is invalid', offset }); - } - return record.seed === undefined - ? { idempotencyKey: record.idempotencyKey, kind: 'reset', stateVersion } - : { idempotencyKey: record.idempotencyKey, kind: 'reset', seed: record.seed, stateVersion }; - } - throw new RuntimeStateCorruptionError({ line, message: 'record kind is invalid', offset }); -}; - -const snapshotForRecords = (records: readonly RuntimeStateRecord[], limit?: number): RuntimeSnapshot => { - let edits: EditEvent[] = []; - let seed: JsonValue | undefined; - for (const record of records) { - if (record.kind === 'edit') { - edits = [...edits, record.event]; - } else { - edits = []; - seed = record.seed; - } - } - const visibleEdits = limit === undefined ? edits : edits.slice(-limit); - return seed === undefined - ? { edits: visibleEdits, stateVersion: records.length } - : { edits: visibleEdits, seed, stateVersion: records.length }; -}; - -const parseSnapshot = (contents: Buffer): ParsedState => { - if (contents.byteLength > MAX_STATE_BYTES) { - throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); - } - let completeBytes = contents.byteLength; - if (contents.byteLength > 0 && contents[contents.byteLength - 1] !== 0x0a) { - const lastNewline = contents.lastIndexOf(0x0a); - completeBytes = lastNewline < 0 ? 0 : lastNewline + 1; - } - const records: RuntimeStateRecord[] = []; - const idempotencyKeys = new Set(); - let offset = 0; - let line = 1; - while (offset < completeBytes) { - const newline = contents.indexOf(0x0a, offset); - const end = newline < 0 ? completeBytes : newline; - let raw: unknown; - try { - raw = JSON.parse(contents.subarray(offset, end).toString('utf8')); - } catch { - throw new RuntimeStateCorruptionError({ line, message: 'record is not valid JSON', offset }); - } - const record = parseStateRecord({ line, offset, value: raw }); - const expectedVersion = records.length + 1; - if (record.stateVersion !== expectedVersion) { - throw new RuntimeStateCorruptionError({ - line, - message: `expected monotonic state version ${expectedVersion}, received ${record.stateVersion}`, - offset, - }); - } - if (idempotencyKeys.has(record.idempotencyKey)) { - throw new RuntimeStateCorruptionError({ line, message: `duplicate idempotency key ${record.idempotencyKey}`, offset }); - } - idempotencyKeys.add(record.idempotencyKey); - records.push(record); - offset = end + 1; - line += 1; - } - return { completeBytes, records, snapshot: snapshotForRecords(records) }; -}; - -const abortError = (signal: AbortSignal): Error => - signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); - -const settled = (operation: Promise): Promise> => - operation.then( - (value) => ({ type: 'value', value }), - (error: unknown) => ({ type: 'error', error: error instanceof Error ? error : new Error(String(error)) }), - ); - -const cancellation = (signal: AbortSignal): Promise> => - signal.aborted - ? Promise.resolve({ type: 'cancelled', error: abortError(signal) }) - : new Promise((resolve) => { - signal.addEventListener('abort', () => resolve({ type: 'cancelled', error: abortError(signal) }), { once: true }); - }); - -const isAlreadyLocked = (error: unknown): boolean => (error as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED'; - -const delay = async (milliseconds: number, signal: AbortSignal): Promise => { - if (signal.aborted) throw abortError(signal); - await new Promise((resolve, reject) => { - const timer = setTimeout(done, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(abortError(signal)); - }; - function done() { - signal.removeEventListener('abort', onAbort); - resolve(); - } - signal.addEventListener('abort', onAbort, { once: true }); - }); -}; - -const validateLimit = (limit: number | undefined): void => { - if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 50)) { - throw new RangeError('limit must be an integer from 1 through 50'); - } -}; - -const validateStateVersion = (stateVersion: number | undefined): void => { - if (stateVersion !== undefined && (!Number.isSafeInteger(stateVersion) || stateVersion < 0)) { - throw new RangeError('stateVersion must be a nonnegative safe integer'); - } -}; - -export const createRuntimeStateKernel = ({ - createId = randomUUID, - now = () => new Date(), - policy, - stateFile, - storage, -}: StateKernelInput): RuntimeKernel => { - let poisoned: RuntimeStateLockError | undefined; - const owners = new Set(); - - const poison = (error: RuntimeStateLockError, fatal: boolean): RuntimeStateLockError => { - poisoned ??= error; - for (const owner of owners) owner.controller.abort(poisoned); - if (fatal) { - try { - policy.terminateOwner(poisoned); - } catch { - // The permanent poisoned state remains authoritative if teardown itself throws. - } - } - return poisoned; - }; - - const assertHealthy = (signal?: AbortSignal): void => { - if (signal?.aborted === true) throw abortError(signal); - if (poisoned !== undefined) throw poisoned; - }; - - const createOwner = (signal: AbortSignal | undefined): OperationOwner => { - assertHealthy(signal); - const owner: OperationOwner = { controller: new AbortController(), unsafeToRelease: false }; - if (signal !== undefined) { - if (signal.aborted) owner.controller.abort(abortError(signal)); - else signal.addEventListener('abort', () => owner.controller.abort(abortError(signal)), { once: true }); - } - owners.add(owner); - return owner; - }; - - const armDeadline = (owner: OperationOwner, deadline: number, error: Error): ReturnType => - setTimeout(() => owner.controller.abort(error), Math.max(0, deadline - Date.now())); - - const releaseRaw = async (owner: OperationOwner, rawRelease: StateLeaseRelease, label: string): Promise => { - const operation = settled(rawRelease()); - const timeoutError = new RuntimeStateLockError(`${label} exceeded ${policy.releaseMs} ms`); - let releaseTimer: ReturnType | undefined; - const timeout = new Promise>((resolve) => { - releaseTimer = setTimeout(() => resolve({ type: 'timeout', error: timeoutError }), policy.releaseMs); - }); - const outcome = await Promise.race([operation, timeout]); - clearTimeout(releaseTimer); - if (outcome.type === 'timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${outcome.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - if (outcome.type === 'error') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${label} failed; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - }; - - const awaitUnowned = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([settled(operation), cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'cancelled') throw outcome.error; - if (outcome.type === 'error') throw outcome.error; - if (Date.now() >= deadline) { - owner.controller.abort(timeoutError); - throw timeoutError; - } - return outcome.value; - }; - - const awaitAcquisition = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const phase = settled(operation); - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'cancelled') { - void phase.then(async (late) => { - if (late.type === 'value') await releaseRaw(owner, late.value, 'Late runtime state lease release'); - }).catch(() => undefined); - throw outcome.error; - } - if (outcome.type === 'error') throw outcome.error; - if (Date.now() >= deadline || owner.controller.signal.aborted || poisoned !== undefined) { - const reason = poisoned ?? (owner.controller.signal.aborted ? abortError(owner.controller.signal) : timeoutError); - await releaseRaw(owner, outcome.value, 'Late runtime state lease release'); - throw reason; - } - return outcome.value; - }; - - const awaitOwned = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const phase = settled(operation); - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'error') throw outcome.error; - if (outcome.type === 'value') { - if (poisoned !== undefined) { - owner.unsafeToRelease = true; - throw poisoned; - } - if (owner.controller.signal.aborted) { - throw abortError(owner.controller.signal); - } - if (Date.now() >= deadline) { - owner.controller.abort(timeoutError); - throw timeoutError; - } - return outcome.value; - } - - if (poisoned !== undefined) { - owner.unsafeToRelease = true; - throw poisoned; - } - const settlementTimeout = new RuntimeStateLockError( - `Runtime state phase did not settle within ${policy.ownerSettlementMs} ms after cancellation`, - ); - let settlementTimer: ReturnType | undefined; - const settlement = await Promise.race([ - phase, - new Promise>((resolve) => { - settlementTimer = setTimeout( - () => resolve({ type: 'settlement-timeout', error: settlementTimeout }), - policy.ownerSettlementMs, - ); - }), - ]); - clearTimeout(settlementTimer); - if (settlement.type === 'settlement-timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${settlement.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - throw outcome.error; - }; - - const releaseLease = async (owner: OperationOwner, canonicalStateFile: string, rawRelease: StateLeaseRelease): Promise => { - let metadataFailure: Error | undefined; - try { - const removal = settled(storage.removeOwner(canonicalStateFile, owner.controller.signal)); - let removalTimer: ReturnType | undefined; - const timeout = new Promise>((resolve) => { - removalTimer = setTimeout(() => resolve({ type: 'timeout' }), policy.releaseMs); - }); - const outcome = await Promise.race([removal, timeout]); - clearTimeout(removalTimer); - if (outcome.type === 'timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`Runtime state lease release exceeded ${policy.releaseMs} ms; this kernel is permanently poisoned`), - true, - ); - } - if (outcome.type === 'error') metadataFailure = outcome.error; - } catch (error) { - if (owner.unsafeToRelease) throw error; - metadataFailure = error instanceof Error ? error : new Error(String(error)); - } - - try { - await releaseRaw(owner, rawRelease, 'Runtime state lease release'); - } catch (releaseError) { - if (metadataFailure === undefined) throw releaseError; - throw new AggregateError( - [metadataFailure, releaseError], - 'Runtime state lease release failed', - { cause: releaseError }, - ); - } - if (metadataFailure !== undefined) throw metadataFailure; - }; - - const acquireLease = async (signal: AbortSignal | undefined, timeoutMs: number) => { - const owner = createOwner(signal); - const deadline = Date.now() + timeoutMs; - const timeoutError = new RuntimeStateLockError(`Timed out acquiring runtime state lease after ${timeoutMs} ms`); - let rawRelease: StateLeaseRelease | undefined; - try { - const canonicalStateFile = await awaitUnowned(owner, deadline, storage.prepare(stateFile, owner.controller.signal), timeoutError); - while (true) { - assertHealthy(owner.controller.signal); - const ownerStale = await awaitUnowned( - owner, - deadline, - storage.readOwnerStaleMs(canonicalStateFile, owner.controller.signal), - timeoutError, - ); - try { - rawRelease = await awaitAcquisition( - owner, - deadline, - storage.acquire({ - onCompromised: (error) => { - owner.unsafeToRelease = true; - const compromise = new RuntimeStateLockError( - 'Runtime state lease was compromised; this kernel is permanently poisoned', - { cause: error }, - ); - owner.controller.abort(compromise); - poison(compromise, true); - }, - stale: Math.max(policy.staleMs, ownerStale), - stateFile: canonicalStateFile, - update: policy.updateMs, - }), - timeoutError, - ); - await awaitOwned( - owner, - deadline, - storage.writeOwner(canonicalStateFile, policy.staleMs, owner.controller.signal), - timeoutError, - ); - return { canonicalStateFile, owner, rawRelease }; - } catch (error) { - if (rawRelease !== undefined && !owner.unsafeToRelease) { - await releaseRaw(owner, rawRelease, 'Runtime state lease release'); - rawRelease = undefined; - } - if (!isAlreadyLocked(error)) throw error; - rawRelease = undefined; - await awaitUnowned( - owner, - deadline, - delay(Math.min(policy.retryDelayMs, Math.max(0, deadline - Date.now())), owner.controller.signal), - timeoutError, - ); - } - } - } catch (error) { - owners.delete(owner); - throw error; - } - }; - - const readSnapshot = async ({ limit, stateVersion }: RuntimeSnapshotReadOptions = {}): Promise => { - validateLimit(limit); - validateStateVersion(stateVersion); - assertHealthy(); - const controller = new AbortController(); - const parsed = parseSnapshot(await storage.read(stateFile, controller.signal)); - if (stateVersion !== undefined) { - if (stateVersion > parsed.records.length) throw new RangeError(`state version ${stateVersion} is unavailable`); - return snapshotForRecords(parsed.records.slice(0, stateVersion), limit); - } - return snapshotForRecords(parsed.records, limit); - }; - - const mutate = async (record: RuntimeStateRecord, options: RuntimeMutationOptions | undefined): Promise => { - if (!isNonEmptyString(record.idempotencyKey)) { - throw new TypeError('Runtime state mutations require a nonempty idempotency key'); - } - if (record.kind === 'edit' && !isEditEvent(record.event)) { - throw new TypeError('Runtime state edits require every event field to be nonempty and valid'); - } - if (record.kind === 'reset' && record.seed !== undefined && !isJsonValue(record.seed)) { - throw new TypeError('Runtime state reset seed must be JSON-safe'); - } - const timeoutMs = options?.lockAcquireTimeoutMs ?? policy.acquireLimitMs; - if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > policy.acquireLimitMs) { - throw new RangeError(`lockAcquireTimeoutMs must be an integer from 1 through ${policy.acquireLimitMs}`); - } - assertHealthy(options?.signal); - const lease = await acquireLease(options?.signal, timeoutMs); - const deadline = Date.now() + policy.mutationMs; - const timeoutError = new RuntimeStateLockError( - `Runtime state mutation exceeded ${policy.mutationMs} ms critical-section limit`, - ); - let result: RuntimeSnapshot | undefined; - let failure: unknown; - try { - const bytes = await awaitOwned( - lease.owner, - deadline, - storage.read(lease.canonicalStateFile, lease.owner.controller.signal), - timeoutError, - ); - const parsed = parseSnapshot(bytes); - const sameKey = parsed.records.find((current) => current.idempotencyKey === record.idempotencyKey); - if (sameKey !== undefined) { - if (canonicalRecordInput(sameKey) !== canonicalRecordInput(record)) { - throw new RuntimeStateLockError(`Runtime state idempotency key ${record.idempotencyKey} was reused with conflicting input`); - } - result = parsed.snapshot; - } else { - if (parsed.completeBytes !== bytes.byteLength) { - await awaitOwned( - lease.owner, - deadline, - storage.repair(lease.canonicalStateFile, parsed.completeBytes, lease.owner.controller.signal), - timeoutError, - ); - } - const nextRecord: RuntimeStateRecord = record.kind === 'edit' - ? { ...record, event: record.event, stateVersion: parsed.snapshot.stateVersion + 1 } - : record.seed === undefined - ? { ...record, stateVersion: parsed.snapshot.stateVersion + 1 } - : { ...record, seed: record.seed, stateVersion: parsed.snapshot.stateVersion + 1 }; - const serialized = Buffer.from(`${JSON.stringify(nextRecord)}\n`, 'utf8'); - if (parsed.completeBytes + serialized.byteLength > MAX_STATE_BYTES) { - throw new RuntimeStateLockError(`Runtime state file cannot exceed ${MAX_STATE_BYTES} bytes`); - } - await awaitOwned( - lease.owner, - deadline, - storage.append(lease.canonicalStateFile, serialized, lease.owner.controller.signal), - timeoutError, - ); - result = snapshotForRecords([...parsed.records, nextRecord]); - } - } catch (error) { - failure = error; - } - - if (!lease.owner.unsafeToRelease) { - try { - await releaseLease(lease.owner, lease.canonicalStateFile, lease.rawRelease); - } catch (error) { - failure = failure === undefined - ? error - : new AggregateError( - [failure, error], - 'Runtime state mutation and lease release failed', - { cause: error }, - ); - } - } - owners.delete(lease.owner); - if (failure !== undefined) throw failure; - return result!; - }; - - return { - recordEdit(input, options) { - return mutate({ - event: { - eventId: createId(), - host: input.host, - path: input.path, - recordedAt: now().toISOString(), - sessionId: input.sessionId, - toolName: input.toolName, - }, - idempotencyKey: input.idempotencyKey, - kind: 'edit', - stateVersion: 0, - }, options); - }, - resetState(input, options) { - return mutate( - input.seed === undefined - ? { idempotencyKey: input.idempotencyKey, kind: 'reset', stateVersion: 0 } - : { idempotencyKey: input.idempotencyKey, kind: 'reset', seed: input.seed, stateVersion: 0 }, - options, - ); - }, - readSnapshot, - }; -}; - -const metadataFile = (stateFile: string): string => `${stateFile}.agent-runtime-lock.json`; - -export const createNodeStateStorage = ({ - platform = process.platform, - syncParent, -}: Readonly<{ - platform?: NodeJS.Platform; - syncParent?: (directory: string) => Promise; -}> = {}): StateStorage => ({ - acquire: (input) => acquireLockfile(input.stateFile, { - onCompromised: input.onCompromised, - realpath: false, - retries: 0, - stale: input.stale, - update: input.update, - }), - async append(stateFile, contents, signal) { - if (signal.aborted) throw abortError(signal); - const handle = await open(stateFile, 'a'); - try { - await handle.writeFile(contents); - await handle.sync(); - } finally { - await handle.close(); - } - }, - async prepare(stateFile) { - await mkdir(dirname(stateFile), { recursive: true }); - let created = false; - try { - await stat(stateFile); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - try { - const handle = await open(stateFile, 'wx'); - await handle.sync(); - await handle.close(); - created = true; - } catch (createError) { - if ((createError as NodeJS.ErrnoException).code !== 'EEXIST') throw createError; - } - } - if (created) { - try { - if (syncParent !== undefined) await syncParent(dirname(stateFile)); - else { - const parent = await open(dirname(stateFile), 'r'); - try { - await parent.sync(); - } finally { - await parent.close(); - } - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (!(platform === 'win32' && (code === 'EPERM' || code === 'EINVAL'))) throw error; - } - } - const canonical = await realpath(stateFile); - const details = await lstat(canonical); - if (!details.isFile() || details.isSymbolicLink()) { - throw new RuntimeStateLockError(`Runtime state path is not a regular file: ${stateFile}`); - } - return canonical; - }, - async read(stateFile) { - try { - const handle = await open(stateFile, 'r'); - try { - const contents = Buffer.allocUnsafe(MAX_STATE_BYTES + 1); - let offset = 0; - while (offset < contents.byteLength) { - const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset); - if (bytesRead === 0) break; - offset += bytesRead; - } - if (offset > MAX_STATE_BYTES) { - throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); - } - return contents.subarray(0, offset); - } finally { - await handle.close(); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Buffer.alloc(0); - throw error; - } - }, - async readOwnerStaleMs(stateFile) { - try { - const metadata: unknown = JSON.parse(await readFile(metadataFile(stateFile), 'utf8')); - const stale = asRecord(metadata)?.stale; - return typeof stale === 'number' && Number.isInteger(stale) && stale > 0 ? stale : 0; - } catch { - return 0; - } - }, - removeOwner: (stateFile) => rm(metadataFile(stateFile), { force: true }), - async repair(stateFile, completeBytes) { - const handle = await open(stateFile, 'r+'); - try { - await handle.truncate(completeBytes); - await handle.sync(); - } finally { - await handle.close(); - } - }, - writeOwner: (stateFile, staleMs, signal) => - writeFile(metadataFile(stateFile), JSON.stringify({ stale: staleMs }), { encoding: 'utf8', signal }), -}); diff --git a/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts b/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts deleted file mode 100644 index 5582b971c..000000000 --- a/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { RuntimeKernel } from './contracts.js'; -import { open } from 'node:fs/promises'; -import { - createNodeStateStorage, - createRuntimeStateKernel, - type StateKernelPolicy, - type StateLeaseRelease, - type StateStorage, -} from './state-file-core.js'; -import type { FileRuntimeKernelOptions } from './state-file.js'; - -export interface RuntimeStateTestAdapter { - readonly acquireLock?: StateStorage['acquire']; - /** Phase hooks receive the mutation's abort signal so barrier-style tests can order on the critical-section cancellation instead of wall-clock margins. */ - readonly beforeAppend?: (signal: AbortSignal) => Promise; - readonly beforeAppendSync?: (signal: AbortSignal) => Promise; - readonly beforeAppendWrite?: (signal: AbortSignal) => Promise; - readonly beforeRead?: () => Promise; - readonly beforeRelease?: () => Promise; - readonly beforeRepair?: (signal: AbortSignal) => Promise; - readonly criticalSectionMs?: number; - readonly fatalOwnerTeardown?: (error: Error) => void; - /** - * Observes every settled lock-acquisition attempt: 'held' when the lock - * was refused because another owner holds it, 'acquired' when the attempt - * won the lease. Lets exclusion tests order assertions on observed - * contender attempts instead of fixed sleeps that race the retry loop. - */ - readonly onLockAttempt?: (outcome: 'acquired' | 'held') => void; - readonly ownerSettlementMs?: number; - readonly platform?: NodeJS.Platform; - readonly prepareStateFile?: (input: Readonly<{ stateFile: string }>) => Promise; - readonly readState?: StateStorage['read']; - readonly releaseMs?: number; - readonly syncParent?: (directory: string) => Promise; -} - -export interface TestFileRuntimeKernelOptions extends FileRuntimeKernelOptions { - readonly adapter?: RuntimeStateTestAdapter; -} - -const wrapRelease = ( - release: StateLeaseRelease, - adapter: RuntimeStateTestAdapter, -): StateLeaseRelease => async () => { - await adapter.beforeRelease?.(); - await release(); -}; - -export const createTestFileRuntimeKernel = ({ adapter = {}, ...options }: TestFileRuntimeKernelOptions): RuntimeKernel => { - const native = createNodeStateStorage({ platform: adapter.platform, syncParent: adapter.syncParent }); - const storage: StateStorage = { - ...native, - acquire: async (input) => { - let release: StateLeaseRelease; - try { - release = await (adapter.acquireLock === undefined ? native.acquire(input) : adapter.acquireLock(input)); - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED') adapter.onLockAttempt?.('held'); - throw error; - } - adapter.onLockAttempt?.('acquired'); - return wrapRelease(release, adapter); - }, - async append(stateFile, contents, signal) { - await adapter.beforeAppend?.(signal); - if (signal.aborted) { - throw signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); - } - if (adapter.beforeAppendWrite !== undefined || adapter.beforeAppendSync !== undefined) { - const handle = await open(stateFile, 'a'); - try { - await adapter.beforeAppendWrite?.(signal); - if (signal.aborted) throw signal.reason; - await handle.writeFile(contents); - await adapter.beforeAppendSync?.(signal); - if (signal.aborted) throw signal.reason; - await handle.sync(); - return; - } finally { - await handle.close(); - } - } - return native.append(stateFile, contents, signal); - }, - prepare: adapter.prepareStateFile === undefined - ? native.prepare - : (stateFile) => adapter.prepareStateFile!({ stateFile }), - read: adapter.readState ?? (async (stateFile, signal) => { - await adapter.beforeRead?.(); - return native.read(stateFile, signal); - }), - async repair(stateFile, completeBytes, signal) { - await adapter.beforeRepair?.(signal); - if (signal.aborted) throw signal.reason; - return native.repair(stateFile, completeBytes, signal); - }, - }; - const policy: StateKernelPolicy = { - acquireLimitMs: 30_000, - mutationMs: adapter.criticalSectionMs ?? 10_000, - ownerSettlementMs: adapter.ownerSettlementMs ?? 100, - releaseMs: adapter.releaseMs ?? 100, - retryDelayMs: 25, - staleMs: 2_000, - terminateOwner: (error) => adapter.fatalOwnerTeardown?.(error), - updateMs: 1_000, - }; - return createRuntimeStateKernel({ - createId: options.createId, - now: options.now, - policy, - stateFile: options.stateFile, - storage, - }); -}; diff --git a/examples/rsc-agent-runtime/src/runtime/state-file.ts b/examples/rsc-agent-runtime/src/runtime/state-file.ts index 73175a34b..202efbf33 100644 --- a/examples/rsc-agent-runtime/src/runtime/state-file.ts +++ b/examples/rsc-agent-runtime/src/runtime/state-file.ts @@ -1,47 +1,185 @@ import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import { realpath } from 'node:fs/promises'; -import type { RuntimeKernel } from './contracts.js'; +import { AgentStateError, type AgentStateChange, type AgentStateStore } from '@agent-bundle/runtime/state'; +import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; + +import type { + EditEvent, + RuntimeKernel, + RuntimeMutationOptions, + RuntimeSnapshot, + RuntimeSnapshotReadOptions, +} from './contracts.js'; import { - createNodeStateStorage, - createRuntimeStateKernel, - RuntimeStateCorruptionError, - RuntimeStateLockError, - type StateKernelPolicy, -} from './state-file-core.js'; - -const PRODUCTION_POLICY: StateKernelPolicy = Object.freeze({ - acquireLimitMs: 30_000, - mutationMs: 10_000, - ownerSettlementMs: 10_000, - releaseMs: 10_000, - retryDelayMs: 25, - staleMs: 30_000, - terminateOwner(error: RuntimeStateLockError) { - process.stderr.write(`${error.message}\n`); - process.kill(process.pid, 'SIGTERM'); - }, - updateMs: 5_000, -}); - -export { RuntimeStateCorruptionError, RuntimeStateLockError }; + editTimelineDefinition, + type EditTimelineEvents, + type EditTimelineState, +} from './state-definition.js'; + +/** + * The example's durable state now lives on the framework state kernel's + * workspace-durable `node:sqlite` driver (#98, G3): the state file is one + * SQLite database per workspace instead of the retired append-only JSONL + * log. Loading this module emits Node's one-time ExperimentalWarning for + * `node:sqlite` (see the README's production notes). + * + * This adapter keeps the example's provider-facing `RuntimeKernel` contract + * — `stateVersion`, bounded `limit` views, exact-version reads, and the + * six-field `EditEvent` wire shape — while the kernel owns locking, + * transactions, idempotency, and corruption behavior. The two presentation + * fields the retired kernel generated are now derived deterministically at + * read time: `eventId` is `edit-` and `recordedAt` is the + * journal's commit timestamp from the change cursor. + */ + +export { AgentStateError }; + +const MUTATION_WAIT_LIMIT_MS = 30_000; export interface FileRuntimeKernelOptions { stateFile: string; now?: () => Date; - createId?: () => string; } -export const createFileRuntimeKernel = (options: FileRuntimeKernelOptions): RuntimeKernel => - createRuntimeStateKernel({ - createId: options.createId, - now: options.now, - policy: PRODUCTION_POLICY, - stateFile: options.stateFile, - storage: createNodeStateStorage(), +const validateLimit = (limit: number | undefined): void => { + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 50)) { + throw new RangeError('limit must be an integer from 1 through 50'); + } +}; + +const validateStateVersion = (stateVersion: number | undefined): void => { + if (stateVersion !== undefined && (!Number.isSafeInteger(stateVersion) || stateVersion < 0)) { + throw new RangeError('stateVersion must be a nonnegative safe integer'); + } +}; + +const validateMutationWait = (waitMs: number | undefined): void => { + if (waitMs !== undefined && (!Number.isInteger(waitMs) || waitMs < 1 || waitMs > MUTATION_WAIT_LIMIT_MS)) { + throw new RangeError(`lockAcquireTimeoutMs must be an integer from 1 through ${MUTATION_WAIT_LIMIT_MS}`); + } +}; + +type TimelineStore = AgentStateStore; + +/** + * Joins the reduced state with the journal's change cursor to rebuild the + * provider-facing `EditEvent` decoration. Edits in the state correspond + * one-to-one, in order, to the `event` changes committed after the latest + * reset, so the join is deterministic for any exact revision. + */ +const decoratedSnapshot = ( + state: EditTimelineState, + revision: number, + changes: readonly AgentStateChange[], + limit: number | undefined, +): RuntimeSnapshot => { + const upTo = changes.filter((change) => change.revision <= revision); + const lastBaseline = [...upTo].reverse().find((change) => change.kind !== 'event'); + const eventChanges = upTo.filter( + (change): change is Extract => + change.kind === 'event' && (lastBaseline === undefined || change.revision > lastBaseline.revision), + ); + if (eventChanges.length !== state.edits.length) { + throw new AgentStateError( + 'corrupt', + `Runtime state at version ${String(revision)} has ${String(state.edits.length)} edits but ${String(eventChanges.length)} committed edit events`, + ); + } + const edits: EditEvent[] = state.edits.map((edit, index) => { + const change = eventChanges[index]!; + return { + eventId: `edit-${String(change.revision)}`, + host: edit.host, + path: edit.path, + recordedAt: change.committedAt, + sessionId: edit.sessionId, + toolName: edit.toolName, + }; }); + const visibleEdits = limit === undefined ? edits : edits.slice(-limit); + return state.seed === undefined + ? { edits: visibleEdits, stateVersion: revision } + : { edits: visibleEdits, seed: state.seed, stateVersion: revision }; +}; + +export const createFileRuntimeKernel = (options: FileRuntimeKernelOptions): RuntimeKernel => { + const now = options.now ?? ((): Date => new Date()); + + /** + * Every operation opens the store, runs, and closes: hook and MCP + * processes are short-lived, and deterministic close keeps file handles + * and WAL checkpoints tidy without a daemon. + */ + const withStore = async ( + waitMs: number | undefined, + operation: (store: TimelineStore) => Promise, + ): Promise => { + const driver = createSqliteStateDriver({ + busyTimeoutMs: waitMs ?? MUTATION_WAIT_LIMIT_MS, + file: options.stateFile, + now, + }); + try { + return await operation(await driver.open(editTimelineDefinition)); + } finally { + await driver.close(); + } + }; + + const snapshotAt = async ( + store: TimelineStore, + revision: number | undefined, + limit: number | undefined, + signal?: AbortSignal, + ): Promise => { + let exact: { readonly revision: number; readonly state: EditTimelineState }; + try { + exact = revision === undefined ? await store.read({ signal }) : await store.read({ revision, signal }); + } catch (error) { + if (error instanceof AgentStateError && error.code === 'revision-unavailable') { + throw new RangeError(`state version ${String(revision)} is unavailable`, { cause: error }); + } + throw error; + } + const batch = await store.changes({ afterRevision: 0, signal }); + return decoratedSnapshot(exact.state, exact.revision, batch.changes, limit); + }; + + return { + async recordEdit(input, mutationOptions?: RuntimeMutationOptions): Promise { + validateMutationWait(mutationOptions?.lockAcquireTimeoutMs); + return withStore(mutationOptions?.lockAcquireTimeoutMs, async (store) => { + const committed = await store.dispatch( + 'editRecorded', + { host: input.host, path: input.path, sessionId: input.sessionId, toolName: input.toolName }, + { idempotencyKey: input.idempotencyKey, signal: mutationOptions?.signal }, + ); + return snapshotAt(store, committed.revision, undefined, mutationOptions?.signal); + }); + }, + + async resetState(input, mutationOptions?: RuntimeMutationOptions): Promise { + validateMutationWait(mutationOptions?.lockAcquireTimeoutMs); + return withStore(mutationOptions?.lockAcquireTimeoutMs, async (store) => { + const committed = await store.reset({ + idempotencyKey: input.idempotencyKey, + ...(input.seed === undefined ? {} : { seed: { edits: [], seed: input.seed } }), + signal: mutationOptions?.signal, + }); + return snapshotAt(store, committed.revision, undefined, mutationOptions?.signal); + }); + }, + + async readSnapshot(readOptions: RuntimeSnapshotReadOptions = {}): Promise { + validateLimit(readOptions.limit); + validateStateVersion(readOptions.stateVersion); + return withStore(undefined, async (store) => snapshotAt(store, readOptions.stateVersion, readOptions.limit)); + }, + }; +}; const stateHome = (): string => { const configured = process.env.XDG_STATE_HOME; @@ -54,5 +192,5 @@ const stateHome = (): string => { export const resolveImplicitRuntimeStateFile = async (workspaceRoot: string): Promise => { const canonicalWorkspace = await realpath(resolve(workspaceRoot)); const workspaceId = createHash('sha256').update(canonicalWorkspace).digest('hex'); - return join(stateHome(), 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); + return join(stateHome(), 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'state.sqlite'); }; diff --git a/examples/rsc-agent-runtime/tests/eval-evidence.test.ts b/examples/rsc-agent-runtime/tests/eval-evidence.test.ts index 7ee54b3b0..fabff4cfb 100644 --- a/examples/rsc-agent-runtime/tests/eval-evidence.test.ts +++ b/examples/rsc-agent-runtime/tests/eval-evidence.test.ts @@ -5,6 +5,8 @@ import { pathToFileURL } from 'node:url'; import { expect, test } from '@rstest/core'; +import { withoutNodeSqliteWarning } from './support/state-driver-warnings.js'; + type TranscriptEvidence = { eventCounts: { hook: number; json: number; mcp: number; rscRender: number }; finalMarkerObserved: boolean; @@ -136,7 +138,7 @@ const unavailableHostEnvelope = async (): Promise<{ capturedAt: string; hosts: N const [exitCode] = (await once(child, 'close')) as [number | null]; expect(exitCode).toBe(1); - expect(stderr).toBe(''); + expect(withoutNodeSqliteWarning(stderr).trim()).toBe(''); return JSON.parse(stdout) as { capturedAt: string; hosts: NativeEvidenceEnvelope[]; schemaVersion: number }; }; diff --git a/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs b/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs deleted file mode 100644 index 64c5b3107..000000000 --- a/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { open, rm, writeFile } from 'node:fs/promises'; -import process from 'node:process'; -import { setInterval } from 'node:timers'; - -import lockfile from 'proper-lockfile'; - -const stateFile = process.argv[2]; -if (stateFile === undefined) { - throw new Error('state file argument is required'); -} - -const handle = await open(stateFile, 'a'); -await handle.close(); -const stale = Number(process.argv[3] ?? '2000'); -const update = Number(process.argv[4] ?? '1000'); -const release = await lockfile.lock(stateFile, { - realpath: true, - retries: 0, - stale, - update, -}); -const metadataFile = `${stateFile}.agent-runtime-lock.json`; -await writeFile(metadataFile, JSON.stringify({ stale })); -process.stdout.write('{"ready":true}\n'); - -process.once('SIGTERM', async () => { - await release(); - await rm(metadataFile, { force: true }); - process.exit(0); -}); -setInterval(() => undefined, 1_000); diff --git a/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts b/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts deleted file mode 100644 index 139bf8da7..000000000 --- a/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createTestFileRuntimeKernel } from '../../src/runtime/state-file-test-support.js'; - -const stateFile = process.argv[2]; -if (stateFile === undefined) throw new Error('state file argument is required'); - -const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: () => new Promise((resolve) => setTimeout(resolve, 50)), - criticalSectionMs: 10, - ownerSettlementMs: 2_000, - }, -}); - -try { - await kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:settlement-exit', - path: 'settlement-exit.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }); - throw new Error('timed-out state mutation unexpectedly succeeded'); -} catch (error) { - if (!(error instanceof Error) || !error.message.includes('exceeded 10 ms')) throw error; - process.stdout.write('phase-settled\n'); -} diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index 0e776fe0c..a4a5056fe 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -1,3 +1,4 @@ +import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; @@ -218,7 +219,7 @@ test('runs the packaged MCP server after its artifact is isolated from the examp await runPackageHosts(); const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-isolated-')); const pluginRoot = join(temporaryRoot, 'claude'); - const stateFile = join(temporaryRoot, 'events.jsonl'); + const stateFile = join(temporaryRoot, 'state.sqlite'); await cp(join(pluginsRoot, 'claude'), pluginRoot, { recursive: true }); await writeFile(stateFile, '', 'utf8'); @@ -255,7 +256,7 @@ test('runs each packaged native hook from one shell argv path when its plugin ro for (const host of ['claude', 'codex'] as const) { const pluginRoot = join(temporaryRoot, `${host} plugin root ; ordinary`); const workspace = join(temporaryRoot, `${host}-workspace`); - const stateFile = join(temporaryRoot, `${host}-events.jsonl`); + const stateFile = join(temporaryRoot, `${host}-state.sqlite`); const manifestPath = join(pluginRoot, 'hooks/hooks.json'); const rootVariable = host === 'claude' ? 'CLAUDE_PLUGIN_ROOT' : 'PLUGIN_ROOT'; const filename = `${host}-note.txt`; @@ -300,7 +301,8 @@ test('runs each packaged native hook from one shell argv path when its plugin ro expect((await readFile(argvFile)).toString('utf8').split('\0').filter(Boolean)).toEqual([ join(pluginRoot, 'runtime/hook/index.js'), '--host', host, ]); - expect((await readFile(stateFile, 'utf8')).trim()).toContain(`"host":"${host}"`); + const recorded = await createFileRuntimeKernel({ stateFile }).readSnapshot(); + expect(recorded.edits.map((edit) => edit.host)).toEqual([host]); expect(command).toBe(`node "\${${rootVariable}}/runtime/hook/index.js" --host ${host}`); expect(command).not.toMatch(/(?:api[ _-]?key|echo|printenv|AGENT_RUNTIME_)/iu); } diff --git a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts index 32c45f924..ad4087947 100644 --- a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts @@ -16,13 +16,13 @@ import { expect, test } from '@rstest/core'; import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; import { ensureExampleBuilt } from './support/ensure-built.js'; +import { withoutNodeSqliteWarning } from './support/state-driver-warnings.js'; const createStateFile = async (): Promise => { const directory = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-mcp-')); - const stateFile = join(directory, 'events.jsonl'); + const stateFile = join(directory, 'state.sqlite'); const kernel = createFileRuntimeKernel({ stateFile, - createId: () => 'seed-edit', now: () => new Date('2026-08-14T10:24:31.000Z'), }); @@ -109,11 +109,11 @@ test('built stdio MCP serves static tools, file-backed data, Flight results, and await expect(client.callTool({ name: 'recent_edits', arguments: { limit: 10 } })).resolves.toMatchObject({ content: [{ type: 'text' }], - structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, + structuredContent: { edits: [{ eventId: 'edit-1' }], stateVersion: 1 }, }); await expect(client.callTool({ name: 'render_edit_timeline', arguments: {} })).resolves.toMatchObject({ content: [{ type: 'text' }], - structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, + structuredContent: { edits: [{ eventId: 'edit-1' }], stateVersion: 1 }, }); const runtimeStatus = await client.callTool({ name: 'runtime_status', arguments: {} }); expect(runtimeStatus.structuredContent).toMatchObject({ editCount: 1, stateVersion: 1 }); @@ -146,6 +146,22 @@ test('built stdio MCP serves static tools, file-backed data, Flight results, and } }); +/** + * Waits for the HTTP entry's one JSON startup line on stderr, skipping the + * documented node:sqlite ExperimentalWarning that precedes it. + */ +const httpStartupLine = async (stream: Readable, current: () => string): Promise<{ port: number }> => { + for (;;) { + const meaningful = withoutNodeSqliteWarning(current()).trim(); + const newline = meaningful.indexOf('\n'); + const candidate = newline === -1 ? meaningful : meaningful.slice(0, newline); + if (candidate.startsWith('{') && candidate.endsWith('}')) { + return JSON.parse(candidate) as { port: number }; + } + await once(stream, 'data'); + } +}; + const readJsonRpcLine = async (stdout: Readable): Promise => { let buffered = ''; for (;;) { @@ -278,8 +294,7 @@ test('built Streamable HTTP MCP reports its one JSON startup line and closes cle const client = createClient(); try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; + const startup = await httpStartupLine(child.stderr, () => stderr); const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`)); await client.connect(transport); await expectStaticSurface(client); @@ -330,8 +345,7 @@ test('built Streamable HTTP MCP accepts only explicitly allowed public tunnel or }); try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; + const startup = await httpStartupLine(child.stderr, () => stderr); await expect( requestStatus({ headers: { Host: 'tunnel.example', Origin: 'https://tunnel.example' }, @@ -366,8 +380,7 @@ test('adds an explicit public MCP URL domain only to returned resource content', const client = createClient(); try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; + const startup = await httpStartupLine(child.stderr, () => stderr); await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`))); const resources = await client.listResources(); expect(resources.resources[0]._meta?.ui).not.toHaveProperty('domain'); diff --git a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts index d64a6ceab..a074fcebf 100644 --- a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts +++ b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8,18 +8,21 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { expect, test } from '@rstest/core'; +import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; import { ensureExampleBuilt } from './support/ensure-built.js'; // This is the ordinary-CI micro-eval spot-check (`npm run eval:spot`): one // deterministic pass over the built production artifacts, with no real Claude // or Codex host. It proves the end-to-end runtime path in a small way: a -// native-shaped hook event renders through the RSC worker into durable kernel -// state, and the MCP server then RSC-lowers that same shared state for a tool -// call while linking the MCP App resource. +// native-shaped hook event renders through the RSC worker into the framework +// state kernel's workspace-durable sqlite store (#98), a second hook process +// replaying the same native tool id commits nothing new, and the MCP server +// then RSC-lowers that same shared state for a tool call while linking the +// MCP App resource. test('micro-eval spot-check: built hook and MCP server share one RSC-rendered runtime', async () => { await ensureExampleBuilt(); const workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-micro-eval-')); - const stateFile = join(workspace, 'events.jsonl'); + const stateFile = join(workspace, 'state.sqlite'); const client = new Client({ name: 'rsc-agent-runtime-micro-eval', version: '1.0.0' }); const transport = new StdioClientTransport({ args: [join(process.cwd(), 'dist/runtime/mcp/stdio.js')], @@ -28,7 +31,7 @@ test('micro-eval spot-check: built hook and MCP server share one RSC-rendered ru stderr: 'pipe', }); - try { + const runHookOnce = async (): Promise => { const hook = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/hook/index.js'), '--host', 'claude'], { env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, stdio: ['pipe', 'pipe', 'pipe'], @@ -55,15 +58,21 @@ test('micro-eval spot-check: built hook and MCP server share one RSC-rendered ru hookEventName: 'PostToolUse', }, }); + }; - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line) as { - readonly event: { readonly host: string; readonly path: string }; - readonly idempotencyKey: string; - }); - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ - event: { host: 'claude', path: join(workspace, 'spot-check.txt') }, - idempotencyKey: 'claude:tool:micro-eval-tool-1', + try { + await runHookOnce(); + // A second short-lived hook process replaying the same native tool id + // returns the committed result instead of appending a duplicate edit. + await runHookOnce(); + + const settled = await createFileRuntimeKernel({ stateFile }).readSnapshot(); + expect(settled.stateVersion).toBe(1); + expect(settled.edits).toHaveLength(1); + expect(settled.edits[0]).toMatchObject({ + eventId: 'edit-1', + host: 'claude', + path: join(workspace, 'spot-check.txt'), }); await client.connect(transport); diff --git a/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts b/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts index ed5487435..c06d61d04 100644 --- a/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { normalizeClaudeHook, normalizeCodexHook } from '../src/hook/normalize.js'; +import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; import { ensureExampleBuilt } from './support/ensure-built.js'; const temporaryDirectories: string[] = []; @@ -213,9 +214,10 @@ describe('built RSC hook entry', () => { }, }); - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); - expect(records.map((record) => record.event.host)).toEqual(['claude', 'codex']); - expect(records.map((record) => record.idempotencyKey)).toEqual(['claude:tool:tool-1', 'codex:tool:tool-2']); + const settled = await createFileRuntimeKernel({ stateFile }).readSnapshot(); + expect(settled.stateVersion).toBe(2); + expect(settled.edits.map((edit) => edit.host)).toEqual(['claude', 'codex']); + expect(settled.edits.map((edit) => edit.eventId)).toEqual(['edit-1', 'edit-2']); }); it('rejects unsupported native hook input without writing stdout', async () => { @@ -255,8 +257,10 @@ describe('built RSC hook entry', () => { expect(result.exitCode).toBe(0); const workspaceId = createHash('sha256').update(await realpath(workspace)).digest('hex'); - const stateFile = join(stateHome, 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); - expect((await readFile(stateFile, 'utf8')).trim()).toContain('fallback.txt'); + const stateFile = join(stateHome, 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'state.sqlite'); + const fallbackSnapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot(); + expect(fallbackSnapshot.stateVersion).toBe(1); + expect(fallbackSnapshot.edits.map((edit) => edit.path.endsWith('fallback.txt'))).toEqual([true]); await expect(access(join(workspace, '.agent-runtime-demo'))).rejects.toThrow(); }); diff --git a/examples/rsc-agent-runtime/tests/state-and-definition.test.ts b/examples/rsc-agent-runtime/tests/state-and-definition.test.ts index 3f077a55e..68d0935fa 100644 --- a/examples/rsc-agent-runtime/tests/state-and-definition.test.ts +++ b/examples/rsc-agent-runtime/tests/state-and-definition.test.ts @@ -1,16 +1,23 @@ -import { access, appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, test } from '@rstest/core'; -import { createRsbuild } from '@rsbuild/core'; import { serializeRuntimeDefinition } from '../src/build/serialize-definition.js'; import { runtimeDefinition } from '../src/definition.js'; -import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; -import { createTestFileRuntimeKernel } from '../src/runtime/state-file-test-support.js'; -import { timeScale } from './support/time-scale.ts'; +import { AgentStateError, createFileRuntimeKernel } from '../src/runtime/state-file.js'; + +/** + * Durable-state semantics for the example's provider-facing RuntimeKernel, + * now an adapter over the framework state kernel's workspace-durable + * `node:sqlite` driver (#98). Locking, transactions, torn-write repair, and + * lease policing belong to the framework kernel and its conformance suite + * (`@agent-bundle/runtime` state tests); what stays here is the example's + * own contract: snapshot shapes, bounded limit views, exact versions, + * idempotency behavior across kernel instances, and the derived + * eventId/recordedAt decoration. + */ const readOnlyAnnotations = { destructiveHint: false, @@ -21,98 +28,6 @@ const readOnlyAnnotations = { const resourceUri = 'ui://rsc-agent-runtime/edit-timeline-v1.html'; -const wait = async (milliseconds: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); - -const errorMessages = (value: unknown, seen = new Set()): readonly string[] => { - if (!(value instanceof Error) || seen.has(value)) return []; - seen.add(value); - return [ - value.message, - ...(value instanceof AggregateError ? value.errors.flatMap((error) => errorMessages(error, seen)) : []), - ...errorMessages(value.cause, seen), - ]; -}; - -const observeCancellation = (signal: AbortSignal, onCancelled: () => void): void => { - if (signal.aborted) { - onCancelled(); - return; - } - signal.addEventListener('abort', onCancelled, { once: true }); -}; - -/** - * Observes a contender kernel's settled lock-acquisition attempts. A fixed - * sleep before asserting the contender has not settled races the retry loop: - * on a loaded runner a wrongly released lease can take longer than the sleep - * to be noticed, so the assertion passes without testing anything. Waiting - * for an observed refusal proves the lease was actually held when the - * contender asked. - */ -const observeLockAttempts = () => { - let waiters: Array<(outcome: 'acquired' | 'held') => void> = []; - return { - /** Resolves with the outcome of the next attempt settled from now on. */ - next: async (): Promise<'acquired' | 'held'> => - new Promise((resolve) => { - waiters.push(resolve); - }), - record: (outcome: 'acquired' | 'held'): void => { - const settled = waiters; - waiters = []; - for (const waiter of settled) waiter(outcome); - }, - }; -}; - -const eagerPromise = (value: T): Promise => ({ - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - _onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return Promise.resolve(onfulfilled === undefined || onfulfilled === null ? value as unknown as TResult1 : onfulfilled(value)); - }, -}) as Promise; - -const startLockOwner = async (stateFile: string, timing: { stale: number; update: number } = { stale: 2_000, update: 1_000 }) => { - const child = spawn(process.execPath, [ - join(process.cwd(), 'tests/fixtures/state-lock-owner.mjs'), - stateFile, - String(timing.stale), - String(timing.update), - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - await new Promise((resolve, reject) => { - child.once('error', reject); - child.stdout.once('data', (chunk: Buffer) => { - if (chunk.toString('utf8').trim() === '{"ready":true}') { - resolve(); - return; - } - reject(new Error(`Unexpected lock-owner output: ${chunk.toString('utf8')}`)); - }); - }); - return child; -}; - -const validEditRecord = (stateVersion: number, idempotencyKey: string) => ({ - event: { - eventId: `event-${stateVersion}`, - host: 'claude', - path: `src/${stateVersion}.ts`, - recordedAt: '2026-08-14T12:00:00.000Z', - sessionId: 'session-1', - toolName: 'Write', - }, - idempotencyKey, - kind: 'edit', - stateVersion, -}); - const containsFunction = (value: unknown): boolean => { if (typeof value === 'function') { return true; @@ -129,12 +44,14 @@ const containsFunction = (value: unknown): boolean => { return false; }; +const temporaryStateFile = async (): Promise => + join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.sqlite'); + test('reads an edit recorded by another kernel instance', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const stateFile = await temporaryStateFile(); const first = createFileRuntimeKernel({ stateFile, now: () => new Date('2026-08-14T12:00:00.000Z'), - createId: () => 'edit-1', }); const second = createFileRuntimeKernel({ stateFile }); @@ -146,18 +63,25 @@ test('reads an edit recorded by another kernel instance', async () => { toolName: 'Write', }); - expect(await second.readSnapshot()).toMatchObject({ - edits: [{ eventId: 'edit-1', host: 'claude', path: 'src/runtime/state-file.ts' }], + expect(await second.readSnapshot()).toEqual({ + edits: [ + { + eventId: 'edit-1', + host: 'claude', + path: 'src/runtime/state-file.ts', + recordedAt: '2026-08-14T12:00:00.000Z', + sessionId: 'session-1', + toolName: 'Write', + }, + ], stateVersion: 1, }); }); -test('limits snapshots to the newest valid edit events', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let nextId = 0; +test('limits snapshots to the newest edit events', async () => { + const stateFile = await temporaryStateFile(); const kernel = createFileRuntimeKernel({ stateFile, - createId: () => `edit-${++nextId}`, now: () => new Date('2026-08-14T12:00:00.000Z'), }); @@ -192,36 +116,15 @@ test('limits snapshots to the newest valid edit events', async () => { }); }); -test('ignores one trailing partial JSONL record', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:partial', - path: 'complete.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - await appendFile(stateFile, '{"eventId":"partial"', 'utf8'); - - await expect(kernel.readSnapshot()).resolves.toMatchObject({ - edits: [{ eventId: 'complete-edit', path: 'complete.ts' }], - stateVersion: 1, - }); -}); - test('deduplicates identical state edits and rejects conflicting idempotency-key reuse', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const stateFile = await temporaryStateFile(); const first = createFileRuntimeKernel({ stateFile, - createId: () => 'first-event', now: () => new Date('2026-08-14T12:00:00.000Z'), }); const second = createFileRuntimeKernel({ stateFile, - createId: () => 'second-event', - now: () => new Date('2026-08-14T12:00:00.000Z'), + now: () => new Date('2026-08-14T12:00:05.000Z'), }); const edit = { host: 'claude' as const, @@ -234,23 +137,25 @@ test('deduplicates identical state edits and rejects conflicting idempotency-key const [firstSnapshot, secondSnapshot] = await Promise.all([first.recordEdit(edit), second.recordEdit(edit)]); expect(firstSnapshot.stateVersion).toBe(1); expect(secondSnapshot.stateVersion).toBe(1); - expect((await readFile(stateFile, 'utf8')).trim().split('\n')).toHaveLength(1); - expect(JSON.parse((await readFile(stateFile, 'utf8')).trim())).toMatchObject({ - idempotencyKey: 'claude:tool:tool-1', - kind: 'edit', - stateVersion: 1, - }); + expect(firstSnapshot.edits).toEqual(secondSnapshot.edits); + expect(await first.readSnapshot()).toMatchObject({ stateVersion: 1 }); - await expect(second.recordEdit({ ...edit, path: 'src/conflict.ts' })).rejects.toThrow( - 'idempotency key claude:tool:tool-1', - ); + // The committed decoration is stable: replays return the original commit's + // eventId and recordedAt, never a retry's clock. + const replayed = await second.recordEdit(edit); + expect(replayed.edits).toEqual(firstSnapshot.edits); + + await expect(second.recordEdit({ ...edit, path: 'src/conflict.ts' })).rejects.toMatchObject({ + code: 'idempotency-conflict', + name: 'AgentStateError', + }); + await expect(first.readSnapshot()).resolves.toMatchObject({ stateVersion: 1 }); }); test('appends reset records without resetting the monotonic durable version', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const stateFile = await temporaryStateFile(); const kernel = createFileRuntimeKernel({ stateFile, - createId: () => 'event-1', now: () => new Date('2026-08-14T12:00:00.000Z'), }); @@ -264,16 +169,15 @@ test('appends reset records without resetting the monotonic durable version', as const reset = await kernel.resetState({ idempotencyKey: 'test:state:reset-1', seed: { reason: 'test' } }); expect(reset).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); - expect(records).toMatchObject([ - { kind: 'edit', stateVersion: 1 }, - { idempotencyKey: 'test:state:reset-1', kind: 'reset', seed: { reason: 'test' }, stateVersion: 2 }, - ]); - expect(await createFileRuntimeKernel({ stateFile }).readSnapshot()).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); + expect(await createFileRuntimeKernel({ stateFile }).readSnapshot()).toEqual({ + edits: [], + seed: { reason: 'test' }, + stateVersion: 2, + }); }); test('preserves reset seeds across immediate, idempotent, reopened, limited, and follow-up snapshots', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const stateFile = await temporaryStateFile(); const seed = Object.freeze({ cwd: '/tmp', hook_event_name: 'PostToolUse', @@ -284,7 +188,6 @@ test('preserves reset seeds across immediate, idempotent, reopened, limited, and }); const first = createFileRuntimeKernel({ stateFile, - createId: () => 'seed-follow-up-edit', now: () => new Date('2026-08-15T00:00:00.000Z'), }); @@ -301,7 +204,6 @@ test('preserves reset seeds across immediate, idempotent, reopened, limited, and const reopened = createFileRuntimeKernel({ stateFile, - createId: () => 'seed-follow-up-edit', now: () => new Date('2026-08-15T00:00:01.000Z'), }); await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual(reset); @@ -312,28 +214,39 @@ test('preserves reset seeds across immediate, idempotent, reopened, limited, and sessionId: 'fixture-seed-session', toolName: 'Write', })).resolves.toEqual({ - edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], + edits: [ + { + eventId: 'edit-3', + host: 'claude', + path: 'after-reset.ts', + recordedAt: '2026-08-15T00:00:01.000Z', + sessionId: 'fixture-seed-session', + toolName: 'Write', + }, + ], seed, stateVersion: 3, }); - await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual({ - edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], + await expect(reopened.readSnapshot({ limit: 1 })).resolves.toMatchObject({ + edits: [expect.objectContaining({ eventId: 'edit-3', path: 'after-reset.ts' })], seed, stateVersion: 3, }); await expect(reopened.resetState({ idempotencyKey: 'test:state:seed-reset', seed: { ...seed, session_id: 'conflicting-seed-session' }, - })).rejects.toThrow('idempotency key test:state:seed-reset'); - await expect(reopened.resetState({ idempotencyKey: 'test:state:seed-clear' })).resolves.toEqual({ edits: [], stateVersion: 4 }); + })).rejects.toMatchObject({ code: 'idempotency-conflict' }); + await expect(reopened.resetState({ idempotencyKey: 'test:state:seed-clear' })).resolves.toEqual({ + edits: [], + stateVersion: 4, + }); await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ edits: [], stateVersion: 4 }); }); test('reconstructs an exact durable snapshot version through edits, resets, and idempotent replays', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const stateFile = await temporaryStateFile(); const kernel = createFileRuntimeKernel({ stateFile, - createId: () => 'exact-version-edit', now: () => new Date('2026-08-15T01:00:00.000Z'), }); const readExact = (stateVersion: number) => kernel.readSnapshot({ stateVersion }); @@ -369,7 +282,7 @@ test('reconstructs an exact durable snapshot version through edits, resets, and }); await expect(readExact(2)).resolves.toEqual({ edits: [], seed, stateVersion: 2 }); await expect(readExact(3)).resolves.toMatchObject({ - edits: [expect.objectContaining({ path: 'after-reset.ts' })], + edits: [expect.objectContaining({ eventId: 'edit-3', path: 'after-reset.ts' })], seed, stateVersion: 3, }); @@ -378,752 +291,43 @@ test('reconstructs an exact durable snapshot version through edits, resets, and await expect(readExact(1.5)).rejects.toThrow(RangeError); }); -test('rejects terminated JSONL corruption while preserving only an incomplete final tail for recovery', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:complete', - path: 'complete.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - - await appendFile(stateFile, '{"broken":true}\n', 'utf8'); - await expect(kernel.readSnapshot()).rejects.toThrow('Runtime state corruption'); - - const recoverableStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'recoverable.jsonl'); - const recoverable = createFileRuntimeKernel({ stateFile: recoverableStateFile, createId: () => 'recovered-edit' }); - await recoverable.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:before-tail', - path: 'first.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }); - await appendFile(recoverableStateFile, '{"truncated"', 'utf8'); - await expect( - recoverable.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:after-tail', - path: 'second.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }), - ).resolves.toMatchObject({ stateVersion: 2 }); - await expect(recoverable.readSnapshot()).resolves.toMatchObject({ - edits: [{ path: 'first.ts' }, { path: 'second.ts' }], - stateVersion: 2, - }); -}); - -test('rejects malformed middle records and non-monotonic durable versions', async () => { - const middleStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'middle.jsonl'); - await writeFile(middleStateFile, `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n{"invalid":true}\n`, 'utf8'); - await expect(createFileRuntimeKernel({ stateFile: middleStateFile }).readSnapshot()).rejects.toThrow('Runtime state corruption'); - - const versionStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'version.jsonl'); - await writeFile( - versionStateFile, - `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n${JSON.stringify(validEditRecord(1, 'test:state:second'))}\n`, - 'utf8', +test('serializes concurrent kernel instances into one monotonic history', async () => { + const stateFile = await temporaryStateFile(); + const kernels = Array.from({ length: 3 }, () => + createFileRuntimeKernel({ stateFile, now: () => new Date('2026-08-15T02:00:00.000Z') }), ); - await expect(createFileRuntimeKernel({ stateFile: versionStateFile }).readSnapshot()).rejects.toThrow('monotonic state version'); -}); - -// Real lock heartbeats and staleness windows put the nominal runtime near -// the 5s default budget; contended 2-core runners starve it. -test('excludes a live heartbeat owner and recovers its stale lock only after SIGKILL', { timeout: 30_000 * timeScale }, async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await writeFile(stateFile, '', 'utf8'); - const owner = await startLockOwner(stateFile); - try { - const lockDirectory = `${stateFile}.lock`; - const firstMtime = (await stat(lockDirectory)).mtimeMs; - // The heartbeat touches the lock from a 1s timer in the owner CHILD - // process. A fixed 1.1s sleep races that timer with only 100ms of - // scheduling margin, which a loaded runner blows through. Poll for the - // touch instead of assuming timer punctuality. - const heartbeatDeadline = Date.now() + 15_000 * timeScale; - let touchedMtime = firstMtime; - while (touchedMtime <= firstMtime && Date.now() < heartbeatDeadline) { - await wait(50); - touchedMtime = (await stat(lockDirectory)).mtimeMs; - } - expect(touchedMtime).toBeGreaterThan(firstMtime); - const aborted = new AbortController(); - setTimeout(() => aborted.abort(new Error('test abort')), 50); - await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:live-owner', - path: 'live-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { lockAcquireTimeoutMs: 500, signal: aborted.signal }, - ), - ).rejects.toThrow('test abort'); - - owner.kill('SIGKILL'); - await new Promise((resolve) => owner.once('close', () => resolve())); - await wait(2_100); - // This test asserts stale-lock recovery, not the release/settlement - // budgets (dedicated tests pin those with explicit adapter values). The - // test-kernel 100ms defaults poison a recovered mutation whenever one - // lock-directory fs operation stalls on a contended runner, so the - // recovery kernel uses the scaled production budgets instead. - await expect( - createTestFileRuntimeKernel({ - adapter: { ownerSettlementMs: 10_000 * timeScale, releaseMs: 10_000 * timeScale }, - stateFile, - }).recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:stale-recovery', - path: 'recovered.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }), - ).resolves.toMatchObject({ stateVersion: 1 }); - } finally { - owner.kill('SIGKILL'); - } -}); - -test('a non-production short-timing contender cannot steal a production lease', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await writeFile(stateFile, '', 'utf8'); - const owner = await startLockOwner(stateFile, { stale: 30_000, update: 5_000 }); - try { - const cancelled = new AbortController(); - setTimeout(() => cancelled.abort(new Error('short contender aborted')), 2_100); - await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:short-contender', - path: 'must-not-write.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { lockAcquireTimeoutMs: 30_000, signal: cancelled.signal }, - ), - ).rejects.toThrow('short contender aborted'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); - } finally { - owner.kill('SIGTERM'); - await new Promise((resolve) => owner.once('close', () => resolve())); - } -}); - -test('releases a lease acquired after an expired absolute acquisition deadline', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - prepareStateFile: async ({ stateFile: preparedStateFile }) => preparedStateFile, - acquireLock: async () => - new Promise((resolve) => { - setTimeout(() => resolve(async () => { - releases += 1; - }), 30); - }), - }, - }); - - await expect( - kernel.recordEdit( - { + const snapshots = await Promise.all( + kernels.map((kernel, index) => + kernel.recordEdit({ host: 'claude', - idempotencyKey: 'test:state:late-lock', - path: 'late-lock.ts', + idempotencyKey: `test:state:concurrent-${String(index)}`, + path: `src/concurrent-${String(index)}.ts`, sessionId: 'session-1', toolName: 'Write', - }, - { lockAcquireTimeoutMs: 20 }, + }), ), - ).rejects.toThrow('Timed out acquiring runtime state lease'); - await wait(60); - expect(releases).toBe(1); -}); - -// The critical-section deadline arms at lock acquisition, so a deadline -// shorter than a loaded runner's read phase can fire BEFORE the hung append -// is entered; the read then settles fast and the rejection becomes 'exceeded -// … critical-section limit' instead of the settlement timeout. A 1s deadline -// lets the read always finish first, so the cancellation deterministically -// lands in the never-settling append. -test('cancels a never-settling active phase at the hard critical-section deadline', { timeout: 30_000 * timeScale }, async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: () => new Promise(() => undefined), - criticalSectionMs: 1_000, - }, - }); - - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:never-settles', - path: 'never-settles.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('did not settle within 100 ms after cancellation'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); -}); - -test('exits promptly after a timed-out phase settles before its owner-settlement deadline', async () => { - const buildRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-build-')); - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-')), 'state.jsonl'); - const rsbuild = await createRsbuild({ - config: { - output: { - distPath: { root: buildRoot }, - filename: { js: '[name].js' }, - target: 'node', - }, - source: { entry: { fixture: './tests/fixtures/state-settlement-exit.ts' } }, - }, - cwd: process.cwd(), - }); - const build = await rsbuild.build(); - const startedAt = Date.now(); - const child = spawn(process.execPath, [join(buildRoot, 'fixture.js'), stateFile], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - const outcome = await Promise.race([ - new Promise>((resolve, reject) => { - child.once('error', reject); - child.once('close', (exitCode) => resolve({ exitCode, type: 'closed' })); - }), - wait(500).then(() => ({ type: 'timeout' as const })), - ]); - if (outcome.type === 'timeout') child.kill('SIGKILL'); - await build.close(); - await rm(buildRoot, { force: true, recursive: true }); - - expect(outcome.type).toBe('closed'); - if (outcome.type === 'closed') expect(outcome.exitCode).toBe(0); - expect(stdout).toBe('phase-settled\n'); - expect(Date.now() - startedAt).toBeLessThan(500); -}); - -// The critical-section deadline arms at lock acquisition, so a 20ms deadline -// could fire before the instrumented phase was entered on a loaded runner; -// the phase barrier then never resolved and the test hung to its budget. A -// 1s deadline lets the read phase always finish first, and the barrier -// observes the cancellation so settlement is ordered on events rather than -// wall-clock margins. -test('retains the lease until a timed-out mutation phase actually settles', { timeout: 30_000 * timeScale }, async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let entered!: () => void; - let settle!: () => void; - let cancelled!: () => void; - const phaseEntered = new Promise((resolve) => { - entered = resolve; - }); - const phaseSettlement = new Promise((resolve) => { - settle = resolve; - }); - const phaseCancelled = new Promise((resolve) => { - cancelled = resolve; - }); - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: async (signal) => { - entered(); - observeCancellation(signal, cancelled); - await phaseSettlement; - }, - criticalSectionMs: 1_000, - ownerSettlementMs: 30_000, - }, - }); - const lockAttempts = observeLockAttempts(); - const second = createTestFileRuntimeKernel({ stateFile, adapter: { onLockAttempt: lockAttempts.record } }); - - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:late-phase-owner', - path: 'late-phase-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - void firstMutation.catch(() => undefined); - await phaseEntered; - - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:late-phase-contender', - path: 'late-phase-contender.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 30_000 }, - ).finally(() => { - contenderSettled = true; - }); - await expect(lockAttempts.next()).resolves.toBe('held'); - expect(contenderSettled).toBe(false); - - // The deadline has cancelled the mutation, but the lease must stay held - // while the phase is unsettled. The first observed refusal may belong to - // an attempt already in flight when the cancellation landed; the second - // necessarily started after it, so a wrongly released lease would surface - // here as 'acquired'. - await phaseCancelled; - await expect(lockAttempts.next()).resolves.toBe('held'); - await expect(lockAttempts.next()).resolves.toBe('held'); - expect(contenderSettled).toBe(false); - - settle(); - await expect(firstMutation).rejects.toThrow('exceeded 1000 ms critical-section limit'); - await expect(contender).resolves.toMatchObject({ stateVersion: 1 }); - const settledContents = await readFile(stateFile, 'utf8'); - await wait(30); - expect(await readFile(stateFile, 'utf8')).toBe(settledContents); - expect(settledContents).not.toContain('late-phase-owner.ts'); - expect(settledContents).toContain('late-phase-contender.ts'); -}); - -for (const phase of ['truncate', 'append', 'fsync'] as const) { - // The critical-section deadline arms at lock acquisition, so a 20ms - // deadline could fire before the instrumented phase was entered on a - // loaded runner (observed 4/10 under taskset -c 0,1); the phase barrier - // then never resolved and the test hung to its budget. A 1s deadline lets - // the pre-phase work always finish first, and the barrier observes the - // cancellation so settlement is ordered on events rather than wall-clock - // margins. - test(`does not unlock while a timed-out ${phase} phase is unsettled`, { timeout: 30_000 * timeScale }, async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - if (phase === 'truncate') { - await writeFile(stateFile, '{"incomplete":true', 'utf8'); - } - let entered!: () => void; - let settle!: () => void; - let cancelled!: () => void; - const phaseEntered = new Promise((resolve) => { - entered = resolve; - }); - const phaseSettlement = new Promise((resolve) => { - settle = resolve; - }); - const phaseCancelled = new Promise((resolve) => { - cancelled = resolve; - }); - const barrier = async (signal: AbortSignal) => { - entered(); - observeCancellation(signal, cancelled); - await phaseSettlement; - }; - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - ...(phase === 'truncate' ? { beforeRepair: barrier } : {}), - ...(phase === 'append' ? { beforeAppendWrite: barrier } : {}), - ...(phase === 'fsync' ? { beforeAppendSync: barrier } : {}), - criticalSectionMs: 1_000, - ownerSettlementMs: 30_000, - }, - }); - const lockAttempts = observeLockAttempts(); - const second = createTestFileRuntimeKernel({ stateFile, adapter: { onLockAttempt: lockAttempts.record } }); - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: `test:state:${phase}-owner`, - path: `${phase}-owner.ts`, - sessionId: 'session-1', - toolName: 'Write', - }); - void firstMutation.catch(() => undefined); - await phaseEntered; - - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: `test:state:${phase}-contender`, - path: `${phase}-contender.ts`, - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 30_000 }, - ).finally(() => { - contenderSettled = true; - }); - await expect(lockAttempts.next()).resolves.toBe('held'); - expect(contenderSettled).toBe(false); - - // The deadline has cancelled the mutation, but the lease must stay held - // while the phase is unsettled. The first observed refusal may belong to - // an attempt already in flight when the cancellation landed; the second - // necessarily started after it, so a wrongly released lease would - // surface here as 'acquired'. - await phaseCancelled; - await expect(lockAttempts.next()).resolves.toBe('held'); - await expect(lockAttempts.next()).resolves.toBe('held'); - expect(contenderSettled).toBe(false); - - settle(); - await expect(firstMutation).rejects.toThrow('exceeded 1000 ms critical-section limit'); - await expect(contender).resolves.toMatchObject({ stateVersion: phase === 'fsync' ? 2 : 1 }); - const contentsAtUnlock = await readFile(stateFile, 'utf8'); - await wait(30); - expect(await readFile(stateFile, 'utf8')).toBe(contentsAtUnlock); - }); -} - -test('keeps contenders excluded until a delayed release settles', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let releaseEntered!: () => void; - let settleRelease!: () => void; - const entered = new Promise((resolve) => { - releaseEntered = resolve; - }); - const settlement = new Promise((resolve) => { - settleRelease = resolve; - }); - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeRelease: async () => { - releaseEntered(); - await settlement; - }, - releaseMs: 200, - }, - }); - const lockAttempts = observeLockAttempts(); - const second = createTestFileRuntimeKernel({ stateFile, adapter: { onLockAttempt: lockAttempts.record } }); - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:delayed-release-owner', - path: 'release-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - await entered; - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:delayed-release-contender', - path: 'release-contender.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, - ).finally(() => { - contenderSettled = true; - }); - await expect(lockAttempts.next()).resolves.toBe('held'); - expect(contenderSettled).toBe(false); - settleRelease(); - await expect(firstMutation).resolves.toMatchObject({ stateVersion: 1 }); - await expect(contender).resolves.toMatchObject({ stateVersion: 2 }); -}); - -test('bounds a stuck release and invokes fatal owner teardown without unlocking', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let fatalError: Error | undefined; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async () => async () => new Promise(() => undefined), - criticalSectionMs: 20, - fatalOwnerTeardown: (error) => { - fatalError = error; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - releaseMs: 20, - }, - }); - - const outcome = await Promise.race([ - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:stuck-release', - path: 'stuck-release.ts', - sessionId: 'session-1', - toolName: 'Write', - }).then(() => 'resolved', (error: unknown) => error), - wait(200).then(() => 'test-timeout'), - ]); - - expect(outcome).toBeInstanceOf(Error); - expect(errorMessages(outcome).some((message) => message.includes('lease release exceeded 20 ms'))).toBe(true); - expect(fatalError?.message).toContain('lease release exceeded 20 ms'); - await expect( - kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:after-stuck-release', - path: 'after-stuck-release.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }), - ).rejects.toThrow('permanently poisoned'); -}); - -test('lease compromise cancels its owning mutation while a contender is acquiring', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let allowRead!: () => void; - let compromiseOwner!: (error: Error) => void; - let firstRead = true; - let acquireCount = 0; - const readBarrier = new Promise((resolve) => { - allowRead = resolve; - }); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async ({ onCompromised }) => { - acquireCount += 1; - if (acquireCount === 1) { - compromiseOwner = onCompromised; - return async () => undefined; - } - return new Promise(() => undefined); - }, - beforeRead: async () => { - if (firstRead) { - firstRead = false; - await readBarrier; - } - }, - criticalSectionMs: 500, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - }, - }); - const first = kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:compromise-owner-a', - path: 'owner-a.ts', - sessionId: 'session-a', - toolName: 'Write', - }); - void first.catch(() => undefined); - await wait(10); - const second = kernel.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:compromise-contender-b', - path: 'contender-b.ts', - sessionId: 'session-b', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, ); - void second.catch(() => undefined); - await wait(10); - compromiseOwner(new Error('simulated owner compromise')); - const firstOutcome = await Promise.race([ - first.then(() => 'resolved', (error: unknown) => error), - wait(100).then(() => 'test-timeout'), - ]); - expect(firstOutcome).toBeInstanceOf(Error); - expect((firstOutcome as Error).message).toContain('permanently poisoned'); - await expect(second).rejects.toThrow('permanently poisoned'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); - allowRead(); + expect(snapshots.map((snapshot) => snapshot.stateVersion).sort()).toEqual([1, 2, 3]); + const settled = await createFileRuntimeKernel({ stateFile }).readSnapshot(); + expect(settled.stateVersion).toBe(3); + expect(new Set(settled.edits.map((edit) => edit.path)).size).toBe(3); }); -test('rechecks a simultaneous owner abort after a phase value wins and releases exactly once', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const controller = new AbortController(); - let appendAttempts = 0; - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async () => async () => { - releases += 1; - }, - beforeAppend: async () => { - appendAttempts += 1; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - readState: () => { - controller.abort(new Error('simultaneous owner abort')); - return eagerPromise(Buffer.alloc(0)); - }, - }, - }); - - await expect( - kernel.recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:simultaneous-abort', - path: 'simultaneous-abort.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { signal: controller.signal }, - ), - ).rejects.toThrow('simultaneous owner abort'); - expect(appendAttempts).toBe(0); - expect(releases).toBe(1); -}); +test('fails closed with a typed corrupt error when the state file is not a database', async () => { + const stateFile = await temporaryStateFile(); + await writeFile(stateFile, 'this is not a sqlite database, and it is long enough to hold a header', 'utf8'); -test('rechecks simultaneous lease poison after a phase value wins and never enters append', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let appendAttempts = 0; - let compromise!: (error: Error) => void; - let fatalTeardowns = 0; - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async ({ onCompromised }) => { - compromise = onCompromised; - return async () => { - releases += 1; - }; - }, - beforeAppend: async () => { - appendAttempts += 1; - }, - fatalOwnerTeardown: () => { - fatalTeardowns += 1; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - readState: () => { - compromise(new Error('simultaneous owner compromise')); - return eagerPromise(Buffer.alloc(0)); - }, - }, - }); - - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:simultaneous-poison', - path: 'simultaneous-poison.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('permanently poisoned'); - expect(appendAttempts).toBe(0); - expect(fatalTeardowns).toBe(1); - expect(releases).toBe(0); -}); - -test('accepts Windows parent-fsync limitations when creating a new state file', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - platform: 'win32', - syncParent: async () => { - throw Object.assign(new Error('Windows directory sync unsupported'), { code: 'EPERM' }); - }, - }, - }); - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:windows-parent-sync', - path: 'windows.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).resolves.toMatchObject({ stateVersion: 1 }); -}); - -test('rejects oversized snapshots before parsing or allocating their full file size', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'oversized.jsonl'); - await writeFile(stateFile, Buffer.alloc(16 * 1024 * 1024 + 1)); - await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).rejects.toThrow('exceeds 16777216 byte limit'); -}); - -test('rejects invalid writes before creating their state file', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await expect( - createFileRuntimeKernel({ stateFile }).recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:invalid-write', - path: '', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('every event field'); - await expect(access(stateFile)).rejects.toThrow(); -}); - -test('poisons a kernel after lease compromise before it can append or mutate again', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let entered!: () => void; - let continueAppend!: () => void; - const enteredBeforeAppend = new Promise((resolve) => { - entered = resolve; - }); - const allowAppend = new Promise((resolve) => { - continueAppend = resolve; + await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).rejects.toMatchObject({ + code: 'corrupt', + name: 'AgentStateError', }); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { beforeAppend: async () => { - entered(); - await allowAppend; - } }, - }); - const pending = kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:compromised', - path: 'compromised.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - void pending.catch(() => undefined); - await Promise.race([ - enteredBeforeAppend, - wait(100).then(() => Promise.reject(new Error('test-only append barrier was not reached'))), - ]); - await rm(`${stateFile}.lock`, { force: true, recursive: true }); - await wait(1_100); - continueAppend(); - await expect(pending).rejects.toThrow('lease was compromised'); - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:after-compromise', - path: 'after-compromise.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('permanently poisoned'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); + expect(new AgentStateError('corrupt', 'proof of the exported class').name).toBe('AgentStateError'); }); -test('treats a valid empty JSONL file as an empty snapshot', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); +test('treats a valid empty state file as an empty snapshot', async () => { + const stateFile = await temporaryStateFile(); await writeFile(stateFile, '', 'utf8'); await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ diff --git a/examples/rsc-agent-runtime/tests/support/state-driver-warnings.ts b/examples/rsc-agent-runtime/tests/support/state-driver-warnings.ts new file mode 100644 index 000000000..c9cc97f17 --- /dev/null +++ b/examples/rsc-agent-runtime/tests/support/state-driver-warnings.ts @@ -0,0 +1,16 @@ +/** + * Node prints a one-time `ExperimentalWarning: SQLite is an experimental + * feature` to stderr when the framework state kernel's `node:sqlite` driver + * loads (#98, documented in this example's README limits section). Tests + * tolerate exactly that documented warning; every other stderr byte stays + * load-bearing. + */ +export const withoutNodeSqliteWarning = (stderr: string): string => + stderr + .split('\n') + .filter( + (line) => + !line.includes('ExperimentalWarning: SQLite is an experimental feature') && + !line.includes('Use `node --trace-warnings ...` to show where the warning was created'), + ) + .join('\n'); diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 03936251b..21bee76c7 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -21,6 +21,7 @@ "provenance": true }, "type": "module", + "sideEffects": false, "engines": { "node": ">=22.19.0" }, @@ -44,6 +45,10 @@ "./state": { "types": "./dist/state/index.d.ts", "import": "./dist/state.js" + }, + "./state/sqlite": { + "types": "./dist/state/sqlite.d.ts", + "import": "./dist/state/sqlite.js" } }, "scripts": { diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts index 7dad0a79d..a98707593 100644 --- a/packages/rsc-runtime/rslib.config.ts +++ b/packages/rsc-runtime/rslib.config.ts @@ -1,12 +1,39 @@ import { defineConfig } from '@rslib/core'; +const sharedLib = { + bundle: true, + dts: true, + format: 'esm', + syntax: 'es2022', +} as const; + export default defineConfig({ lib: [ { - bundle: true, - dts: true, - format: 'esm', - syntax: 'es2022', + ...sharedLib, + source: { + entry: { + 'flight/server': './src/flight/server.ts', + index: './src/index.ts', + plugin: './src/plugin.ts', + state: './src/state/index.ts', + }, + }, + }, + { + ...sharedLib, + // The sqlite driver is its own entry so `node:sqlite` (and its + // ExperimentalWarning) never loads for volatile-state or stateless + // consumers. It imports the state entry's runtime instead of + // re-bundling the kernel: a duplicated module graph would fork class + // identity and break `instanceof AgentStateError` across entries. + output: { + cleanDistPath: false, + externals: { './index.js': '../state.js' }, + }, + source: { + entry: { 'state/sqlite': './src/state/sqlite.ts' }, + }, }, ], output: { @@ -16,12 +43,6 @@ export default defineConfig({ }, root: import.meta.dirname, source: { - entry: { - 'flight/server': './src/flight/server.ts', - index: './src/index.ts', - plugin: './src/plugin.ts', - state: './src/state/index.ts', - }, tsconfigPath: './tsconfig.build.json', }, }); diff --git a/packages/rsc-runtime/src/state/index.ts b/packages/rsc-runtime/src/state/index.ts index 6277ae0ee..4f0a04ad5 100644 --- a/packages/rsc-runtime/src/state/index.ts +++ b/packages/rsc-runtime/src/state/index.ts @@ -13,6 +13,8 @@ export { AgentStateError, agentStateLifetimeIsVolatile, defineState, + describeSchemaIssues, + expectIdempotencyKey, } from './contract.js'; export type { AgentStateChange, @@ -36,6 +38,20 @@ export type { AgentStateSnapshot, AgentStateStore, } from './contract.js'; +// Driver-author toolkit: external drivers implement the same journal +// semantics with these helpers and prove it against the conformance suite. +export { + applyStateEvent, + canonicalCommitInput, + changeFromJournalRecord, + expectConsistentJournal, + migrationIdempotencyKey, + replayJournal, + resolveResetState, + runStateMigrations, +} from './journal.js'; +export type { AgentStateJournalRecord } from './journal.js'; +export { canonicalJson, deepFreezeJson, isJsonSafe } from './json.js'; export { createAgentStateHandle } from './handle.js'; export type { AgentStateHandleOptions } from './handle.js'; export { createMemoryStateDriver } from './memory-driver.js'; diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts new file mode 100644 index 000000000..af4a268c2 --- /dev/null +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -0,0 +1,584 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +// node:sqlite emits an ExperimentalWarning on load (documented in the README): +// the module is Node's built-in SQLite binding, stable enough for Node >= 22.13 +// without flags, and G3 chose it precisely because it adds zero dependencies. +// This import lives behind the dedicated `./state/sqlite` subpath so volatile +// state users and stateless projects never load it or see the warning. +import { DatabaseSync } from 'node:sqlite'; + +import type { + AgentStateChangeBatch, + AgentStateChangesOptions, + AgentStateCommitResult, + AgentStateDefinition, + AgentStateDispatchOptions, + AgentStateDriver, + AgentStateEventSchemas, + AgentStateJournalRecord, + AgentStateReadOptions, + AgentStateResetOptions, + AgentStateSnapshot, + AgentStateStore, +} from './index.js'; +import { + AgentStateError, + applyStateEvent, + canonicalCommitInput, + canonicalJson, + changeFromJournalRecord, + deepFreezeJson, + describeSchemaIssues, + expectIdempotencyKey, + migrationIdempotencyKey, + replayJournal, + resolveResetState, + runStateMigrations, +} from './index.js'; + +/** + * Workspace-durable state driver on `node:sqlite` (#98, G3). + * + * One SQLite database file per state instance holds the journal (monotonic + * revisions, unique idempotency keys), the materialized head, and the + * persisted definition identity/version. Durability discipline: + * + * - WAL journal mode with `synchronous = FULL`, so a killed writer can lose + * at most an uncommitted transaction — never a committed one, and never + * leave a half-applied commit (the cross-process kill test pins this). + * - Every mutation runs inside one `BEGIN IMMEDIATE` transaction: the + * idempotency lookup, compare-and-swap, reducer, journal append, and head + * update commit atomically; cross-process writers serialize on SQLite's + * file lock with a bounded busy timeout. + * - Corruption fails closed: SQLite-level corruption, journal/head revision + * mismatches, unreadable rows, or a head state that no longer satisfies + * the schema surface as typed `corrupt` errors, never repaired silently. + * - Explicit migrations run on open inside the same transactional discipline + * and rebase history (older exact revisions become `revision-unavailable`). + * + * Subscriptions are polling change cursors only; nothing stronger is + * promised from short-lived processes. + */ + +const KERNEL_FORMAT = 1; + +export interface SqliteStateDriverOptions { + /** + * SQLite lock wait budget per operation in milliseconds (default 5000). + * Contending cross-process writers queue on the database lock for at most + * this long before the operation fails typed `unavailable`. + */ + readonly busyTimeoutMs?: number; + /** + * Exact database file for a single state instance (the store's isolated + * root). Mutually exclusive with `root`; opening a second definition id on + * the same file is a typed `corrupt` mismatch. + */ + readonly file?: string; + /** Clock injection for deterministic tests. */ + readonly now?: () => Date; + /** + * Directory that isolates one state root: each definition id gets its own + * database file inside it. Mutually exclusive with `file`. + */ + readonly root?: string; +} + +interface SqliteErrorShape { + readonly errcode?: number; + readonly errstr?: string; +} + +const SQLITE_CORRUPT = 11; +const SQLITE_NOTADB = 26; +const SQLITE_BUSY = 5; + +const mapSqliteError = (definitionId: string, action: string, error: unknown): AgentStateError => { + if (error instanceof AgentStateError) return error; + const shape = error as SqliteErrorShape; + const detail = typeof shape.errstr === 'string' ? `: ${shape.errstr}` : ''; + if (shape.errcode === SQLITE_CORRUPT || shape.errcode === SQLITE_NOTADB) { + return new AgentStateError( + 'corrupt', + `State '${definitionId}' storage is corrupt (${action}${detail})`, + { cause: error }, + ); + } + if (shape.errcode === SQLITE_BUSY) { + return new AgentStateError( + 'unavailable', + `State '${definitionId}' storage stayed locked beyond the busy timeout (${action})`, + { cause: error }, + ); + } + return new AgentStateError( + 'unavailable', + `State '${definitionId}' storage failed (${action}${detail})`, + { cause: error }, + ); +}; + +const expectRevisionShape = (revision: number | undefined, label: string): void => { + if (revision !== undefined && (!Number.isInteger(revision) || revision < 0)) { + throw new AgentStateError('invalid-input', `${label} must be an integer >= 0`); + } +}; + +const expectOperable = (closed: boolean, definitionId: string, signal: AbortSignal | undefined): void => { + if (closed) { + throw new AgentStateError('store-closed', `State '${definitionId}' store is closed`); + } + if (signal?.aborted === true) { + throw new AgentStateError('aborted', `State '${definitionId}' operation was aborted`, { cause: signal.reason }); + } +}; + +const parseStoredJson = (definitionId: string, column: string, revision: number, text: string): unknown => { + try { + return JSON.parse(text); + } catch (error) { + throw new AgentStateError( + 'corrupt', + `State '${definitionId}' journal ${column} at revision ${String(revision)} is not valid JSON`, + { cause: error }, + ); + } +}; + +interface JournalRow { + readonly committed_at: string; + readonly idempotency_key: string; + readonly kind: string; + readonly name: string | null; + readonly payload: string | null; + readonly revision: number; + readonly state: string | null; + readonly to_version: number | null; +} + +const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournalRecord => { + const base = { committedAt: row.committed_at, idempotencyKey: row.idempotency_key, revision: row.revision }; + if (row.kind === 'event' && row.name !== null && row.payload !== null) { + return { ...base, kind: 'event', name: row.name, payload: parseStoredJson(definitionId, 'payload', row.revision, row.payload) }; + } + if (row.kind === 'reset' && row.state !== null) { + return { ...base, kind: 'reset', state: parseStoredJson(definitionId, 'state', row.revision, row.state) }; + } + if (row.kind === 'migrate' && row.state !== null && row.to_version !== null) { + return { + ...base, + kind: 'migrate', + state: parseStoredJson(definitionId, 'state', row.revision, row.state), + toVersion: row.to_version, + }; + } + throw new AgentStateError( + 'corrupt', + `State '${definitionId}' journal row at revision ${String(row.revision)} has an invalid shape`, + ); +}; + +const sanitizedFileName = (definitionId: string): string => + `${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${Buffer.from(definitionId, 'utf8').toString('hex').slice(0, 12)}.sqlite`; + +class SqliteStore implements AgentStateStore { + readonly location: string; + #closed = false; + readonly #db: DatabaseSync; + #definition: AgentStateDefinition; + readonly #now: () => Date; + readonly #onClose: () => void; + + constructor( + definition: AgentStateDefinition, + db: DatabaseSync, + file: string, + now: () => Date, + onClose: () => void, + ) { + this.#definition = definition; + this.#db = db; + this.location = file; + this.#now = now; + this.#onClose = onClose; + } + + get definition(): AgentStateDefinition { + return this.#definition; + } + + /** + * Runs `work` inside one transaction, mapping storage failures to typed + * errors. Writes take BEGIN IMMEDIATE (cross-process serialization on the + * database lock); reads take a deferred snapshot transaction, which WAL + * never blocks on writers. + */ + #transaction(mode: 'read' | 'write', action: string, work: () => T): T { + const id = this.#definition.id; + try { + this.#db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN DEFERRED'); + } catch (error) { + throw mapSqliteError(id, `${action}: begin`, error); + } + let result: T; + try { + result = work(); + } catch (error) { + try { + this.#db.exec('ROLLBACK'); + } catch { + // The connection is unusable; the original error carries the cause. + } + throw mapSqliteError(id, action, error); + } + try { + this.#db.exec('COMMIT'); + } catch (error) { + throw mapSqliteError(id, `${action}: commit`, error); + } + return result; + } + + #headRow(action: string): { revision: number; state: string } { + const row = this.#db.prepare('SELECT revision, state FROM agent_state_head WHERE id = 1').get() as + | { revision: number; state: string } + | undefined; + if (row === undefined || !Number.isInteger(row.revision) || row.revision < 0) { + throw new AgentStateError('corrupt', `State '${this.#definition.id}' head row is missing or invalid (${action})`); + } + return row; + } + + #headState(action: string): AgentStateSnapshot { + const row = this.#headRow(action); + const raw = parseStoredJson(this.#definition.id, 'head state', row.revision, row.state); + const parsed = this.#definition.schema.safeParse(raw); + if (!parsed.success) { + throw new AgentStateError( + 'corrupt', + `State '${this.#definition.id}' head state no longer satisfies the schema: ${describeSchemaIssues(parsed.error)}`, + ); + } + return Object.freeze({ revision: row.revision, state: deepFreezeJson(parsed.data) }); + } + + #journalRecords(upTo?: number): AgentStateJournalRecord[] { + const rows = ( + upTo === undefined + ? this.#db.prepare('SELECT * FROM agent_state_journal ORDER BY revision').all() + : this.#db.prepare('SELECT * FROM agent_state_journal WHERE revision <= ? ORDER BY revision').all(upTo) + ) as unknown as JournalRow[]; + return rows.map((row) => recordFromRow(this.#definition.id, row)); + } + + #latestMigrationRevision(): number { + const row = this.#db + .prepare("SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal WHERE kind = 'migrate'") + .get() as { revision: number }; + return row.revision; + } + + #committedByKey(key: string): AgentStateJournalRecord | undefined { + const row = this.#db.prepare('SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as + | JournalRow + | undefined; + return row === undefined ? undefined : recordFromRow(this.#definition.id, row); + } + + #replayTo(revision: number): TState { + const latestMigration = this.#latestMigrationRevision(); + if (latestMigration > revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${this.#definition.id}' revision ${String(revision)} predates the migration at revision ${String(latestMigration)}`, + ); + } + return replayJournal(this.#definition, this.#journalRecords(revision), revision); + } + + #appendRecord(record: AgentStateJournalRecord, state: TState): AgentStateCommitResult { + const stateText = canonicalJson(state); + this.#db + .prepare( + 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ) + .run( + record.revision, + record.kind, + record.kind === 'event' ? record.name : null, + record.kind === 'event' ? canonicalJson(record.payload) : null, + record.kind === 'event' ? null : stateText, + record.kind === 'migrate' ? record.toVersion : null, + record.idempotencyKey, + record.committedAt, + ); + this.#db.prepare('UPDATE agent_state_head SET revision = ?, state = ? WHERE id = 1').run(record.revision, stateText); + return Object.freeze({ replayed: false, revision: record.revision, state }); + } + + #commit( + input: + | { readonly kind: 'event'; readonly name: string; readonly rawPayload: unknown } + | { readonly kind: 'reset'; readonly seed: TState | undefined }, + options: AgentStateDispatchOptions | AgentStateResetOptions, + ): AgentStateCommitResult { + expectOperable(this.#closed, this.#definition.id, options.signal); + const key = expectIdempotencyKey(options.idempotencyKey); + expectRevisionShape(options.expectedRevision, `State '${this.#definition.id}' expectedRevision`); + return this.#transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', () => { + const head = this.#headState('commit'); + const prepared = + input.kind === 'event' + ? ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { + const applied = applyStateEvent(this.#definition, head.state, input.name, input.rawPayload); + const record: AgentStateJournalRecord = { + committedAt: this.#now().toISOString(), + idempotencyKey: key, + kind: 'event', + name: input.name, + payload: applied.payload, + revision: head.revision + 1, + }; + return { canonicalInput: canonicalCommitInput(record), record, state: applied.state }; + })() + : ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { + const state = resolveResetState(this.#definition, input.seed); + const record: AgentStateJournalRecord = { + committedAt: this.#now().toISOString(), + idempotencyKey: key, + kind: 'reset', + revision: head.revision + 1, + state, + }; + return { canonicalInput: canonicalCommitInput(record), record, state }; + })(); + const committed = this.#committedByKey(key); + if (committed !== undefined) { + if (canonicalCommitInput(committed) !== prepared.canonicalInput) { + throw new AgentStateError( + 'idempotency-conflict', + `State '${this.#definition.id}' idempotency key was reused with a conflicting input`, + ); + } + return Object.freeze({ replayed: true, revision: committed.revision, state: this.#replayTo(committed.revision) }); + } + if (options.expectedRevision !== undefined && options.expectedRevision !== head.revision) { + throw new AgentStateError( + 'revision-conflict', + `State '${this.#definition.id}' expected revision ${String(options.expectedRevision)} but the head is ${String(head.revision)}`, + ); + } + return this.#appendRecord(prepared.record, prepared.state); + }); + } + + async dispatch>( + name: TName, + payload: unknown, + options: AgentStateDispatchOptions, + ): Promise> { + return this.#commit({ kind: 'event', name, rawPayload: payload }, options); + } + + async reset(options: AgentStateResetOptions): Promise> { + return this.#commit({ kind: 'reset', seed: options.seed }, options); + } + + async read(options: AgentStateReadOptions = {}): Promise> { + expectOperable(this.#closed, this.#definition.id, options.signal); + expectRevisionShape(options.revision, `State '${this.#definition.id}' revision`); + return this.#transaction('read', 'read', () => { + const head = this.#headState('read'); + if (options.revision === undefined || options.revision === head.revision) return head; + if (options.revision > head.revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${this.#definition.id}' revision ${String(options.revision)} is beyond the head ${String(head.revision)}`, + ); + } + return Object.freeze({ revision: options.revision, state: this.#replayTo(options.revision) }); + }); + } + + async changes(options: AgentStateChangesOptions): Promise { + expectOperable(this.#closed, this.#definition.id, options.signal); + if (options.afterRevision === undefined) { + throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes require afterRevision`); + } + expectRevisionShape(options.afterRevision, `State '${this.#definition.id}' afterRevision`); + if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) { + throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes limit must be an integer >= 1`); + } + return this.#transaction('read', 'changes', () => { + const head = this.#headRow('changes'); + const rows = this.#db + .prepare('SELECT * FROM agent_state_journal WHERE revision > ? ORDER BY revision LIMIT ?') + .all(options.afterRevision, options.limit ?? -1) as unknown as JournalRow[]; + const changes = rows.map((row) => changeFromJournalRecord(recordFromRow(this.#definition.id, row))); + return Object.freeze({ changes: Object.freeze(changes), headRevision: head.revision }); + }); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + try { + this.#db.close(); + } catch { + // Closing an already-broken connection must not mask the caller's path. + } + this.#onClose(); + } + + /** Opens the database schema, verifies identity, and runs due migrations. */ + initialize(): void { + this.#transaction('write', 'open', () => { + this.#db.exec(` + CREATE TABLE IF NOT EXISTS agent_state_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + definition_id TEXT NOT NULL, + schema_version INTEGER NOT NULL, + kernel_format INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS agent_state_journal ( + revision INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('event', 'reset', 'migrate')), + name TEXT, + payload TEXT, + state TEXT, + to_version INTEGER, + idempotency_key TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS agent_state_head ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL, + state TEXT NOT NULL + ); + `); + const definition = this.#definition; + const meta = this.#db + .prepare('SELECT definition_id, schema_version, kernel_format FROM agent_state_meta WHERE id = 1') + .get() as { definition_id: string; kernel_format: number; schema_version: number } | undefined; + if (meta === undefined) { + this.#db + .prepare('INSERT INTO agent_state_meta (id, definition_id, schema_version, kernel_format) VALUES (1, ?, ?, ?)') + .run(definition.id, definition.version, KERNEL_FORMAT); + this.#db + .prepare('INSERT INTO agent_state_head (id, revision, state) VALUES (1, 0, ?)') + .run(canonicalJson(definition.initial)); + return; + } + if (meta.definition_id !== definition.id) { + throw new AgentStateError( + 'corrupt', + `State '${definition.id}' storage at '${this.location}' belongs to definition '${meta.definition_id}'`, + ); + } + if (meta.kernel_format !== KERNEL_FORMAT) { + throw new AgentStateError( + 'corrupt', + `State '${definition.id}' storage uses kernel format ${String(meta.kernel_format)}; this kernel reads format ${String(KERNEL_FORMAT)}`, + ); + } + const head = this.#headRow('open'); + const journalHead = ( + this.#db.prepare('SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal').get() as { + revision: number; + } + ).revision; + if (head.revision !== journalHead) { + throw new AgentStateError( + 'corrupt', + `State '${definition.id}' head revision ${String(head.revision)} does not match the journal head ${String(journalHead)}`, + ); + } + if (meta.schema_version === definition.version) return; + const rawHead = parseStoredJson(definition.id, 'head state', head.revision, head.state); + const migrated = runStateMigrations(definition, meta.schema_version, rawHead); + const record: AgentStateJournalRecord = { + committedAt: this.#now().toISOString(), + idempotencyKey: migrationIdempotencyKey(definition.version), + kind: 'migrate', + revision: head.revision + 1, + state: migrated, + toVersion: definition.version, + }; + this.#appendRecord(record, migrated); + this.#db.prepare('UPDATE agent_state_meta SET schema_version = ? WHERE id = 1').run(definition.version); + }); + } +} + +export const createSqliteStateDriver = (options: SqliteStateDriverOptions): AgentStateDriver => { + if ((options.root === undefined) === (options.file === undefined)) { + throw new AgentStateError('invalid-input', 'Sqlite state drivers require exactly one of root or file'); + } + const busyTimeoutMs = options.busyTimeoutMs ?? 5000; + if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 1) { + throw new AgentStateError('invalid-input', 'busyTimeoutMs must be an integer >= 1'); + } + const now = options.now ?? ((): Date => new Date()); + const openStores = new Set>(); + let closed = false; + + return Object.freeze({ + durable: true, + kind: 'sqlite', + lifetime: 'workspace-durable' as const, + + async close(): Promise { + closed = true; + for (const store of [...openStores]) { + await store.close(); + } + openStores.clear(); + }, + + async open( + definition: AgentStateDefinition, + ): Promise> { + if (closed) { + throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); + } + if (definition.lifetime !== 'workspace-durable') { + throw new AgentStateError( + 'lifetime-mismatch', + `State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides 'workspace-durable'`, + ); + } + const file = resolve( + options.file !== undefined ? options.file : join(options.root as string, sanitizedFileName(definition.id)), + ); + let db: DatabaseSync; + try { + mkdirSync(dirname(file), { recursive: true }); + db = new DatabaseSync(file); + } catch (error) { + throw mapSqliteError(definition.id, 'open database', error); + } + let store: SqliteStore; + try { + // busy_timeout first: switching journal modes takes the database + // lock, and two processes racing the very first open would otherwise + // fail SQLITE_BUSY with a zero retry budget. + db.exec(`PRAGMA busy_timeout = ${String(busyTimeoutMs)}`); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + store = new SqliteStore(definition, db, file, now, () => + openStores.delete(store as unknown as SqliteStore), + ); + store.initialize(); + } catch (error) { + try { + db.close(); + } catch { + // Preserve the initialization failure. + } + throw mapSqliteError(definition.id, 'initialize storage', error); + } + openStores.add(store as unknown as SqliteStore); + return store; + }, + }); +}; diff --git a/packages/rsc-runtime/tests/fixtures/state-sqlite-writer.mjs b/packages/rsc-runtime/tests/fixtures/state-sqlite-writer.mjs new file mode 100644 index 000000000..b410b8247 --- /dev/null +++ b/packages/rsc-runtime/tests/fixtures/state-sqlite-writer.mjs @@ -0,0 +1,59 @@ +/** + * Cross-process writer for the sqlite state driver proofs. Runs against the + * BUILT package (dist/), so two independent Node processes exercise the real + * published module graph over one workspace-durable database file. + * + * argv: [count] + * + * The definition mirrors `crossProcessDefinition` in + * ../state-sqlite-cross-process.test.ts; the two must stay identical. + */ +import { z } from 'zod'; + +import { defineState } from '../../dist/state.js'; +import { createSqliteStateDriver } from '../../dist/state/sqlite.js'; + +const [, , file, writerId, mode, countText] = process.argv; +if (typeof file !== 'string' || typeof writerId !== 'string' || (mode !== 'count' && mode !== 'loop')) { + process.stderr.write('usage: state-sqlite-writer.mjs [count]\n'); + process.exit(2); +} + +const definition = defineState({ + events: { + taskAdded: z.object({ id: z.string().min(1), title: z.string().min(1) }).strict(), + }, + id: 'state-cross-process/tasks', + initial: { tasks: [] }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ tasks: [...state.tasks, event.payload] }), + schema: z.object({ tasks: z.array(z.object({ id: z.string(), title: z.string() }).strict()) }).strict(), +}); + +const driver = createSqliteStateDriver({ file }); +const store = await driver.open(definition); + +if (mode === 'count') { + const count = Number(countText); + for (let index = 0; index < count; index += 1) { + await store.dispatch( + 'taskAdded', + { id: `${writerId}-${String(index)}`, title: `Task ${writerId} ${String(index)}` }, + { idempotencyKey: `${writerId}:${String(index)}` }, + ); + } + await store.close(); + process.stdout.write(JSON.stringify({ committed: count, writerId })); + process.exit(0); +} + +// mode === 'loop': commit forever; the parent SIGKILLs this process mid-write +// to prove a killed writer can never leave a successful-but-corrupt state. +process.stdout.write('{"ready":true}\n'); +for (let index = 0; ; index += 1) { + await store.dispatch( + 'taskAdded', + { id: `${writerId}-${String(index)}`, title: `Task ${writerId} ${String(index)}` }, + { idempotencyKey: `${writerId}:${String(index)}` }, + ); +} diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts new file mode 100644 index 000000000..ce6ae8555 --- /dev/null +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +/** + * Packaged-tree boundaries for the optional state kernel (#98): stateless + * consumers who import the package root (or `./plugin`) must receive none of + * the kernel or storage code, volatile-state consumers must never load + * `node:sqlite`, and the sqlite entry must share the state entry's runtime + * so error identity holds across subpaths. Runs against the prebuilt dist + * (the integration pool builds it up front). + */ + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const distFile = async (...segments: string[]): Promise => + readFile(join(packageRoot, 'dist', ...segments), 'utf8'); + +describe.sequential('state kernel packaging boundaries', () => { + it('keeps every kernel and storage identifier out of the root and plugin entries', async () => { + for (const entry of ['index.js', 'plugin.js']) { + const source = await distFile(entry); + for (const identifier of ['node:sqlite', 'defineState', 'AgentStateError', 'DatabaseSync', 'agent_state_journal']) { + expect(source, `${entry} must not contain ${identifier}`).not.toContain(identifier); + } + } + }); + + it('keeps node:sqlite out of the volatile state entry', async () => { + const source = await distFile('state.js'); + expect(source).toContain('defineState'); + expect(source).not.toContain('node:sqlite'); + expect(source).not.toContain('DatabaseSync'); + }); + + it('gives the sqlite entry its own subpath that shares the state runtime', async () => { + const source = await distFile('state', 'sqlite.js'); + expect(source).toContain('node:sqlite'); + expect(source).toContain('from "../state.js"'); + const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as { + exports: Record; + }; + expect(Object.keys(packageJson.exports)).toEqual(['.', './plugin', './flight/server', './state', './state/sqlite']); + for (const subpath of Object.keys(packageJson.exports)) { + const target = packageJson.exports[subpath]!; + await expect(distFile(...target.import.replace('./dist/', '').split('/'))).resolves.toBeTruthy(); + await expect(distFile(...target.types.replace('./dist/', '').split('/'))).resolves.toBeTruthy(); + } + }); + + it('throws one shared AgentStateError identity across the state and sqlite entries', async () => { + const stateEntry = (await import(pathToFileURL(join(packageRoot, 'dist', 'state.js')).href)) as + typeof import('../src/state/index.js'); + const sqliteEntry = (await import(pathToFileURL(join(packageRoot, 'dist', 'state', 'sqlite.js')).href)) as + typeof import('../src/state/sqlite.js'); + const definition = stateEntry.defineState({ + events: { noted: z.object({ value: z.string() }).strict() }, + id: 'state-packaging/identity', + initial: { notes: [] as readonly string[] }, + lifetime: 'process', + reduce: (state, event) => ({ notes: [...state.notes, event.payload.value] }), + schema: z.object({ notes: z.array(z.string()) }).strict(), + }); + // The sqlite driver rejects a volatile definition; the error must be an + // instance of the state entry's AgentStateError class, proving one + // shared kernel runtime rather than a duplicated bundle. + try { + await sqliteEntry.createSqliteStateDriver({ root: packageRoot }).open(definition); + throw new Error('expected a lifetime-mismatch rejection'); + } catch (error) { + expect(error).toBeInstanceOf(stateEntry.AgentStateError); + expect((error as { code: string }).code).toBe('lifetime-mismatch'); + } + }); +}); diff --git a/packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts b/packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts new file mode 100644 index 000000000..510972bd9 --- /dev/null +++ b/packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts @@ -0,0 +1,161 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import { defineState, type AgentStateDefinition } from '../src/state/index.js'; +import { createSqliteStateDriver } from '../src/state/sqlite.js'; + +/** + * The two cross-process acceptance proofs for the workspace-durable driver + * (#98): independent processes safely update one state instance, and a + * SIGKILLed writer can never leave a successful-but-corrupt state. The + * children run the BUILT package from dist/ (prebuilt by the integration + * pool's root build), so the proof covers the published module graph. + */ + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const writerFixture = join(packageRoot, 'tests', 'fixtures', 'state-sqlite-writer.mjs'); +const timeScale = Math.max(1, Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? '1') || 1); + +const crossProcessEvents = { + taskAdded: z.object({ id: z.string().min(1), title: z.string().min(1) }).strict(), +} as const; + +interface CrossProcessState { + readonly tasks: readonly { readonly id: string; readonly title: string }[]; +} + +/** Mirrors the definition inside tests/fixtures/state-sqlite-writer.mjs; the two must stay identical. */ +const crossProcessDefinition = (): AgentStateDefinition => + defineState({ + events: crossProcessEvents, + id: 'state-cross-process/tasks', + initial: { tasks: [] }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ tasks: [...state.tasks, event.payload] }), + schema: z.object({ tasks: z.array(z.object({ id: z.string(), title: z.string() }).strict()) }).strict(), + }); + +const spawnWriter = (file: string, writerId: string, mode: 'count' | 'loop', count?: number): ChildProcess => + spawn(process.execPath, [writerFixture, file, writerId, mode, ...(count === undefined ? [] : [String(count)])], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + +const collect = (child: ChildProcess): { stderr: () => string; stdout: () => string } => { + const out: Buffer[] = []; + const err: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer) => out.push(chunk)); + child.stderr?.on('data', (chunk: Buffer) => err.push(chunk)); + return { + stderr: () => Buffer.concat(err).toString('utf8'), + stdout: () => Buffer.concat(out).toString('utf8'), + }; +}; + +const withStateFile = async (run: (file: string) => Promise): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-state-cross-process-')); + try { + await run(join(root, 'state.sqlite')); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +describe.sequential('sqlite driver cross-process proofs', () => { + it('two independent processes safely update one workspace-durable state instance', { timeout: 60_000 }, () => + withStateFile(async (file) => { + const perWriter = 25; + const first = spawnWriter(file, 'alpha', 'count', perWriter); + const second = spawnWriter(file, 'beta', 'count', perWriter); + const firstOutput = collect(first); + const secondOutput = collect(second); + const [[firstExit], [secondExit]] = (await Promise.all([once(first, 'close'), once(second, 'close')])) as [ + [number | null], + [number | null], + ]; + expect(firstExit, firstOutput.stderr()).toBe(0); + expect(secondExit, secondOutput.stderr()).toBe(0); + expect(JSON.parse(firstOutput.stdout())).toEqual({ committed: perWriter, writerId: 'alpha' }); + + const store = await createSqliteStateDriver({ file }).open(crossProcessDefinition()); + const head = await store.read(); + expect(head.revision).toBe(perWriter * 2); + expect(head.state.tasks).toHaveLength(perWriter * 2); + const ids = head.state.tasks.map((task) => task.id); + expect(new Set(ids).size).toBe(perWriter * 2); + for (const writerId of ['alpha', 'beta']) { + expect(ids.filter((id) => id.startsWith(`${writerId}-`))).toHaveLength(perWriter); + } + + // The interleaved journal replays exactly at every revision boundary. + const cursor = await store.changes({ afterRevision: 0 }); + expect(cursor.headRevision).toBe(perWriter * 2); + expect(cursor.changes.map((change) => change.revision)).toEqual( + Array.from({ length: perWriter * 2 }, (_, index) => index + 1), + ); + expect((await store.read({ revision: perWriter })).state.tasks).toHaveLength(perWriter); + + // Replaying a key committed by another process returns its committed result. + const replayed = await store.dispatch( + 'taskAdded', + { id: 'alpha-0', title: 'Task alpha 0' }, + { idempotencyKey: 'alpha:0' }, + ); + expect(replayed.replayed).toBe(true); + await store.close(); + })); + + it('a SIGKILLed writer cannot leave a successful-but-corrupt state', { timeout: 60_000 }, () => + withStateFile(async (file) => { + const writer = spawnWriter(file, 'victim', 'loop'); + const output = collect(writer); + const closed = once(writer, 'close'); + try { + const reader = await createSqliteStateDriver({ file, busyTimeoutMs: 10_000 }).open(crossProcessDefinition()); + const deadline = Date.now() + 20_000 * timeScale; + let observed = 0; + while (observed < 5) { + observed = (await reader.read()).revision; + if (observed >= 5) break; + if (Date.now() > deadline) { + throw new Error(`writer only reached revision ${String(observed)}: ${output.stderr()}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await reader.close(); + } finally { + writer.kill('SIGKILL'); + } + await closed; + + // A fresh instance over the same file opens cleanly: the head matches + // the journal, SQLite's own integrity check passes, every retained + // revision replays, and the store accepts new commits. + const store = await createSqliteStateDriver({ file }).open(crossProcessDefinition()); + const head = await store.read(); + expect(head.revision).toBeGreaterThanOrEqual(5); + expect(head.state.tasks).toHaveLength(head.revision); + + const db = new DatabaseSync(file); + expect(db.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }); + db.close(); + + for (let revision = 0; revision <= head.revision; revision += 1) { + expect((await store.read({ revision })).state.tasks).toHaveLength(revision); + } + const next = await store.dispatch( + 'taskAdded', + { id: 'post-kill', title: 'Task post kill' }, + { idempotencyKey: 'post-kill:0' }, + ); + expect(next.revision).toBe(head.revision + 1); + await store.close(); + })); +}); diff --git a/packages/rsc-runtime/tests/state-sqlite.test.ts b/packages/rsc-runtime/tests/state-sqlite.test.ts new file mode 100644 index 000000000..75ac77108 --- /dev/null +++ b/packages/rsc-runtime/tests/state-sqlite.test.ts @@ -0,0 +1,197 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import { + AgentStateError, + defineState, + stateDriverConformanceCases, + type AgentStateDefinition, + type AgentStateDriver, + type AgentStateEventSchemas, + type StateConformanceContext, +} from '../src/state/index.js'; +import { createSqliteStateDriver } from '../src/state/sqlite.js'; + +/** + * The workspace-durable driver must pass the exact same conformance suite as + * the in-memory driver — including the durable-only case the memory harness + * skips — plus the storage-level behavior only a real database can express: + * corruption fail-closed, definition identity pinning, and WAL mode. + */ + +const withContext = async (run: (context: StateConformanceContext) => Promise): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-state-sqlite-conformance-')); + const drivers: AgentStateDriver[] = []; + const driver = (): AgentStateDriver => { + // Every open/reopen uses a fresh driver instance (a fresh connection): + // "another instance" for a durable driver is a genuinely new client + // over the same storage root. + const created = createSqliteStateDriver({ root }); + drivers.push(created); + return created; + }; + try { + await run({ + durable: true, + lifetime: 'workspace-durable', + open: (definition) => driver().open(definition), + reopen: (definition) => driver().open(definition), + }); + } finally { + for (const created of drivers) { + await created.close(); + } + await rm(root, { force: true, recursive: true }); + } +}; + +describe('sqlite driver conformance', () => { + for (const conformanceCase of stateDriverConformanceCases) { + it(conformanceCase.name, () => withContext((context) => conformanceCase.run(context))); + } +}); + +const counterEvents = { + bumped: z.object({ by: z.number().int() }).strict(), +} as const; + +interface CounterState { + readonly count: number; +} + +const counterDefinition = ( + id = 'state-sqlite-test/counter', +): AgentStateDefinition => + defineState({ + events: counterEvents, + id, + initial: { count: 0 }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }); + +const otherDefinition = (): AgentStateDefinition => + defineState({ + events: counterEvents, + id: 'state-sqlite-test/other', + initial: { count: 0 }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }); + +const withRoot = async (run: (root: string) => Promise): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-state-sqlite-')); + try { + await run(root); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +describe('sqlite driver storage behavior', () => { + it('declares durable workspace-durable storage and validates its options', () => { + const driver = createSqliteStateDriver({ root: tmpdir() }); + expect(driver).toMatchObject({ durable: true, kind: 'sqlite', lifetime: 'workspace-durable' }); + expect(() => createSqliteStateDriver({} as never)).toThrow(AgentStateError); + expect(() => createSqliteStateDriver({ file: 'a.sqlite', root: '/tmp' })).toThrow(AgentStateError); + expect(() => createSqliteStateDriver({ busyTimeoutMs: 0, root: '/tmp' })).toThrow(AgentStateError); + }); + + it('pins one definition id per database file', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + const first = await createSqliteStateDriver({ file }).open(counterDefinition()); + await first.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + await first.close(); + await expect(createSqliteStateDriver({ file }).open(otherDefinition())).rejects.toMatchObject({ + code: 'corrupt', + name: 'AgentStateError', + }); + })); + + it('separates definition ids into isolated database files under one root', () => + withRoot(async (root) => { + const driver = createSqliteStateDriver({ root }); + const first = await driver.open(counterDefinition()); + const second = await driver.open(otherDefinition()); + expect(first.location).not.toBe(second.location); + await first.dispatch('bumped', { by: 3 }, { idempotencyKey: 'k1' }); + expect((await second.read()).revision).toBe(0); + await driver.close(); + await expect(first.read()).rejects.toMatchObject({ code: 'store-closed' }); + })); + + it('runs WAL journal mode with full synchronous durability', () => + withRoot(async (root) => { + const store = await createSqliteStateDriver({ root }).open(counterDefinition()); + await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + const db = new DatabaseSync(store.location); + try { + expect(db.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }); + } finally { + db.close(); + } + await store.close(); + })); + + it('fails closed with a typed corrupt error when the file is not a database', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + await writeFile(file, 'this is not a sqlite database, and it is long enough to hold a header'); + await expect(createSqliteStateDriver({ file }).open(counterDefinition())).rejects.toMatchObject({ + code: 'corrupt', + name: 'AgentStateError', + }); + })); + + it('fails closed when the journal and head disagree', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + const store = await createSqliteStateDriver({ file }).open(counterDefinition()); + await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + await store.close(); + const db = new DatabaseSync(file); + db.exec('UPDATE agent_state_head SET revision = 7'); + db.close(); + await expect(createSqliteStateDriver({ file }).open(counterDefinition())).rejects.toMatchObject({ + code: 'corrupt', + }); + })); + + it('fails closed when a persisted head no longer satisfies the schema', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + const store = await createSqliteStateDriver({ file }).open(counterDefinition()); + await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + await store.close(); + const db = new DatabaseSync(file); + db.exec(`UPDATE agent_state_head SET state = '{"wrong":true}'`); + db.close(); + const reopened = await createSqliteStateDriver({ file }).open(counterDefinition()); + await expect(reopened.read()).rejects.toMatchObject({ code: 'corrupt' }); + await expect( + reopened.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k2' }), + ).rejects.toMatchObject({ code: 'corrupt' }); + await reopened.close(); + })); + + it('fails closed on a newer kernel storage format', () => + withRoot(async (root) => { + const file = join(root, 'state.sqlite'); + const store = await createSqliteStateDriver({ file }).open(counterDefinition()); + await store.close(); + const db = new DatabaseSync(file); + db.exec('UPDATE agent_state_meta SET kernel_format = 99'); + db.close(); + await expect(createSqliteStateDriver({ file }).open(counterDefinition())).rejects.toMatchObject({ + code: 'corrupt', + }); + })); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a4639a97..14eff7ecb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,6 @@ importers: express: specifier: 5.2.1 version: 5.2.1(supports-color@7.2.0) - proper-lockfile: - specifier: ^4.1.2 - version: 4.1.2 react: specifier: 19.2.8 version: 19.2.8 @@ -157,9 +154,6 @@ importers: '@types/express': specifier: 5.0.6 version: 5.0.6 - '@types/proper-lockfile': - specifier: ^4.1.4 - version: 4.1.4 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1164,9 +1158,6 @@ packages: '@types/node@26.4.0': resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} - '@types/proper-lockfile@4.1.4': - resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} - '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1181,9 +1172,6 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} - '@types/retry@0.12.5': - resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} - '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1698,9 +1686,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2173,9 +2158,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -2263,10 +2245,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2362,9 +2340,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3347,10 +3322,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/proper-lockfile@4.1.4': - dependencies: - '@types/retry': 0.12.5 - '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -3363,8 +3334,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/retry@0.12.5': {} - '@types/send@1.2.1': dependencies: '@types/node': 26.4.0 @@ -3819,8 +3788,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4471,12 +4438,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - property-information@7.2.0: {} proxy-addr@2.0.7: @@ -4590,8 +4551,6 @@ snapshots: require-from-string@2.0.2: {} - retry@0.12.0: {} - reusify@1.1.0: {} router@2.2.0(supports-color@7.2.0): @@ -4706,8 +4665,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sirv@3.0.2: diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 8d3d7d0eb..e3e2f9c67 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -44,6 +44,8 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/script-playground-service.test.ts', 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', + 'packages/rsc-runtime/tests/state-packaging.test.ts', + 'packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts', 'packages/workbench/tests/comparisons-page-client-scope-browser.test.ts', 'packages/workbench/tests/evals-real.e2e.test.ts', 'packages/workbench/tests/examples-real.e2e.test.ts',