diff --git a/.changeset/state-kernel-budgets.md b/.changeset/state-kernel-budgets.md new file mode 100644 index 000000000..a11d59a64 --- /dev/null +++ b/.changeset/state-kernel-budgets.md @@ -0,0 +1,8 @@ +--- +"@agent-bundle/runtime": minor +--- + +Add fail-closed size, time, and retention budgets to the optional Agent state +kernel. `defineState` now resolves configurable runtime policy defaults, and +the memory and SQLite drivers enforce identical typed `budget-exceeded` +semantics without changing reads or replay of committed history. diff --git a/.changeset/state-lifetime-visibility.md b/.changeset/state-lifetime-visibility.md new file mode 100644 index 000000000..376a04284 --- /dev/null +++ b/.changeset/state-lifetime-visibility.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +--- + +Expose declared state lifetime, driver, budgets, and provenance through +`agent-bundle inspect --state`, and add read-only durable SQLite store +inventory to `agent-bundle doctor`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 098b8b195..88e8bf335 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -26,7 +26,7 @@ gate a build, a validation, or a dev rebuild. | `AB5000` | General CLI and adapter failures. | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. | +| `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. | | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | @@ -151,7 +151,7 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | -## Route graph, state, and provider conventions (`AB4800`–`AB4820`, `AB4940`–`AB4942`) +## Route graph, state, and provider conventions (`AB4800`–`AB4821`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -264,10 +264,21 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4818` | error | `src/state.ts` is present but does not default-export one direct `defineState({ ... })` call, or `state` config is not the supported `false` opt-out. | | `AB4819` | error | The state definition's `id` or `lifetime` is missing, non-literal, empty, duplicated, or outside the state lifetime vocabulary. | | `AB4820` | error | A generated project selects `external` state lifetime; v1 generated mounting supports only `request`, `process`, and `workspace-durable` because external drivers require embedder wiring. | +| `AB4821` | error | A project state definition uses the reserved notice-ledger id `@agent-bundle/runtime/agent-notice-ledger/v1`; generated runtimes own that id for the co-mounted notice store. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | +## Read-only Doctor durable-state inventory (`AB7316`) + +`agent-bundle doctor` inventories workspace-durable SQLite stores by directory +entry and filesystem metadata only. It never opens a database or creates +SQLite lock or shared-memory files. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 85f62306d..e51683ed8 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -87,6 +87,33 @@ directory. Routed CLI bins and rendered scripts use in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger, while application authorization policy is deferred. +#### State mutation budgets + +`defineState({ ... })` accepts an optional `budgets` runtime policy. Omitted +fields resolve to these fail-closed defaults: + +- `maxEventBytes: 262_144` — UTF-8 bytes in the canonical JSON of each + schema-validated event payload. +- `maxStateBytes: 1_048_576` — UTF-8 bytes in the canonical JSON of the + initial state and each event, reset, or migration result. +- `maxRevisions: 100_000` — total journal revisions admitted for + caller-initiated events and resets. +- `maxCommitMs: 5_000` — wall-clock milliseconds from mutation validation + start until the commit is ready to append. + +Each override must be an integer of at least 1. A definition whose initial +state exceeds its state cap is rejected as `invalid-definition`; a mutation +that exceeds any cap fails typed `budget-exceeded` and commits nothing. +Raise the corresponding field in `budgets` to admit a larger or slower +mutation or retain more revisions. + +Budgets are runtime policy, not persisted state metadata. The same storage +may be reopened with different caps. Lowering a cap never breaks reads, +change cursors, or exact-revision replay of already-committed history. +Kernel-generated migration commits still enforce `maxStateBytes`, but are +exempt from `maxRevisions` and `maxCommitMs` so a full journal cannot brick +an otherwise valid migration. + ### Request context providers (power tier) Each direct child of `src/providers/` derives its key by camel-casing the file diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 73b4d14a3..2867b12a1 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,6 +1,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { + AGENT_STATE_DEFAULT_BUDGETS, + type AgentStateBudgets, +} from '@agent-bundle/runtime/state'; + import { capabilityIsSupported } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; @@ -253,10 +258,35 @@ export interface InspectionPlan { } export interface InspectOptions extends ProjectOptions { - readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills'; + readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills' | 'state'; readonly target?: string; } +export type StateInspectionDriver = 'memory' | 'sqlite'; + +export type StateInspection = + | { + readonly declared: false; + } + | { + readonly budgets: + | { + readonly resolved: AgentStateBudgets; + readonly source: 'declared' | 'defaults'; + } + | { + readonly source: 'dynamic'; + }; + readonly declared: true; + readonly driver: StateInspectionDriver; + readonly durableLocation?: string; + readonly id: string; + readonly lifetime: NonNullable['lifetime']; + readonly notices: readonly string[]; + readonly provenance: NonNullable['provenance']; + readonly source: string; + }; + export interface ReadyInspectResult { readonly diagnostics: readonly Diagnostic[]; readonly model: NormalizedPlugin; @@ -267,6 +297,7 @@ export interface ReadyInspectResult { readonly hooks?: NormalizedPlugin['hooks']; readonly routes?: RouteGraphInspection; readonly skills?: NormalizedPlugin['skills']; + readonly state?: StateInspection; readonly skillTreeLayouts?: readonly { readonly layout?: NormalizedPlugin['skills'][number]['skillTreeLayout']; readonly skillId: string; @@ -491,6 +522,54 @@ const skippedComponentsFor = ( : 'unsupported-capability') satisfies InspectionSkipReason, }))); +const durableStateLocation = + '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)'; + +const noticeLedgerInspection = + 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; + +const stateDriver = ( + lifetime: NonNullable['lifetime'], +): StateInspectionDriver => { + switch (lifetime) { + case 'request': + case 'process': + return 'memory'; + case 'workspace-durable': + return 'sqlite'; + default: { + const unreachable: never = lifetime; + throw new TypeError(`Unknown normalized state lifetime ${String(unreachable)}.`); + } + } +}; + +const inspectState = (model: NormalizedPlugin): StateInspection => { + const definition = model.state; + if (definition === undefined) return Object.freeze({ declared: false }); + const budgets: Extract['budgets'] = + definition.budgets === 'dynamic' + ? Object.freeze({ source: 'dynamic' }) + : Object.freeze({ + resolved: Object.freeze({ + ...AGENT_STATE_DEFAULT_BUDGETS, + ...(definition.budgets?.declared ?? {}), + }), + source: definition.budgets === undefined ? 'defaults' : 'declared', + }); + return deepFreeze({ + budgets, + declared: true, + driver: stateDriver(definition.lifetime), + ...(definition.lifetime === 'workspace-durable' ? { durableLocation: durableStateLocation } : {}), + id: definition.id, + lifetime: definition.lifetime, + notices: [noticeLedgerInspection], + provenance: definition.provenance, + source: definition.source, + }); +}; + export const inspect = async (options: InspectOptions): Promise => { const prepared = await prepareProject(options, 'inspect'); if ( @@ -582,6 +661,7 @@ export const inspect = async (options: InspectOptions): Promise = }))), } : {}), + ...(options.focus === 'state' ? { state: inspectState(model) } : {}), }); return Object.freeze({ diagnostics: prepared.diagnostics, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index a6979559b..f62753165 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -25,6 +25,7 @@ import type { InstallScope, } from './install/install.ts'; import type { + DoctorDurableStateReport, DoctorHost, DoctorReport, runDoctor, @@ -103,6 +104,7 @@ interface InspectCommandOptions { readonly root: string; readonly routes?: boolean; readonly skills?: boolean; + readonly state?: boolean; readonly target?: string; } @@ -258,6 +260,13 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => { ); }; +const formatByteSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + const kibibytes = bytes / 1024; + if (kibibytes < 1024) return `${kibibytes.toFixed(1).replace(/\.0$/u, '')} KiB`; + return `${(kibibytes / 1024).toFixed(1).replace(/\.0$/u, '')} MiB`; +}; + const writeHumanDoctor = (output: Output, result: DoctorReport): void => { for (const host of result.hosts) { const detail = host.probe.version ?? host.probe.evidence; @@ -272,6 +281,18 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; output.write(` bundle:${identity} ${host.bundle.state}\n`); } + const reports = [ + ...host.inventory.findings.map((finding) => finding.durableState), + host.bundle?.durableState, + ].filter((report): report is DoctorDurableStateReport => report !== undefined); + const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; + if (uniqueReports.length > 0) { + const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); + const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); + output.write( + ` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`, + ); + } } output.write( `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + @@ -306,6 +327,10 @@ const writeHumanInspect = (output: Output, result: Awaited plan.target).join(', ')}\n`); // Release identity is derived from package.json (issue #94); a project // without a package version gets a clearly labeled development fallback. @@ -313,6 +338,10 @@ const writeHumanInspect = (output: Output, result: Awaited { - const focuses = [options.bundler, options.hooks, options.routes, options.skills].filter((focus) => focus === true); + const focuses = [ + options.bundler, + options.hooks, + options.routes, + options.skills, + options.state, + ].filter((focus) => focus === true); if (focuses.length > 1) { throw new TypeError('Choose at most one inspect focus.'); } @@ -566,6 +602,7 @@ export const runCli = async ( ...(options.hooks === true ? { focus: 'hooks' as const } : {}), ...(options.routes === true ? { focus: 'routes' as const } : {}), ...(options.skills === true ? { focus: 'skills' as const } : {}), + ...(options.state === true ? { focus: 'state' as const } : {}), ...(options.target === undefined ? {} : { target: options.target }), }); if (options.json === true) writeMachine(stdout, result); diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 3603d517c..112ac8874 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -65,7 +65,7 @@ export interface DiscoveredProject { skills: SkillDocument[]; /** Conventional src/state.ts declaration and its parse-only diagnostics. */ state?: { - readonly definition?: Pick; + readonly definition?: Pick; readonly diagnostics: readonly Diagnostic[]; readonly source: string; }; diff --git a/packages/agent-bundle/src/config/state-extract.ts b/packages/agent-bundle/src/config/state-extract.ts index 0263a9b55..d12372e83 100644 --- a/packages/agent-bundle/src/config/state-extract.ts +++ b/packages/agent-bundle/src/config/state-extract.ts @@ -4,13 +4,17 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; -import type { NormalizedStateDefinition } from '../core/types.ts'; +import type { + AgentStateBudgetName, + NormalizedStateBudgets, + NormalizedStateDefinition, +} from '../core/types.ts'; /** Reserved by the generated runtime for the internal notice ledger store. */ const AGENT_NOTICE_LEDGER_STATE_ID = '@agent-bundle/runtime/agent-notice-ledger/v1'; export interface ExtractedStateDefinition { - readonly definition?: Pick; + readonly definition?: Pick; readonly diagnostics: readonly Diagnostic[]; } @@ -45,6 +49,54 @@ const property = ( return matches.length === 1 ? unwrap(matches[0]!.initializer) : undefined; }; +const stateBudgetNames = new Set([ + 'maxCommitMs', + 'maxEventBytes', + 'maxRevisions', + 'maxStateBytes', +]); + +const staticPropertyName = (name: ts.PropertyName): string | undefined => + ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; + +const extractBudgets = (object: ts.ObjectLiteralExpression): NormalizedStateBudgets | undefined => { + const candidates = object.properties.filter((candidate) => + !ts.isSpreadAssignment(candidate) && staticPropertyName(candidate.name) === 'budgets'); + if (candidates.length === 0) { + return object.properties.some((candidate) => + ts.isSpreadAssignment(candidate) || (!ts.isSpreadAssignment(candidate) && ts.isComputedPropertyName(candidate.name))) + ? 'dynamic' + : undefined; + } + const candidate = candidates[0]; + if ( + candidates.length !== 1 + || candidate === undefined + || !ts.isPropertyAssignment(candidate) + ) { + return 'dynamic'; + } + const initializer = unwrap(candidate.initializer); + if (!ts.isObjectLiteralExpression(initializer)) return 'dynamic'; + + const declared: Partial> = {}; + const seen = new Set(); + for (const entry of initializer.properties) { + if (!ts.isPropertyAssignment(entry)) return 'dynamic'; + const name = staticPropertyName(entry.name); + if (name === undefined || !stateBudgetNames.has(name as AgentStateBudgetName)) return 'dynamic'; + const budgetName = name as AgentStateBudgetName; + if (seen.has(budgetName)) return 'dynamic'; + const valueNode = unwrap(entry.initializer); + if (!ts.isNumericLiteral(valueNode)) return 'dynamic'; + const value = Number(valueNode.text); + if (!Number.isSafeInteger(value) || value <= 0) return 'dynamic'; + declared[budgetName] = value; + seen.add(budgetName); + } + return { declared }; +}; + /** * Extracts the storage identity and lifetime from the conventional * `export default defineState({ ... })` declaration without evaluating it. @@ -85,6 +137,7 @@ export const extractStateDefinition = ( const input = unwrap(expression.arguments[0]!) as ts.ObjectLiteralExpression; const idNode = property(input, 'id'); const lifetimeNode = property(input, 'lifetime'); + const budgets = extractBudgets(input); const id = idNode !== undefined && ts.isStringLiteral(idNode) ? idNode.text : undefined; const lifetime = lifetimeNode !== undefined && ts.isStringLiteral(lifetimeNode) ? lifetimeNode.text : undefined; const accepted = lifetime === 'request' @@ -122,7 +175,11 @@ export const extractStateDefinition = ( }); } return deepFreeze({ - definition: { id, lifetime }, + definition: { + ...(budgets === undefined ? {} : { budgets }), + id, + lifetime, + }, diagnostics: [], }); }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index ad604c269..5ecc2f68a 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -505,7 +505,20 @@ export interface NormalizedRuntime { } /** Statically extracted conventional project state used by generated entry emitters. */ +export type AgentStateBudgetName = + | 'maxCommitMs' + | 'maxEventBytes' + | 'maxRevisions' + | 'maxStateBytes'; + +export type NormalizedStateBudgets = + | { + readonly declared: Readonly>>; + } + | 'dynamic'; + export interface NormalizedStateDefinition { + readonly budgets?: NormalizedStateBudgets; readonly id: string; readonly lifetime: 'request' | 'process' | 'workspace-durable'; readonly provenance: SourceProvenance; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 42e8507df..9c8751e2b 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -57,6 +57,7 @@ export interface DoctorHostProbe { } export interface DoctorFinding { + readonly durableState?: DoctorDurableStateReport; readonly entry?: string; readonly manifest?: string; readonly name?: string; @@ -65,6 +66,25 @@ export interface DoctorFinding { readonly version?: string; } +export interface DoctorDurableStateStore { + /** Main database plus any present `-wal` and `-shm` sidecars. */ + readonly bytes: number; + readonly file: string; + readonly mtime: string; + readonly path: string; +} + +export interface DoctorDurableStateReport { + readonly diagnostics: readonly Diagnostic[]; + readonly directory: string; + readonly findings: readonly DoctorDurableStateStore[]; + readonly status: 'known' | 'warnings'; + readonly summary: { + readonly bytes: number; + readonly stores: number; + }; +} + export interface DoctorInventory { readonly findings: readonly DoctorFinding[]; readonly status: DoctorInventoryStatus; @@ -245,6 +265,81 @@ const freezeInventory = ( status, }); +const durableStateReport = ( + directory: string, + findings: readonly DoctorDurableStateStore[], + diagnostics: readonly Diagnostic[], +): DoctorDurableStateReport => { + const frozenDiagnostics = freezeDiagnostics(diagnostics); + return Object.freeze({ + diagnostics: frozenDiagnostics, + directory, + findings: Object.freeze(findings.map((finding) => Object.freeze({ ...finding }))), + status: frozenDiagnostics.length === 0 ? 'known' : 'warnings', + summary: Object.freeze({ + bytes: findings.reduce((total, finding) => total + finding.bytes, 0), + stores: findings.length, + }), + }); +}; + +const inspectDurableState = async ( + pluginRoot: string, + target?: DoctorHost, +): Promise => { + const directory = join(pluginRoot, 'state'); + let entries: readonly string[]; + try { + entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + const diagnostics = [diagnostic( + 'AB7316', + `Durable state directory ${JSON.stringify(directory)} could not be read.`, + 'Repair directory permissions and rerun `agent-bundle doctor`; Doctor never opens or repairs state databases.', + 'warning', + target, + )]; + return durableStateReport(directory, [], diagnostics); + } + + const diagnostics: Diagnostic[] = []; + const findings: DoctorDurableStateStore[] = []; + for (const file of entries.filter((entry) => entry.endsWith('.sqlite'))) { + const path = join(directory, file); + try { + const metadata = await lstat(path); + if (!metadata.isFile()) continue; + let bytes = metadata.size; + for (const suffix of ['-wal', '-shm'] as const) { + try { + const sidecar = await lstat(`${path}${suffix}`); + if (sidecar.isFile()) bytes += sidecar.size; + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + throw error; + } + } + findings.push({ + bytes, + file, + mtime: metadata.mtime.toISOString(), + path, + }); + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + diagnostics.push(diagnostic( + 'AB7316', + `Durable state store ${JSON.stringify(path)} could not be inspected.`, + 'Repair file permissions and rerun `agent-bundle doctor`; Doctor never opens or repairs state databases.', + 'warning', + target, + )); + } + } + return durableStateReport(directory, findings, diagnostics); +}; + const probeBinary = async ( host: Exclude, cwd: string, @@ -478,7 +573,10 @@ const cursorInventory = async ( )); continue; } + const durableState = await inspectDurableState(path, 'cursor'); + if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); findings.push({ + ...(durableState === undefined ? {} : { durableState }), entry, manifest: manifest.manifest, name: manifest.name, @@ -988,7 +1086,15 @@ const doctorHost = async ( ? await claudeBundle(identity, probed.probe, run) : codexBundle(identity); diagnostics.push(...checked.diagnostics); - bundle = checked.finding; + if (checked.finding === undefined) { + throw new TypeError(`The ${host} bundle check returned no finding.`); + } + const durableState = await inspectDurableState(identity.bundleRoot, host); + if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + bundle = Object.freeze({ + ...checked.finding, + ...(durableState === undefined ? {} : { durableState }), + }); } catch (error) { const malformed = malformedBundle(host, error); diagnostics.push(...malformed.diagnostics); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 9d6f10e35..1817797c9 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -227,6 +227,111 @@ it('accepts a versionless Cursor inventory manifest as installed', async () => { } }); +it('inventories durable SQLite stores and sidecars without opening them', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'stateful'); + const stateRoot = join(pluginRoot, 'state'); + const store = 'project-tasks-0123456789abcdef.sqlite'; + try { + await Promise.all([ + writeJson(join(pluginRoot, 'plugin.json'), { name: 'stateful', version: '1.0.0' }), + mkdir(stateRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(stateRoot, store), 'database'), + writeFile(join(stateRoot, `${store}-wal`), 'wal!'), + writeFile(join(stateRoot, `${store}-shm`), 'shm'), + writeFile(join(stateRoot, 'ignore.txt'), 'ignored'), + ]); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find( + (entry) => entry.entry === 'stateful', + ); + expect(finding?.durableState).toMatchObject({ + directory: stateRoot, + findings: [{ + bytes: 15, + file: store, + mtime: expect.any(String), + path: join(stateRoot, store), + }], + status: 'known', + summary: { bytes: 15, stores: 1 }, + }); + + const human: string[] = []; + const humanCode = await runCli( + ['doctor'], + { stdout: { write: (chunk: string) => human.push(chunk) } }, + { runDoctor: async () => report }, + ); + expect(humanCode).toBe(0); + expect(human.join('')).toContain('durable state: 1 store, 15 B'); + + const json: string[] = []; + await runCli( + ['doctor', '--json'], + { stdout: { write: (chunk: string) => json.push(chunk) } }, + { runDoctor: async () => report }, + ); + expect(JSON.parse(json.join('')).hosts[0].inventory.findings[0].durableState).toMatchObject({ + findings: [{ bytes: 15, file: store }], + summary: { bytes: 15, stores: 1 }, + }); + } finally { + await fixture.cleanup(); + } +}); + +it('inventories durable state under a checked --from bundle', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'codex'); + const stateRoot = join(bundle, 'state'); + await mkdir(stateRoot); + await writeFile(join(stateRoot, 'from-bundle-fedcba9876543210.sqlite'), 'state'); + const report = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['codex'], + }); + expect(hostReport(report, 'codex').bundle?.durableState).toMatchObject({ + directory: stateRoot, + findings: [{ bytes: 5, file: 'from-bundle-fedcba9876543210.sqlite' }], + summary: { bytes: 5, stores: 1 }, + }); + } finally { + await fixture.cleanup(); + } +}); + +it('warns when an installed bundle state directory cannot be read', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'blocked-state'); + try { + await writeJson(join(pluginRoot, 'plugin.json'), { name: 'blocked-state', version: '1.0.0' }); + await writeFile(join(pluginRoot, 'state'), 'not a directory'); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7316', severity: 'warning' }), + ])); + expect(report.summary).toMatchObject({ errors: 0, warnings: 1 }); + } finally { + await fixture.cleanup(); + } +}); + it('reports a Cursor inventory manifest with a non-string version as corrupt', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts new file mode 100644 index 000000000..61a6c7d08 --- /dev/null +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -0,0 +1,186 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { runCli } from '../src/cli.ts'; + +const createProject = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-state-')); + await Promise.all([ + mkdir(join(root, 'src'), { recursive: true }), + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')), + ]); + return root; +}; + +const inspectCli = async ( + root: string, + args: readonly string[], +): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { + const stderr: string[] = []; + const stdout: string[] = []; + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + const code = await runCli( + ['inspect', '--root', root, ...args], + { + stderr: { write: (chunk: string) => stderr.push(chunk) }, + stdout: { write: (chunk: string) => stdout.push(chunk) }, + }, + ); + return { code, stderr: stderr.join(''), stdout: stdout.join('') }; +}; + +it('inspects volatile and workspace-durable state without inventing runtime paths', async () => { + const root = await createProject(); + const stateSource = join(root, 'src', 'state.ts'); + try { + await writeFile(stateSource, [ + 'export default defineState({', + " id: 'fixture/process-state',", + " lifetime: 'process',", + '});', + '', + ].join('\n')); + + const volatile = await inspectCli(root, ['--state', '--json']); + expect(volatile).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(volatile.stdout)).toMatchObject({ + selected: { + state: { + budgets: { + resolved: { + maxCommitMs: 5000, + maxEventBytes: 262144, + maxRevisions: 100000, + maxStateBytes: 1048576, + }, + source: 'defaults', + }, + declared: true, + driver: 'memory', + id: 'fixture/process-state', + lifetime: 'process', + notices: [expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1')], + provenance: { kind: 'conventional', sourcePath: stateSource }, + source: stateSource, + }, + }, + state: 'ready', + }); + expect(JSON.parse(volatile.stdout).selected.state).not.toHaveProperty('durableLocation'); + + await writeFile(stateSource, [ + 'export default defineState({', + " id: 'fixture/durable-state',", + " lifetime: 'workspace-durable',", + ' budgets: { maxStateBytes: 2048 },', + '});', + '', + ].join('\n')); + const durable = await inspectCli(root, ['--state', '--json']); + expect(durable).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(durable.stdout)).toMatchObject({ + selected: { + state: { + budgets: { + resolved: { + maxCommitMs: 5000, + maxEventBytes: 262144, + maxRevisions: 100000, + maxStateBytes: 2048, + }, + source: 'declared', + }, + declared: true, + driver: 'sqlite', + durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + id: 'fixture/durable-state', + lifetime: 'workspace-durable', + }, + }, + }); + + const humanFocus = await inspectCli(root, ['--state']); + expect(JSON.parse(humanFocus.stdout)).toMatchObject({ + declared: true, + id: 'fixture/durable-state', + }); + + const humanDefault = await inspectCli(root, []); + expect(humanDefault.stdout).toContain( + 'state: fixture/durable-state (workspace-durable, sqlite driver)', + ); + + await writeFile(stateSource, [ + 'export default defineState({', + " id: 'fixture/dynamic-state',", + " lifetime: 'request',", + ' budgets: { maxCommitMs: MAX_COMMIT_MS },', + '});', + '', + ].join('\n')); + const dynamic = await inspectCli(root, ['--state', '--json']); + expect(JSON.parse(dynamic.stdout)).toMatchObject({ + selected: { + state: { + budgets: { source: 'dynamic' }, + declared: true, + driver: 'memory', + id: 'fixture/dynamic-state', + lifetime: 'request', + }, + }, + }); + expect(JSON.parse(dynamic.stdout).selected.state.budgets).not.toHaveProperty('resolved'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports stateless inspection and rejects competing state focuses', async () => { + const root = await createProject(); + try { + const stateless = await inspectCli(root, ['--state', '--json']); + expect(stateless).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(stateless.stdout)).toMatchObject({ + selected: { state: { declared: false } }, + state: 'ready', + }); + + await writeFile(join(root, 'src', 'state.ts'), [ + 'export default defineState({', + " id: 'fixture/disabled-state',", + " lifetime: 'process',", + '});', + '', + ].join('\n')); + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + ' state: false,', + '};', + '', + ].join('\n')); + const disabled = await inspectCli(root, ['--state', '--json']); + expect(JSON.parse(disabled.stdout)).toMatchObject({ + selected: { state: { declared: false } }, + state: 'ready', + }); + + const ambiguous = await inspectCli(root, ['--state', '--routes']); + expect(ambiguous.code).toBe(1); + expect(JSON.parse(ambiguous.stderr)).toMatchObject([{ code: 'AB5000', severity: 'error' }]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/state-definition-extract.test.ts b/packages/agent-bundle/tests/state-definition-extract.test.ts index 071a4035b..530983a6a 100644 --- a/packages/agent-bundle/tests/state-definition-extract.test.ts +++ b/packages/agent-bundle/tests/state-definition-extract.test.ts @@ -22,6 +22,59 @@ describe('state definition extraction', () => { diagnostics: [], }); expect(Object.isFrozen(result)).toBe(true); + expect(result.definition?.budgets).toBeUndefined(); + }); + + it('extracts full literal budgets', () => { + const result = extract([ + 'export default defineState({', + " id: 'project/tasks',", + " lifetime: 'workspace-durable',", + ' budgets: {', + ' maxEventBytes: 262144,', + ' maxStateBytes: 1048576,', + ' maxRevisions: 100000,', + ' maxCommitMs: 5000,', + ' },', + '});', + ].join('\n')); + + expect(result.definition?.budgets).toEqual({ + declared: { + maxCommitMs: 5000, + maxEventBytes: 262144, + maxRevisions: 100000, + maxStateBytes: 1048576, + }, + }); + }); + + it('extracts partial literal budgets', () => { + const result = extract([ + 'export default defineState({', + " id: 'project/tasks',", + " lifetime: 'process',", + ' budgets: { maxRevisions: 42 },', + '});', + ].join('\n')); + + expect(result.definition?.budgets).toEqual({ declared: { maxRevisions: 42 } }); + }); + + it.each([ + ['computed value', 'budgets: { maxStateBytes: MAX_STATE_BYTES }'], + ['unknown key', 'budgets: { maxStateBytes: 1024, burst: 2 }'], + ])('marks %s budgets as dynamic', (_label, budgets) => { + const result = extract([ + 'export default defineState({', + " id: 'project/tasks',", + " lifetime: 'request',", + ` ${budgets},`, + '});', + ].join('\n')); + + expect(result.definition?.budgets).toBe('dynamic'); + expect(result.diagnostics).toEqual([]); }); it.each([ diff --git a/packages/rsc-runtime/src/state/conformance.ts b/packages/rsc-runtime/src/state/conformance.ts index 1907cd7e2..66025981b 100644 --- a/packages/rsc-runtime/src/state/conformance.ts +++ b/packages/rsc-runtime/src/state/conformance.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { z } from 'zod'; import type { + AgentStateBudgetsInput, AgentStateDefinition, AgentStateErrorCode, AgentStateEventSchemas, @@ -10,6 +11,7 @@ import type { AgentStateStore, } from './contract.js'; import { + AGENT_STATE_DEFAULT_BUDGETS, AGENT_STATE_LIFETIMES, AGENT_STATE_RESERVED_KEY_PREFIX, AgentStateError, @@ -81,8 +83,10 @@ const taskEvents = { const taskDefinition = ( lifetime: AgentStateLifetime, id = 'agent-state-conformance/tasks', + budgets?: AgentStateBudgetsInput, ): AgentStateDefinition => defineState({ + budgets, events: taskEvents, id, initial: { tasks: [], total: 0 }, @@ -128,8 +132,10 @@ const taskSchemaV2 = z const taskDefinitionV2 = ( lifetime: AgentStateLifetime, id = 'agent-state-conformance/tasks', + budgets?: AgentStateBudgetsInput, ): AgentStateDefinition => defineState({ + budgets, events: taskEvents, id, initial: { labels: [], tasks: [], total: 0 }, @@ -195,6 +201,93 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje }); }, }, + { + name: 'definitions without budgets use defaults and preserve ordinary commits', + run: async (context) => { + const definition = taskDefinition(context.lifetime); + assert.deepEqual(definition.budgets, AGENT_STATE_DEFAULT_BUDGETS); + const store = await context.open(definition); + assert.equal((await addTask(store, 'a')).revision, 1); + }, + }, + { + name: 'an over-budget event payload fails without committing', + run: async (context) => { + const store = await context.open( + taskDefinition(context.lifetime, 'agent-state-conformance/event-budget', { maxEventBytes: 10 }), + ); + await assert.rejects(addTask(store, 'a'), rejectsWith('budget-exceeded')); + assert.equal((await store.read()).revision, 0); + }, + }, + { + name: 'an over-budget reducer output fails without committing', + run: async (context) => { + const store = await context.open( + taskDefinition(context.lifetime, 'agent-state-conformance/state-budget', { maxStateBytes: 30 }), + ); + await assert.rejects(addTask(store, 'a'), rejectsWith('budget-exceeded')); + assert.deepEqual(await store.read(), { revision: 0, state: { tasks: [], total: 0 } }); + }, + }, + { + name: 'an over-budget reset seed fails without committing', + run: async (context) => { + const store = await context.open( + taskDefinition(context.lifetime, 'agent-state-conformance/reset-budget', { maxStateBytes: 30 }), + ); + await assert.rejects( + store.reset({ + idempotencyKey: 'reset:large', + seed: { tasks: [{ id: 'seed', title: 'Seeded task' }], total: 1 }, + }), + rejectsWith('budget-exceeded'), + ); + assert.deepEqual(await store.read(), { revision: 0, state: { tasks: [], total: 0 } }); + }, + }, + { + name: 'maxRevisions blocks new commits but permits replay and a raised runtime policy', + run: async (context) => { + const id = 'agent-state-conformance/revision-budget'; + const capped = await context.open(taskDefinition(context.lifetime, id, { maxRevisions: 2 })); + const first = await addTask(capped, 'a'); + await addTask(capped, 'b'); + await assert.rejects(addTask(capped, 'c'), rejectsWith('budget-exceeded')); + assert.equal((await capped.read()).revision, 2); + + const replayed = await addTask(capped, 'a'); + assert.deepEqual(replayed, { ...first, replayed: true }); + assert.equal((await capped.read()).revision, 2); + + const raised = await context.reopen(taskDefinition(context.lifetime, id, { maxRevisions: 3 })); + assert.equal((await addTask(raised, 'c')).revision, 3); + }, + }, + { + name: 'lowered budgets preserve exact reads and committed replay', + run: async (context) => { + const id = 'agent-state-conformance/lowered-budget-replay'; + const original = await context.open(taskDefinition(context.lifetime, id)); + const first = await addTask(original, 'a'); + await addTask(original, 'b'); + + const loweredEvent = await context.reopen( + taskDefinition(context.lifetime, id, { maxEventBytes: 10, maxStateBytes: 30 }), + ); + assert.deepEqual(await loweredEvent.read({ revision: 1 }), { + revision: 1, + state: first.state, + }); + + const loweredCommit = await context.reopen( + taskDefinition(context.lifetime, id, { maxCommitMs: 1, maxRevisions: 1, maxStateBytes: 30 }), + ); + const replayed = await addTask(loweredCommit, 'a'); + assert.deepEqual(replayed, { ...first, replayed: true }); + assert.equal((await loweredCommit.read()).revision, 2); + }, + }, { name: 'unknown events and invalid payloads fail typed without committing', run: async (context) => { @@ -449,6 +542,19 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje await assert.rejects(context.reopen(taskDefinition(context.lifetime)), rejectsWith('migration-missing')); }, }, + { + name: 'an over-budget migrated state fails open without committing', + run: async (context) => { + const id = 'agent-state-conformance/migration-state-budget'; + const storeV1 = await context.open(taskDefinition(context.lifetime, id)); + await addTask(storeV1, 'a'); + await assert.rejects( + context.reopen(taskDefinitionV2(context.lifetime, id, { maxStateBytes: 40 })), + rejectsWith('budget-exceeded'), + ); + assert.equal((await storeV1.read()).revision, 1); + }, + }, { name: 'replaying a key committed before a migration returns its committed result', run: async (context) => { diff --git a/packages/rsc-runtime/src/state/contract.ts b/packages/rsc-runtime/src/state/contract.ts index 1097becf7..9fc8c35d1 100644 --- a/packages/rsc-runtime/src/state/contract.ts +++ b/packages/rsc-runtime/src/state/contract.ts @@ -37,6 +37,7 @@ export const AGENT_STATE_LIFETIMES: readonly AgentStateLifetime[] = Object.freez export type AgentStateErrorCode = | 'aborted' + | 'budget-exceeded' | 'corrupt' | 'idempotency-conflict' | 'invalid-definition' @@ -114,7 +115,78 @@ export type AgentStateEvent = { */ export type AgentStateMigrations = Readonly unknown>>; +/** + * Optional runtime policy overrides for state mutations. Budgets are not + * persisted in storage metadata: reopening the same storage with different + * budgets is allowed. + */ +export interface AgentStateBudgetsInput { + /** + * Maximum wall-clock time in milliseconds from mutation validation start + * until just before commit. A slower event or reset fails + * `budget-exceeded`; raise this definition budget to admit slower commits. + */ + readonly maxCommitMs?: number; + /** + * Maximum UTF-8 bytes in a validated event payload, measured with + * `Buffer.byteLength(canonicalJson(payload), 'utf8')`. A larger payload + * fails `budget-exceeded`; raise this definition budget to admit it. + */ + readonly maxEventBytes?: number; + /** + * Maximum total journal revisions admitted for caller-initiated commits. + * The next event or reset past this cap fails `budget-exceeded`; raise this + * definition budget to retain more revisions. Kernel migrations are exempt. + */ + readonly maxRevisions?: number; + /** + * Maximum UTF-8 bytes in a committed state, measured with + * `Buffer.byteLength(canonicalJson(state), 'utf8')`. A larger initial, + * event, reset, or migrated state fails closed; raise this definition + * budget to admit it. + */ + readonly maxStateBytes?: number; +} + +/** Resolved runtime state budgets. These values are policy, not persisted metadata. */ +export interface AgentStateBudgets { + /** + * Maximum wall-clock time in milliseconds from mutation validation start + * until just before commit. A slower event or reset fails + * `budget-exceeded`; raise this definition budget to admit slower commits. + */ + readonly maxCommitMs: number; + /** + * Maximum UTF-8 bytes in a validated event payload, measured with + * `Buffer.byteLength(canonicalJson(payload), 'utf8')`. A larger payload + * fails `budget-exceeded`; raise this definition budget to admit it. + */ + readonly maxEventBytes: number; + /** + * Maximum total journal revisions admitted for caller-initiated commits. + * The next event or reset past this cap fails `budget-exceeded`; raise this + * definition budget to retain more revisions. Kernel migrations are exempt. + */ + readonly maxRevisions: number; + /** + * Maximum UTF-8 bytes in a committed state, measured with + * `Buffer.byteLength(canonicalJson(state), 'utf8')`. A larger initial, + * event, reset, or migrated state fails closed; raise this definition + * budget to admit it. + */ + readonly maxStateBytes: number; +} + +export const AGENT_STATE_DEFAULT_BUDGETS: AgentStateBudgets = Object.freeze({ + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, +}); + export interface AgentStateDefinitionInput { + /** Runtime-only mutation policy; omitted fields resolve from {@link AGENT_STATE_DEFAULT_BUDGETS}. */ + readonly budgets?: AgentStateBudgetsInput; /** Event name to payload schema; dispatch validates payloads before the reducer runs. */ readonly events: TEvents; /** Stable identity for storage naming and cross-process addressing. */ @@ -134,6 +206,8 @@ export interface AgentStateDefinition< TState = unknown, TEvents extends AgentStateEventSchemas = AgentStateEventSchemas, > { + /** Resolved runtime-only mutation policy; never persisted in storage metadata. */ + readonly budgets: AgentStateBudgets; readonly events: TEvents; readonly id: string; readonly initial: TState; @@ -157,6 +231,23 @@ const expectNonEmptyText = (value: unknown, label: string): string => { return value; }; +const resolveStateBudgets = (id: string, input: AgentStateBudgetsInput | undefined): AgentStateBudgets => { + const resolved = { + ...AGENT_STATE_DEFAULT_BUDGETS, + ...input, + }; + for (const field of ['maxCommitMs', 'maxEventBytes', 'maxRevisions', 'maxStateBytes'] as const) { + const value = resolved[field]; + if (!Number.isInteger(value) || value < 1) { + throw new AgentStateError( + 'invalid-definition', + `State '${id}' budget '${field}' must be an integer >= 1`, + ); + } + } + return Object.freeze(resolved); +}; + export const defineState = ( input: AgentStateDefinitionInput, ): AgentStateDefinition => { @@ -167,6 +258,7 @@ export const defineState = ( if (typeof input.reduce !== 'function') { throw new AgentStateError('invalid-definition', `State '${id}' requires a reduce function`); } + const budgets = resolveStateBudgets(id, input.budgets); const eventNames = Object.keys(input.events); if (eventNames.length === 0) { throw new AgentStateError('invalid-definition', `State '${id}' must declare at least one event`); @@ -209,7 +301,15 @@ export const defineState = ( if (!isJsonSafe(parsedInitial.data)) { throw new AgentStateError('invalid-definition', `State '${id}' initial state must be JSON-safe`); } + const initialBytes = Buffer.byteLength(canonicalJson(parsedInitial.data), 'utf8'); + if (initialBytes > budgets.maxStateBytes) { + throw new AgentStateError( + 'invalid-definition', + `State '${id}' initial state is ${String(initialBytes)} bytes, exceeding maxStateBytes ${String(budgets.maxStateBytes)}`, + ); + } return Object.freeze({ + budgets, events: Object.freeze({ ...input.events }) as TEvents, id, initial: deepFreezeJson(parsedInitial.data), diff --git a/packages/rsc-runtime/src/state/index.ts b/packages/rsc-runtime/src/state/index.ts index 93f2210f7..16fe1268c 100644 --- a/packages/rsc-runtime/src/state/index.ts +++ b/packages/rsc-runtime/src/state/index.ts @@ -8,6 +8,7 @@ * `node:sqlite`. */ export { + AGENT_STATE_DEFAULT_BUDGETS, AGENT_STATE_LIFETIMES, AGENT_STATE_RESERVED_KEY_PREFIX, AgentStateError, @@ -17,6 +18,8 @@ export { expectIdempotencyKey, } from './contract.js'; export type { + AgentStateBudgets, + AgentStateBudgetsInput, AgentStateChange, AgentStateChangeBatch, AgentStateChangesOptions, @@ -44,7 +47,9 @@ export { applyStateEvent, canonicalCommitInput, changeFromJournalRecord, + expectCommitWithinBudgets, expectConsistentJournal, + expectMigrationWithinStateBudget, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, diff --git a/packages/rsc-runtime/src/state/journal.ts b/packages/rsc-runtime/src/state/journal.ts index 8277f8440..f395a3dfc 100644 --- a/packages/rsc-runtime/src/state/journal.ts +++ b/packages/rsc-runtime/src/state/journal.ts @@ -71,10 +71,11 @@ export const migrationIdempotencyKey = (toVersion: number): string => * payload alone: a committed key retried after the state changed replays * the committed result, so the reducer must not run first. */ -export const parseEventPayload = ( +const parseEventPayloadInternal = ( definition: AgentStateDefinition, name: string, payload: unknown, + enforceBudget: boolean, ): unknown => { const schema = definition.events[name]; if (schema === undefined) { @@ -90,9 +91,84 @@ export const parseEventPayload = definition.budgets.maxEventBytes) { + throw new AgentStateError( + 'budget-exceeded', + `State '${definition.id}' event '${name}' payload is ${String(payloadBytes)} bytes, exceeding maxEventBytes ${String(definition.budgets.maxEventBytes)}`, + ); + } + } return deepFreezeJson(parsed.data); }; +export const parseEventPayload = ( + definition: AgentStateDefinition, + name: string, + payload: unknown, +): unknown => parseEventPayloadInternal(definition, name, payload, true); + +const expectStateWithinBudget = ( + definition: AgentStateDefinition, + stateText: string, + kind: 'event' | 'migrate' | 'reset', +): void => { + const stateBytes = Buffer.byteLength(stateText, 'utf8'); + if (stateBytes > definition.budgets.maxStateBytes) { + throw new AgentStateError( + 'budget-exceeded', + `State '${definition.id}' ${kind} state is ${String(stateBytes)} bytes, exceeding maxStateBytes ${String(definition.budgets.maxStateBytes)}`, + ); + } +}; + +/** + * Enforces caller-initiated commit budgets immediately before append. + * Drivers call this inside their atomic write boundary after state + * resolution. Already-committed idempotent replay bypasses it. Kernel + * migration records use {@link expectMigrationWithinStateBudget} instead: + * migrations enforce state bytes but are exempt from revision and time caps. + */ +export const expectCommitWithinBudgets = ( + definition: AgentStateDefinition, + input: { + readonly headRevision: number; + readonly kind: 'event' | 'reset'; + readonly nowMs: number; + readonly startedAtMs: number; + readonly stateText: string; + }, +): void => { + expectStateWithinBudget(definition, input.stateText, input.kind); + const nextRevision = input.headRevision + 1; + if (nextRevision > definition.budgets.maxRevisions) { + throw new AgentStateError( + 'budget-exceeded', + `State '${definition.id}' ${input.kind} revision ${String(nextRevision)} exceeds maxRevisions ${String(definition.budgets.maxRevisions)}`, + ); + } + const elapsedMs = input.nowMs - input.startedAtMs; + if (elapsedMs > definition.budgets.maxCommitMs) { + throw new AgentStateError( + 'budget-exceeded', + `State '${definition.id}' ${input.kind} commit took ${String(elapsedMs)}ms, exceeding maxCommitMs ${String(definition.budgets.maxCommitMs)}`, + ); + } +}; + +/** + * Enforces only maxStateBytes for a kernel-generated migration record. + * Migration commits are deliberately exempt from maxRevisions and + * maxCommitMs so reaching either caller budget cannot brick store opening. + */ +export const expectMigrationWithinStateBudget = ( + definition: AgentStateDefinition, + stateText: string, +): void => { + expectStateWithinBudget(definition, stateText, 'migrate'); +}; + /** * Runs the reducer over an already-validated payload (see * {@link parseEventPayload}) and validates its output. Throws typed @@ -211,7 +287,8 @@ export const replayJournal = ( ); } try { - state = applyStateEvent(definition, state, record.name, record.payload).state; + const payload = parseEventPayloadInternal(definition, record.name, record.payload, false); + state = reduceStateEvent(definition, state, record.name, payload); } catch (error) { throw new AgentStateError( 'corrupt', diff --git a/packages/rsc-runtime/src/state/memory-driver.ts b/packages/rsc-runtime/src/state/memory-driver.ts index a40469fe0..135cc53cc 100644 --- a/packages/rsc-runtime/src/state/memory-driver.ts +++ b/packages/rsc-runtime/src/state/memory-driver.ts @@ -23,6 +23,8 @@ import type { AgentStateJournalRecord } from './journal.js'; import { canonicalCommitInput, changeFromJournalRecord, + expectCommitWithinBudgets, + expectMigrationWithinStateBudget, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, @@ -31,6 +33,7 @@ import { runStateMigrations, } from './journal.js'; import { stateEffect } from './effect.js'; +import { canonicalJson } from './json.js'; import { createPendingOpenTracker } from './pending-opens.js'; /** @@ -156,6 +159,7 @@ const createMemoryStore = ( | { readonly kind: 'reset'; readonly state: TState } ), expectedRevision: number | undefined, + startedAtMs: number, ): AgentStateCommitResult => { const committed = internals.keys.get(input.key); if (committed !== undefined) { @@ -177,10 +181,19 @@ const createMemoryStore = ( input.kind === 'event' ? reduceStateEvent(internals.definition, internals.head.state, input.name, input.payload) : input.state; + const stateText = canonicalJson(state); + const committedAt = now(); + expectCommitWithinBudgets(internals.definition, { + headRevision: internals.head.revision, + kind: input.kind, + nowMs: committedAt.getTime(), + startedAtMs, + stateText, + }); const journalRecord: AgentStateJournalRecord = input.kind === 'event' ? { - committedAt: now().toISOString(), + committedAt: committedAt.toISOString(), idempotencyKey: input.key, kind: 'event', name: input.name, @@ -188,7 +201,7 @@ const createMemoryStore = ( revision: internals.head.revision + 1, } : { - committedAt: now().toISOString(), + committedAt: committedAt.toISOString(), idempotencyKey: input.key, kind: 'reset', revision: internals.head.revision + 1, @@ -237,6 +250,7 @@ const createMemoryStore = ( return runStore( stateEffect(() => { expectOperable(internals.closed, internals.definition.id, options.signal); + const startedAtMs = now().getTime(); const key = expectIdempotencyKey(options.idempotencyKey); expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); // Payload validation only — the reducer runs inside `commit`, after @@ -250,7 +264,11 @@ const createMemoryStore = ( payload: parsed, revision: 0, }); - return commit({ canonicalInput, key, kind: 'event', name, payload: parsed }, options.expectedRevision); + return commit( + { canonicalInput, key, kind: 'event', name, payload: parsed }, + options.expectedRevision, + startedAtMs, + ); }), ); }, @@ -281,6 +299,7 @@ const createMemoryStore = ( return runStore( stateEffect(() => { expectOperable(internals.closed, internals.definition.id, options.signal); + const startedAtMs = now().getTime(); const key = expectIdempotencyKey(options.idempotencyKey); expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); const state = resolveResetState(internals.definition, options.seed); @@ -291,7 +310,11 @@ const createMemoryStore = ( revision: 0, state, }); - return commit({ canonicalInput, key, kind: 'reset', state }, options.expectedRevision); + return commit( + { canonicalInput, key, kind: 'reset', state }, + options.expectedRevision, + startedAtMs, + ); }), ); }, @@ -311,6 +334,8 @@ const migrateOpenStore = ( ): void => { const fromVersion = internals.definition.version; const migrated = runStateMigrations(definition, fromVersion, internals.head.state); + const migratedStateText = canonicalJson(migrated); + expectMigrationWithinStateBudget(definition, migratedStateText); const record: AgentStateJournalRecord = { committedAt: now().toISOString(), idempotencyKey: migrationIdempotencyKey(definition.version), @@ -403,6 +428,9 @@ export const createMemoryStateDriver = (options: MemoryStateDriverOptions = {}): } if (definition.version !== existing.internals.definition.version) { migrateOpenStore(existing.internals, definition, now); + } else { + // Budgets are runtime policy, not persisted metadata. + existing.internals.definition = definition; } return existing; } diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index 4bd2d36bb..bfe172cc1 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -45,8 +45,10 @@ import { changeFromJournalRecord, deepFreezeJson, describeSchemaIssues, + expectCommitWithinBudgets, expectConsistentJournal, expectIdempotencyKey, + expectMigrationWithinStateBudget, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, @@ -393,8 +395,12 @@ class SqliteStore implements Age return replayJournal(this.#definition, this.#journalRecords(db, revision), revision); } - #appendRecord(db: DatabaseSync, record: AgentStateJournalRecord, state: TState): AgentStateCommitResult { - const stateText = canonicalJson(state); + #appendRecord( + db: DatabaseSync, + record: AgentStateJournalRecord, + state: TState, + stateText: string, + ): AgentStateCommitResult { db .prepare( 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, result_state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', @@ -423,8 +429,8 @@ class SqliteStore implements Age options: AgentStateDispatchOptions | AgentStateResetOptions, ): Effect.Effect, AgentStateError, SqliteConnection> { const definition = this.#definition; - const appendRecord = (db: DatabaseSync, record: AgentStateJournalRecord, state: TState) => - this.#appendRecord(db, record, state); + const appendRecord = (db: DatabaseSync, record: AgentStateJournalRecord, state: TState, stateText: string) => + this.#appendRecord(db, record, state, stateText); const committedByKey = (db: DatabaseSync, key: string) => this.#committedByKey(db, key); const committedState = ( db: DatabaseSync, @@ -439,34 +445,39 @@ class SqliteStore implements Age type PreparedCommit = | { readonly canonicalInput: string; readonly kind: 'event'; readonly name: string; readonly payload: unknown } | { readonly canonicalInput: string; readonly kind: 'reset'; readonly state: TState }; - const validate = sqliteEffect(definition.id, 'validate commit', (): { key: string; prepared: PreparedCommit } => { - expectOperable(this.#closed, definition.id, options.signal); - expectRevisionShape(options.expectedRevision, `State '${definition.id}' expectedRevision`); - const key = expectIdempotencyKey(options.idempotencyKey); - if (input.kind === 'event') { - const payload = parseEventPayload(definition, input.name, input.rawPayload); + const validate = sqliteEffect( + definition.id, + 'validate commit', + (): { key: string; prepared: PreparedCommit; startedAtMs: number } => { + expectOperable(this.#closed, definition.id, options.signal); + const startedAtMs = now().getTime(); + expectRevisionShape(options.expectedRevision, `State '${definition.id}' expectedRevision`); + const key = expectIdempotencyKey(options.idempotencyKey); + if (input.kind === 'event') { + const payload = parseEventPayload(definition, input.name, input.rawPayload); + const canonicalInput = canonicalCommitInput({ + committedAt: '', + idempotencyKey: key, + kind: 'event', + name: input.name, + payload, + revision: 0, + }); + return { key, prepared: { canonicalInput, kind: 'event', name: input.name, payload }, startedAtMs }; + } + const state = resolveResetState(definition, input.seed); const canonicalInput = canonicalCommitInput({ committedAt: '', idempotencyKey: key, - kind: 'event', - name: input.name, - payload, + kind: 'reset', revision: 0, + state, }); - return { key, prepared: { canonicalInput, kind: 'event', name: input.name, payload } }; - } - const state = resolveResetState(definition, input.seed); - const canonicalInput = canonicalCommitInput({ - committedAt: '', - idempotencyKey: key, - kind: 'reset', - revision: 0, - state, - }); - return { key, prepared: { canonicalInput, kind: 'reset', state } }; - }); + return { key, prepared: { canonicalInput, kind: 'reset', state }, startedAtMs }; + }, + ); return Effect.gen(function*() { - const { key, prepared } = yield* validate; + const { key, prepared, startedAtMs } = yield* validate; return yield* transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', (db) => { const committed = committedByKey(db, key); if (committed !== undefined) { @@ -489,14 +500,22 @@ class SqliteStore implements Age `State '${definition.id}' expected revision ${String(options.expectedRevision)} but the head is ${String(head.revision)}`, ); } - const committedAt = now().toISOString(); switch (prepared.kind) { case 'event': { const state = reduceStateEvent(definition, head.state, prepared.name, prepared.payload); + const stateText = canonicalJson(state); + const committedAt = now(); + expectCommitWithinBudgets(definition, { + headRevision: head.revision, + kind: 'event', + nowMs: committedAt.getTime(), + startedAtMs, + stateText, + }); return appendRecord( db, { - committedAt, + committedAt: committedAt.toISOString(), idempotencyKey: key, kind: 'event', name: prepared.name, @@ -504,14 +523,32 @@ class SqliteStore implements Age revision: head.revision + 1, }, state, + stateText, ); } - case 'reset': + case 'reset': { + const stateText = canonicalJson(prepared.state); + const committedAt = now(); + expectCommitWithinBudgets(definition, { + headRevision: head.revision, + kind: 'reset', + nowMs: committedAt.getTime(), + startedAtMs, + stateText, + }); return appendRecord( db, - { committedAt, idempotencyKey: key, kind: 'reset', revision: head.revision + 1, state: prepared.state }, + { + committedAt: committedAt.toISOString(), + idempotencyKey: key, + kind: 'reset', + revision: head.revision + 1, + state: prepared.state, + }, prepared.state, + stateText, ); + } default: { const unreachable: never = prepared; throw new AgentStateError('invalid-input', `Unknown commit kind ${String(unreachable)}`); @@ -696,6 +733,8 @@ class SqliteStore implements Age } } const migrated = runStateMigrations(definition, meta.schema_version, rawHead); + const migratedStateText = canonicalJson(migrated); + expectMigrationWithinStateBudget(definition, migratedStateText); // Journal records retain the original commit input for dedupe. Their // committed results migrate separately, matching the memory driver's // `{ record, state }` split. A legacy journal-head result can be @@ -728,7 +767,7 @@ class SqliteStore implements Age state: migrated, toVersion: definition.version, }; - this.#appendRecord(transactionDb, record, migrated); + this.#appendRecord(transactionDb, record, migrated, migratedStateText); transactionDb.prepare('UPDATE agent_state_meta SET schema_version = ? WHERE id = 1').run(definition.version); }); return this.#runtime.run( diff --git a/packages/rsc-runtime/tests/state-kernel.test.ts b/packages/rsc-runtime/tests/state-kernel.test.ts index f760aaa59..615207e28 100644 --- a/packages/rsc-runtime/tests/state-kernel.test.ts +++ b/packages/rsc-runtime/tests/state-kernel.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { agent, runAgentRequest } from '../src/index.js'; import { + AGENT_STATE_DEFAULT_BUDGETS, AGENT_STATE_LIFETIMES, AgentStateError, agentStateLifetimeIsVolatile, @@ -115,11 +116,72 @@ describe('defineState', () => { it('freezes the definition and its parsed initial state', () => { const definition = counterDefinition(); expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.budgets)).toBe(true); expect(Object.isFrozen(definition.initial)).toBe(true); + expect(definition.budgets).toEqual(AGENT_STATE_DEFAULT_BUDGETS); expect(definition.version).toBe(1); expect(definition.migrations).toEqual({}); }); + it('validates, resolves, and freezes runtime budgets', () => { + const definition = defineState({ + events: counterEvents, + id: 'state-kernel-test/budgets', + initial: { count: 0 }, + lifetime: 'process', + budgets: { maxCommitMs: 25, maxEventBytes: 64 }, + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }); + + expect(definition.budgets).toEqual({ + ...AGENT_STATE_DEFAULT_BUDGETS, + maxCommitMs: 25, + maxEventBytes: 64, + }); + expect(Object.isFrozen(definition.budgets)).toBe(true); + + for (const [field, value] of [ + ['maxEventBytes', 0], + ['maxStateBytes', -1], + ['maxRevisions', 1.5], + ['maxCommitMs', Number.POSITIVE_INFINITY], + ] as const) { + expect(() => + defineState({ + events: counterEvents, + id: 'state-kernel-test/invalid-budget', + initial: { count: 0 }, + lifetime: 'process', + budgets: { [field]: value }, + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }), + ).toThrow(expect.objectContaining({ + code: 'invalid-definition', + message: expect.stringContaining(field), + })); + } + }); + + it('rejects an initial state over maxStateBytes without exposing its contents', () => { + const confidential = 'initial-state-secret'; + expect(() => + defineState({ + events: { replaced: z.object({ value: z.string() }).strict() }, + id: 'state-kernel-test/initial-budget', + initial: { value: confidential }, + lifetime: 'process', + budgets: { maxStateBytes: 8 }, + reduce: (_state, event) => ({ value: event.payload.value }), + schema: z.object({ value: z.string() }).strict(), + }), + ).toThrow(expect.objectContaining({ + code: 'invalid-definition', + message: expect.not.stringContaining(confidential), + })); + }); + it('schema issues in errors name paths and codes, never rejected values', () => { const definition = defineState({ events: { noted: z.object({ secret: z.string().max(3) }).strict() }, @@ -206,6 +268,33 @@ describe('createMemoryStateDriver', () => { expect((await store.read()).revision).toBe(0); }); + it('fails a commit that exceeds maxCommitMs without changing the head', async () => { + let nowMs = 0; + const driver = createMemoryStateDriver({ + now: () => { + const current = new Date(nowMs); + nowMs += 11; + return current; + }, + }); + const definition = defineState({ + events: counterEvents, + id: 'state-kernel-test/commit-time-budget', + initial: { count: 0 }, + lifetime: 'process', + budgets: { maxCommitMs: 10 }, + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }); + const store = await driver.open(definition); + + await expect( + store.dispatch('incremented', { by: 1 }, { idempotencyKey: 'slow' }), + ).rejects.toMatchObject({ code: 'budget-exceeded', name: 'AgentStateError' }); + await expect(store.read()).resolves.toEqual({ revision: 0, state: { count: 0 } }); + await driver.close(); + }); + it('driver close closes every registered store', async () => { const driver = createMemoryStateDriver(); const store = await driver.open(counterDefinition()); @@ -343,6 +432,34 @@ describe('explicit migrations', () => { expect((await again.read()).revision).toBe(2); }); + it('exempts kernel migration commits from revision and time budgets', async () => { + let nowMs = 0; + const driver = createMemoryStateDriver({ + now: () => { + const current = new Date(nowMs); + nowMs += 10_000; + return current; + }, + }); + const definitionV1 = defineState({ + ...v1(), + budgets: { maxCommitMs: 20_000, maxRevisions: 1 }, + }); + const storeV1 = await driver.open(definitionV1); + await storeV1.dispatch('incremented', { by: 1 }, { idempotencyKey: 'i1' }); + const definitionV2 = defineState({ + ...v2(), + budgets: { maxCommitMs: 1, maxRevisions: 1 }, + }); + + await expect(driver.open(definitionV2)).resolves.toBeDefined(); + await expect(storeV1.read()).resolves.toEqual({ + revision: 2, + state: { count: 1, unit: 'edits' }, + }); + await driver.close(); + }); + it('fails closed when a migration step throws or produces invalid state', async () => { const throwingDriver = createMemoryStateDriver(); await throwingDriver.open(v1()); diff --git a/packages/rsc-runtime/tests/state-sqlite.test.ts b/packages/rsc-runtime/tests/state-sqlite.test.ts index 2d4d86ff7..93075199d 100644 --- a/packages/rsc-runtime/tests/state-sqlite.test.ts +++ b/packages/rsc-runtime/tests/state-sqlite.test.ts @@ -409,6 +409,66 @@ describe('sqlite driver storage behavior', () => { await driver.close(); })); + it('rolls back a commit that exceeds maxCommitMs', () => + withRoot(async (root) => { + let nowMs = 0; + const driver = createSqliteStateDriver({ + now: () => { + const current = new Date(nowMs); + nowMs += 11; + return current; + }, + root, + }); + const definition = defineState({ + events: counterEvents, + id: 'state-sqlite-test/commit-time-budget', + initial: { count: 0 }, + lifetime: 'workspace-durable', + budgets: { maxCommitMs: 10 }, + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number().int() }).strict(), + }); + const store = await driver.open(definition); + + await expect( + store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'slow' }), + ).rejects.toMatchObject({ code: 'budget-exceeded', name: 'AgentStateError' }); + await expect(store.read()).resolves.toEqual({ revision: 0, state: { count: 0 } }); + await driver.close(); + })); + + it('exempts kernel migration commits from revision and time budgets', () => + withRoot(async (root) => { + const file = join(root, 'migration-budget.sqlite'); + const definitionV1 = defineState({ + ...counterDefinition('state-sqlite-test/migration-budget'), + budgets: { maxRevisions: 1 }, + }); + const driverV1 = createSqliteStateDriver({ file }); + const storeV1 = await driverV1.open(definitionV1); + await storeV1.dispatch('bumped', { by: 1 }, { idempotencyKey: 'i1' }); + await driverV1.close(); + + let nowMs = 0; + const definitionV2 = defineState({ + ...migratingCounterDefinition('state-sqlite-test/migration-budget'), + budgets: { maxCommitMs: 1, maxRevisions: 1 }, + }); + const driverV2 = createSqliteStateDriver({ + file, + now: () => { + const current = new Date(nowMs); + nowMs += 10_000; + return current; + }, + }); + + const migrated = await driverV2.open(definitionV2); + await expect(migrated.read()).resolves.toEqual({ revision: 2, state: { count: 10 } }); + await driverV2.close(); + })); + it('surfaces database close failures when the store scope otherwise succeeds', () => withRoot(async (root) => { const closeFailure = new Error('database close failed');