From a4d7a750bf3a91230b858cdd256efc8d138f99d7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 23:42:28 +0000 Subject: [PATCH 1/3] feat(runtime): mount state kernel and notice ledger into generated request scopes (#233) --- docs/diagnostics.md | 5 +- docs/entry-conventions.md | 25 +- packages/agent-bundle/src/build/build.ts | 8 +- packages/agent-bundle/src/build/entries.ts | 20 +- .../agent-bundle/src/build/entry-shell.ts | 95 +++++++- .../agent-bundle/src/build/inspect-bundler.ts | 2 + .../agent-bundle/src/build/package-build.ts | 7 +- packages/agent-bundle/src/config/discover.ts | 34 ++- packages/agent-bundle/src/config/normalize.ts | 9 + .../agent-bundle/src/config/state-extract.ts | 115 +++++++++ packages/agent-bundle/src/config/validate.ts | 8 + .../agent-bundle/src/core/project-context.ts | 1 + packages/agent-bundle/src/core/types.ts | 13 ++ .../agent-bundle/src/mcp-server-runtime.ts | 7 + packages/agent-bundle/src/test/mcp.ts | 73 ++++-- packages/agent-bundle/tests/config.test.ts | 32 +++ .../agent-bundle/tests/entry-shell.test.ts | 96 ++++++++ .../tests/projection/mcp-in-memory.test.ts | 35 +++ .../tests/state-definition-extract.test.ts | 50 ++++ packages/rsc-runtime/package.json | 4 + packages/rsc-runtime/rslib.config.ts | 15 ++ packages/rsc-runtime/src/mount/index.ts | 221 ++++++++++++++++++ packages/rsc-runtime/src/warm-runtime.ts | 8 +- packages/rsc-runtime/tests/mount.test.ts | 129 ++++++++++ .../rsc-runtime/tests/state-packaging.test.ts | 8 + .../rsc-runtime/tests/warm-runtime.test.ts | 19 ++ 26 files changed, 1004 insertions(+), 35 deletions(-) create mode 100644 packages/agent-bundle/src/config/state-extract.ts create mode 100644 packages/agent-bundle/tests/state-definition-extract.test.ts create mode 100644 packages/rsc-runtime/src/mount/index.ts create mode 100644 packages/rsc-runtime/tests/mount.test.ts diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 812212e32..7570a88f9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -120,7 +120,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 (`AB4800`–`AB4817`) +## Route graph and state convention (`AB4800`–`AB4820`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -230,6 +230,9 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. | | `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. | | `AB4817` | error | An event route requires the shared runtime for a target, but no generated MCP entry hosts that runtime and the route does not allow standalone fallback. | +| `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. | ## Development package build (`AB7103`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 012ef96fd..aff4e38e6 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -62,8 +62,29 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | - -Conventions match `.ts` and `.tsx` files exactly. +| `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | + +Route and package entry conventions match `.ts` and `.tsx` files exactly; +the state convention is specifically `src/state.ts`. + +### Generated state mounting + +The compiler parses `src/state.ts` without executing it and requires one +`export default defineState({ ... })` call whose `id` and `lifetime` are +string literals. Generated mounting currently supports `request`, `process`, +and `workspace-durable`; `external` remains embedder-owned driver wiring. +Volatile lifetimes use the memory driver. Request lifetime opens and releases +fresh project and notice stores per invocation; process lifetime shares them +for the generated worker or executable process. + +Workspace-durable generated MCP workers store under +`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent, +the worker derives the artifact root from the parent of its own `mcp/` +directory. Routed CLI bins and rendered scripts use +`$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise +`$PWD/.agent-bundle/state`. Notice authorization is deliberately permissive +in generated mounting v1 (`authorized`); recipient/principal matching remains +enforced by the ledger, while application authorization policy is deferred. ### Migration nudges diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index c1cd54b5c..7a42afe22 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -360,7 +360,12 @@ export const build = async (options: BuildOptions): Promise => { compiledEntries.push( ...(await compileEntries( options.model.scripts.filter((script) => script.targets.includes(target.name)), - { cwd: options.projectRoot, outDir: target.root, ...tools }, + { + cwd: options.projectRoot, + outDir: target.root, + ...(options.model.state === undefined ? {} : { state: options.model.state }), + ...tools, + }, )), ); compiledHooks.push(...(await compileHooks(target.hookEntries, { @@ -378,6 +383,7 @@ export const build = async (options: BuildOptions): Promise => { .map((entry) => entry.hook), outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, + ...(options.model.state === undefined ? {} : { state: options.model.state }), target: target.name, ...tools, }))); diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 33fe42242..0c8a69539 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -9,7 +9,13 @@ import { eventProjectRuntimeSpecifier, type TargetHookEntry, } from '../adapters/hook-contract.ts'; -import type { AgentBundleToolsConfig, NormalizedHook, NormalizedMcpServer, NormalizedScript } from '../core/types.ts'; +import type { + AgentBundleToolsConfig, + NormalizedHook, + NormalizedMcpServer, + NormalizedScript, + NormalizedStateDefinition, +} from '../core/types.ts'; import { mcpEntryAliasPattern } from '../config/normalize.ts'; import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; @@ -148,7 +154,12 @@ export const planCompiledEntries = ( export const compileEntries = async ( entries: readonly NormalizedScript[], - options: { readonly cwd: string; readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, + options: { + readonly cwd: string; + readonly outDir: string; + readonly state?: NormalizedStateDefinition; + readonly tools?: AgentBundleToolsConfig; + }, ): Promise => { const compiled = planCompiledEntries(entries, options); const bundled = compiled.filter((entry) => entry.mode === 'bundle'); @@ -174,6 +185,7 @@ export const compileEntries = async ( virtualSource: generatedRenderedScriptEntrySource({ name, routeId: rendered.routeId, + ...(options.state === undefined ? {} : { state: options.state }), workerFile: rendered.workerFile, }), }), @@ -192,6 +204,7 @@ export const compileEntries = async ( provenance: { kind: 'conventional', relativePath: `scripts/${name}` }, source, }], + ...(options.state === undefined ? {} : { state: options.state }), }), }), ]; @@ -296,6 +309,7 @@ export const compileMcpEntries = async ( readonly eventHooks: readonly NormalizedHook[]; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; + readonly state?: NormalizedStateDefinition; readonly target: string; readonly tools?: AgentBundleToolsConfig; }, @@ -331,6 +345,7 @@ export const compileMcpEntries = async ( plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, + ...(options.state === undefined ? {} : { state: options.state }), target: options.target, workerFile: `${entry.name}-flight.mjs`, }); @@ -344,6 +359,7 @@ export const compileMcpEntries = async ( eventRoutes: entry.id === eventHostId ? options.eventHooks : [], routes: server.generatedRoutes, serverName: server.name, + ...(options.state === undefined ? {} : { state: options.state }), }); }); // Factory-exporting entries (default export) are wrapped in the framework diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 663cad380..81c6bd14c 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; import { stableJson } from '../core/digest.ts'; -import type { NormalizedHook } from '../core/types.ts'; +import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; /** @@ -109,10 +109,52 @@ export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; + readonly state?: NormalizedStateDefinition; /** The sibling react-server worker bundle; required when any command is rendered. */ readonly workerFile?: string; } +type GeneratedStateFallback = 'artifact' | 'cwd'; + +const generatedStateImports = ( + state: NormalizedStateDefinition | undefined, + fallback: GeneratedStateFallback, +): readonly string[] => { + if (state === undefined) return []; + return [ + ...(state.lifetime === 'workspace-durable' + ? [ + "import { join } from 'node:path';", + ...(fallback === 'artifact' ? ["import { fileURLToPath } from 'node:url';"] : []), + "import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';", + ] + : ["import { createMemoryStateDriver } from '@agent-bundle/runtime/state';"]), + "import { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';", + `import stateDefinition from ${JSON.stringify(state.source)};`, + ]; +}; + +const generatedStateOwner = ( + state: NormalizedStateDefinition | undefined, + fallback: GeneratedStateFallback, +): readonly string[] => { + if (state === undefined) return []; + if (state.lifetime !== 'workspace-durable') { + return [ + `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createMemoryStateDriver({ lifetime: ${JSON.stringify(state.lifetime)} }) });`, + '', + ]; + } + const fallbackExpression = fallback === 'artifact' + ? "fileURLToPath(new URL('..', import.meta.url))" + : "join(process.cwd(), '.agent-bundle')"; + return [ + `const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? ${fallbackExpression};`, + "const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }) });", + '', + ]; +}; + /** * The worker-backed render-session factory shared by generated CLI * executables and rendered scripts: one worker per rendered invocation, raw @@ -192,8 +234,10 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ? "import { available, createAgentRenderDispatcher, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), + ...generatedStateImports(options.state, 'cwd'), ...routeImports(commandRoutes), '', + ...generatedStateOwner(options.state, 'cwd'), 'const routes = Object.freeze({', ...commandRoutes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), @@ -214,7 +258,10 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');", ' const parsed = parseInput(route, input);', ' const cwd = process.cwd();', - ' const result = await runAgentRequest({', + ...(options.state === undefined + ? [] + : [' const bindings = await runtimeState.requestBindings({ signal: context.signal });', ' try {']), + `${options.state === undefined ? ' ' : ' '}const result = await runAgentRequest({`, ' capabilities: {', ' command: unavailable(),', ' filesystem: unavailable(),', @@ -223,10 +270,15 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ' },', " host: unavailable('unsupported-surface'),", " invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", + ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' signal: context.signal,', + ...(options.state === undefined ? [] : [' state: bindings.state,']), " workspace: available({ root: cwd }, 'derived'),", - ' }, async () => route.module.default({ input: parsed, signal: context.signal }));', - ' return route.module.resultSchema.parse(result);', + ` }, async () => route.module.default({ input: parsed, signal: context.signal }));`, + `${options.state === undefined ? ' ' : ' '}return route.module.resultSchema.parse(result);`, + ...(options.state === undefined + ? [] + : [' } finally {', ' await bindings.close();', ' }']), '};', '', ...(rendered @@ -248,7 +300,8 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', ] : []), - 'await runGeneratedCliProcess({', + ...(options.state === undefined ? [] : ['try {']), + `${options.state === undefined ? '' : ' '}await runGeneratedCliProcess({`, ' commands,', ...(options.plugin.description === undefined ? [] : [` description: ${JSON.stringify(options.plugin.description)},`]), ' execute,', @@ -256,12 +309,16 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...(rendered ? [' render,'] : []), ` version: ${JSON.stringify(options.plugin.version)},`, '});', + ...(options.state === undefined + ? [] + : ['} finally {', ' await runtimeState.close();', '}']), '', ].join('\n'); }; export interface GeneratedRenderedRouteWorkerOptions { readonly routes: readonly CompiledAgentRoute[]; + readonly state?: NormalizedStateDefinition; } /** @@ -277,8 +334,10 @@ export const generatedRenderedRouteWorkerSource = ( "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...generatedStateImports(options.state, 'cwd'), ...routeImports(options.routes), '', + ...generatedStateOwner(options.state, 'cwd'), '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", @@ -297,6 +356,10 @@ export const generatedRenderedRouteWorkerSource = ( ' requests.set(message.id, controller);', ' try {', ' const cwd = process.cwd();', + ...(options.state === undefined + ? [] + : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), + ...(options.state === undefined ? [] : [' try {']), ' await runAgentRequest({', ' capabilities: {', ' command: unavailable(),', @@ -306,8 +369,10 @@ export const generatedRenderedRouteWorkerSource = ( ' },', " host: unavailable('unsupported-surface'),", ' invocation: message.request,', + ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", ' signal: controller.signal,', + ...(options.state === undefined ? [] : [' state: bindings.state,']), " workspace: available({ root: cwd }, 'derived'),", ' }, async () => {', ' const flight = renderAgentFlight(createElement(route.module.default, { ...message.props, signal: controller.signal }), { signal: controller.signal });', @@ -319,6 +384,9 @@ export const generatedRenderedRouteWorkerSource = ( " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", ' }', ' });', + ...(options.state === undefined + ? [] + : [' } finally {', ' await bindings.close();', ' }']), " parentPort.postMessage({ id: message.id, type: 'end' });", ' } catch (error) {', " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", @@ -337,6 +405,7 @@ export const generatedRenderedRouteWorkerSource = ( export interface GeneratedRenderedScriptEntryOptions { readonly name: string; readonly routeId: string; + readonly state?: NormalizedStateDefinition; readonly workerFile: string; } @@ -376,6 +445,7 @@ export interface GeneratedRouteMcpEntryOptions { readonly plugin: { readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; + readonly state?: NormalizedStateDefinition; readonly target?: string; readonly workerFile: string; } @@ -385,6 +455,7 @@ export interface GeneratedRouteFlightWorkerOptions { readonly eventRoutes?: readonly NormalizedHook[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; + readonly state?: NormalizedStateDefinition; } export const generatedRouteArtifactEpoch = (plugin: { @@ -426,6 +497,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { runAgentRequest } from '@agent-bundle/runtime';", + ...generatedStateImports(options.state, 'artifact'), ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), '', @@ -435,6 +507,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'process.stdout.write = process.stderr.write.bind(process.stderr);', `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', + ...generatedStateOwner(options.state, 'artifact'), 'const routes = Object.freeze({', ...routeRecords(routes), ...eventRouteRecords(eventRoutes, routes.length), @@ -453,13 +526,20 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ' requests.set(message.id, controller);', ' processLifetime.hits += 1;', ' try {', + ...(options.state === undefined + ? [] + : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']), ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', - ' invocation: { artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', + ' ...(message.host === undefined ? {} : { host: message.host }),', + ' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', + ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', ' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },', ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', + ...(options.state === undefined ? [] : [' state: bindings.state,']), + ' ...(message.workspace === undefined ? {} : { workspace: message.workspace }),', ' }, async () => {', " const props = message.invocation.kind === 'event'", ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', @@ -468,6 +548,9 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ' return new Uint8Array(await new Response(flight).arrayBuffer());', ' });', ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', + ...(options.state === undefined + ? [] + : [' } finally {', ' await bindings.close();', ' }']), ' } catch (error) {', " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", ' } finally {', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 2aee65d7d..3b0d6b9c1 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -173,6 +173,7 @@ const mcpEntryEntries = async ( plugin: { name: model.metadata.name, version: model.metadata.version }, routes: generatedRoutes, serverName, + ...(model.state === undefined ? {} : { state: model.state }), workerFile, }); entries.push(rslibInspectionEntry({ @@ -223,6 +224,7 @@ const mcpEntryEntries = async ( artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), routes: generatedRoutes, serverName, + ...(model.state === undefined ? {} : { state: model.state }), }), }, kind: 'mcp-entry', diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index b7c1fdd6e..39e4f7f97 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -113,6 +113,7 @@ export const planPackageEntries = async ( const sourceInputs = Object.freeze([...new Set([ bin.provenance.sourcePath, ...bin.generatedCli.routes.map((route) => route.source), + ...(model.state === undefined ? [] : [model.state.source]), ])]); entries.push({ aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, @@ -131,6 +132,7 @@ export const planPackageEntries = async ( version: model.metadata.version, }, routes: bin.generatedCli.routes, + ...(model.state === undefined ? {} : { state: model.state }), ...(rendered ? { workerFile } : {}), }), }); @@ -145,7 +147,10 @@ export const planPackageEntries = async ( rscManifest: true, source: bin.source, sourceInputs, - virtualSource: generatedRenderedRouteWorkerSource({ routes: renderedRoutes }), + virtualSource: generatedRenderedRouteWorkerSource({ + routes: renderedRoutes, + ...(model.state === undefined ? {} : { state: model.state }), + }), }); } continue; diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 0faf52d62..3603d517c 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -1,4 +1,4 @@ -import { stat } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { basename, dirname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; @@ -13,6 +13,9 @@ import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseRule, type RuleDocument } from './rule.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; +import { extractStateDefinition } from './state-extract.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import type { NormalizedStateDefinition } from '../core/types.ts'; /** A skill directory is identified by SKILL.md or a rendered-skill source module. */ const isSkillDocumentName = (name: string): boolean => @@ -60,8 +63,35 @@ export interface DiscoveredProject { */ shadowedConventionalSkills?: readonly string[]; skills: SkillDocument[]; + /** Conventional src/state.ts declaration and its parse-only diagnostics. */ + state?: { + readonly definition?: Pick; + readonly diagnostics: readonly Diagnostic[]; + readonly source: string; + }; } +const discoverState = async ( + projectRoot: string, + config: Readonly, +): Promise => { + if (config.state === false) return undefined; + const source = resolve(projectRoot, 'src', 'state.ts'); + let moduleText: string; + try { + moduleText = await readFile(source, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + const extracted = extractStateDefinition(moduleText, 'src/state.ts', source); + return { + ...(extracted.definition === undefined ? {} : { definition: extracted.definition }), + diagnostics: extracted.diagnostics, + source, + }; +}; + /** Expands one configured skills entry: literal paths stay literal, globs match skill directories or SKILL.md files. */ const expandConfiguredSkill = async (projectRoot: string, skill: string): Promise => { if (!fastGlob.isDynamicPattern(skill)) return [resolve(projectRoot, skill)]; @@ -264,6 +294,7 @@ export const discoverProject = async ( .filter((source) => !isProjectPathIgnored(rules, projectRoot, source)) .sort((left, right) => left.localeCompare(right)); const discoveredRules = await Promise.all(ruleSources.map((source) => parseRule(source))); + const state = await discoverState(projectRoot, config); return { assets: await discoverAssets(projectRoot, config.assets, rules), ...(discoveredCommands.length === 0 ? {} : { commands: discoveredCommands }), @@ -274,5 +305,6 @@ export const discoverProject = async ( skills: await Promise.all( skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), ), + ...(state === undefined ? {} : { state }), }; }; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 07fe47409..c6f873c0b 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -49,6 +49,7 @@ import type { NormalizedRule, NormalizedScript, NormalizedSkill, + NormalizedStateDefinition, SourceProvenance, } from '../core/types.ts'; import type { CompiledCliSurface } from '../routes/types.ts'; @@ -1010,6 +1011,13 @@ export const normalizeProject = async ( const assets = normalizeAssets(loaded, discovered, targetNames); const commands = normalizeCommands(discovered, targetNames); const rules = normalizeRules(discovered, targetNames); + const state: NormalizedStateDefinition | undefined = discovered.state?.definition === undefined + ? undefined + : { + ...discovered.state.definition, + provenance: { kind: 'conventional', sourcePath: discovered.state.source }, + source: discovered.state.source, + }; const packageBuild = normalizePackageBuild( loaded.config, loaded.context.projectRoot, @@ -1040,6 +1048,7 @@ export const normalizeProject = async ( runtime: normalizeRuntime(loaded), scripts, skills, + ...(state === undefined ? {} : { state }), targets: targetNames.map((name) => ({ id: `target:${name}`, name, diff --git a/packages/agent-bundle/src/config/state-extract.ts b/packages/agent-bundle/src/config/state-extract.ts new file mode 100644 index 000000000..16351e928 --- /dev/null +++ b/packages/agent-bundle/src/config/state-extract.ts @@ -0,0 +1,115 @@ +// The workspace compiler is TypeScript 7, while this parse-only alias keeps +// the stable single-file compiler API used by the other static extractors. +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'; + +export interface ExtractedStateDefinition { + readonly definition?: Pick; + readonly diagnostics: readonly Diagnostic[]; +} + +const diagnostic = ( + code: 'AB4818' | 'AB4819' | 'AB4820', + message: string, + recovery: string, + sourcePath: string, +): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); + +const unwrap = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isSatisfiesExpression(current) + || ts.isNonNullExpression(current) + || ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const property = ( + object: ts.ObjectLiteralExpression, + name: 'id' | 'lifetime', +): ts.Expression | undefined => { + const matches = object.properties.filter((candidate): candidate is ts.PropertyAssignment => + ts.isPropertyAssignment(candidate) + && ((ts.isIdentifier(candidate.name) || ts.isStringLiteral(candidate.name)) && candidate.name.text === name)); + return matches.length === 1 ? unwrap(matches[0]!.initializer) : undefined; +}; + +/** + * Extracts the storage identity and lifetime from the conventional + * `export default defineState({ ... })` declaration without evaluating it. + */ +export const extractStateDefinition = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): ExtractedStateDefinition => { + const sourceFile = ts.createSourceFile( + relativePath, + moduleText, + ts.ScriptTarget.Latest, + true, + relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const defaults = sourceFile.statements.filter((statement): statement is ts.ExportAssignment => + ts.isExportAssignment(statement) && !statement.isExportEquals); + const expression = defaults.length === 1 ? unwrap(defaults[0]!.expression) : undefined; + if ( + expression === undefined + || !ts.isCallExpression(expression) + || !ts.isIdentifier(expression.expression) + || expression.expression.text !== 'defineState' + || expression.arguments.length !== 1 + || !ts.isObjectLiteralExpression(unwrap(expression.arguments[0]!)) + ) { + return deepFreeze({ + diagnostics: [diagnostic( + 'AB4818', + `State module ${relativePath} must default-export one direct \`defineState({ ... })\` call.`, + 'Export default defineState({ id, lifetime, ... }) from the state module.', + sourcePath, + )], + }); + } + + const input = unwrap(expression.arguments[0]!) as ts.ObjectLiteralExpression; + const idNode = property(input, 'id'); + const lifetimeNode = property(input, 'lifetime'); + const id = idNode !== undefined && ts.isStringLiteral(idNode) ? idNode.text : undefined; + const lifetime = lifetimeNode !== undefined && ts.isStringLiteral(lifetimeNode) ? lifetimeNode.text : undefined; + const accepted = lifetime === 'request' + || lifetime === 'process' + || lifetime === 'workspace-durable' + || lifetime === 'external'; + if (id === undefined || id.trim() === '' || !accepted) { + return deepFreeze({ + diagnostics: [diagnostic( + 'AB4819', + `State module ${relativePath} requires non-empty string-literal id and lifetime properties; lifetime must be request, process, workspace-durable, or external.`, + 'Replace computed or referenced id/lifetime values with string literals in defineState({ ... }).', + sourcePath, + )], + }); + } + if (lifetime === 'external') { + return deepFreeze({ + diagnostics: [diagnostic( + 'AB4820', + `State module ${relativePath} selects external lifetime, but generated mounting v1 supports request, process, and workspace-durable lifetimes only.`, + 'Use a supported generated lifetime, or wire the external driver from an embedder.', + sourcePath, + )], + }); + } + return deepFreeze({ + definition: { id, lifetime }, + diagnostics: [], + }); +}; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 9a57c9d9b..c47abbb37 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1773,6 +1773,13 @@ export const validateSource = ( diagnostics.push(...validateRules(loaded, discovered, registry)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); + if (loaded.config.state !== undefined && loaded.config.state !== false) { + diagnostics.push(sourceDiagnostic( + 'AB4818', + 'State configuration accepts only false; omit it to discover src/state.ts.', + loaded.configPath, + )); + } diagnostics.push(...packageConventionShadowNudges(loaded)); diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); // Route overrides are validated during discovery; source validation must @@ -1781,6 +1788,7 @@ export const validateSource = ( // Route-graph collisions (AB4800-AB4804) are compiled during discovery; // they are project-source errors, so they gate inspect and build here. diagnostics.push(...(discovered.routeGraph?.diagnostics ?? [])); + diagnostics.push(...(discovered.state?.diagnostics ?? [])); diagnostics.push(...validateEventRoutes(loaded, discovered, registry)); // The stage-1 gate for conventional script routes rides beside the graph's // own collisions: rendered, nested, and config-conflicting script routes diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index ecc1ebdba..703a8e195 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -288,6 +288,7 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ ])), ]), ...model.scripts.flatMap((script) => [script.provenance.sourcePath, script.source]), + ...(model.state === undefined ? [] : [model.state.provenance.sourcePath, model.state.source]), ...model.mcpServers.flatMap((server) => [ server.provenance.sourcePath, ...(server.source === undefined ? [] : [server.source]), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index b7cf87596..1e97f4d7e 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -224,6 +224,9 @@ export type AgentBundleHookInput = | AgentBundleHookEntry | readonly (string | AgentBundleHookEntry)[]; +/** False disables the conventional src/state.ts module. */ +export type AgentBundleStateConfig = false; + export interface AgentBundleDevRuntimeConfig { readonly provider: string; } @@ -246,6 +249,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { runtime?: AgentBundleRuntimeConfig; scripts?: Readonly>; skills?: string[]; + state?: AgentBundleStateConfig; targets?: string[]; tools?: AgentBundleToolsConfig; [key: string]: unknown; @@ -500,6 +504,14 @@ export interface NormalizedRuntime { readonly node: string; } +/** Statically extracted conventional project state used by generated entry emitters. */ +export interface NormalizedStateDefinition { + readonly id: string; + readonly lifetime: 'request' | 'process' | 'workspace-durable'; + readonly provenance: SourceProvenance; + readonly source: string; +} + export interface NormalizedPlugin { /** * Project-level copied assets. Normalizers always provide this collection; @@ -544,6 +556,7 @@ export interface NormalizedPlugin { readonly runtime: NormalizedRuntime; readonly scripts: readonly NormalizedScript[]; readonly skills: readonly NormalizedSkill[]; + readonly state?: NormalizedStateDefinition; readonly targets: readonly NormalizedTarget[]; } diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 154526c65..d0a7f812c 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -147,6 +147,8 @@ export const renderGeneratedRoute = async ( invocation: { artifactEpoch, kind: 'tool', operationId: route.id, surface: route.name }, signal: context.mcpReq.signal, }, async () => { + // State and notice admission live only in the render scope. This host scope + // establishes identity and forwards it so one invocation is admitted once. const projected = await projectMcpRenderStream(dispatcher.stream({ artifactEpoch, invocation: { kind: 'tool', props: { input: input as never, operationId: route.id } }, @@ -350,10 +352,13 @@ export const createFlightWorkerHost = ( worker.postMessage({ actor: context.actor, artifactEpoch: requestEpoch ?? artifactEpoch, + host: context.host, id, invocation, + requestInvocation: context.invocation, session: context.session, type: 'render', + workspace: context.workspace, }); }); }, @@ -448,6 +453,8 @@ const startEventRuntime = async ( signal, ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, 'native') }), }, async () => events.projectEventDocument( + // The host scope remains ledger-free: the Flight worker owns the one + // notice admission where route components read the request handle. await dispatcher.dispatch({ invocation: { kind: 'event', diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 46b8e57b5..4b4630a49 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -15,6 +15,12 @@ * `packed-stdio` level) is the helper that carries process evidence. */ import type { Client } from '@modelcontextprotocol/client'; +import type { + AgentStateDefinition, + AgentStateDriver, + AgentStateEventSchemas, +} from '@agent-bundle/runtime/state'; +import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount'; import { AgentTestError, captured } from './errors.ts'; import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; @@ -49,12 +55,20 @@ export interface McpToolInvocation { readonly structuredContent?: unknown; } -export interface InMemoryMcpSessionOptions { +export interface InMemoryMcpSessionOptions< + TState = unknown, + TEvents extends AgentStateEventSchemas = AgentStateEventSchemas, +> { /** Request-scoped overrides applied to every route render in this session. */ readonly context?: RenderRouteContext; readonly manifest?: AgentBundleTestManifest; /** MCP server name. Optional when the project compiled exactly one server. */ readonly server?: string; + /** Optional state owner input for parity with a generated Flight worker. */ + readonly state?: { + readonly definition: AgentStateDefinition; + readonly driver: AgentStateDriver; + }; } export interface InMemoryMcpSession extends AsyncDisposable { @@ -135,6 +149,7 @@ interface ServerRuntime { interface Renderer { readonly createElement: typeof import('react').createElement; + readonly createGeneratedRuntimeState: typeof createGeneratedRuntimeState; readonly createWarmFlightHost: typeof import('@agent-bundle/runtime').createWarmFlightHost; readonly renderAgentFlight: typeof import('@agent-bundle/runtime/flight/server').renderAgentFlight; readonly runAgentRequest: typeof import('@agent-bundle/runtime').runAgentRequest; @@ -157,9 +172,10 @@ let dependenciesPromise: Promise | undefined; */ const loadDependencies = async (): Promise => { dependenciesPromise ??= (async () => { - const [serverRuntime, runtime, flight, react, client] = await Promise.all([ + const [serverRuntime, runtime, mount, flight, react, client] = await Promise.all([ import('../mcp-server-runtime.ts'), import('@agent-bundle/runtime'), + import('@agent-bundle/runtime/mount'), import('@agent-bundle/runtime/flight/server'), import('react'), import('@modelcontextprotocol/client'), @@ -168,6 +184,7 @@ const loadDependencies = async (): Promise => { Client: client.Client, InMemoryTransport: client.InMemoryTransport, createElement: react.createElement, + createGeneratedRuntimeState: mount.createGeneratedRuntimeState, createGeneratedRouteMcpServer: serverRuntime.createGeneratedRouteMcpServer, createWarmFlightHost: runtime.createWarmFlightHost, renderAgentFlight: flight.renderAgentFlight, @@ -214,8 +231,11 @@ const streamOf = (chunks: readonly Uint8Array[]): ReadableStream => * build output, and claiming an App resource without it would be exactly the * fake this level must not become. App surfaces belong to the browser level. */ -export const openInMemoryMcpServer = async ( - options: InMemoryMcpSessionOptions = {}, +export const openInMemoryMcpServer = async < + TState = unknown, + TEvents extends AgentStateEventSchemas = AgentStateEventSchemas, +>( + options: InMemoryMcpSessionOptions = {}, ): Promise => { const manifest = options.manifest ?? testManifest(); const serverName = resolveServerName(manifest, options.server); @@ -272,6 +292,9 @@ export const openInMemoryMcpServer = async ( // the same warm host wrapper the artifact uses — it simply renders here // instead of in a spawned thread. const artifactEpoch = `${manifest.plugin.name}@${manifest.plugin.version}`; + const runtimeState = options.state === undefined + ? undefined + : dependencies.createGeneratedRuntimeState(options.state); const host = dependencies.createWarmFlightHost({ artifactEpoch, host: { @@ -285,25 +308,35 @@ export const openInMemoryMcpServer = async ( details: [`registered: ${Object.keys(routes).sort().join(', ')}`], }); } - return streamOf(await dependencies.runAgentRequest({ - ...context, - invocation: { - kind: 'tool' as const, - operationId: route.id, - surface: route.name, - ...context.invocation, - }, - ...(request.progress === undefined ? {} : { progress: request.progress }), - signal: request.signal, - }, async () => drain(dependencies.renderAgentFlight( - dependencies.createElement(route.module.default as never, { - input: props.input, + const bindings = await runtimeState?.requestBindings({ signal: request.signal }); + try { + return streamOf(await dependencies.runAgentRequest({ + ...context, + invocation: { + kind: 'tool' as const, + operationId: route.id, + surface: route.name, + ...context.invocation, + }, + ...(bindings === undefined ? {} : { + noticeLedger: bindings.noticeLedger, + state: bindings.state, + }), + ...(request.progress === undefined ? {} : { progress: request.progress }), signal: request.signal, - } as never), - { signal: request.signal }, - )))); + }, async () => drain(dependencies.renderAgentFlight( + dependencies.createElement(route.module.default as never, { + input: props.input, + signal: request.signal, + } as never), + { signal: request.signal }, + )))); + } finally { + await bindings?.close(); + } }, }, + ...(runtimeState === undefined ? {} : { runtimeState }), }); const server = await dependencies.createGeneratedRouteMcpServer({ diff --git a/packages/agent-bundle/tests/config.test.ts b/packages/agent-bundle/tests/config.test.ts index 31eee1be8..5d5e5ced6 100644 --- a/packages/agent-bundle/tests/config.test.ts +++ b/packages/agent-bundle/tests/config.test.ts @@ -97,6 +97,38 @@ it('honors an explicit empty skills list instead of conventional discovery', asy } }); +it('discovers src/state.ts by convention and honors state false', async () => { + const fixture = await createProjectFixture(); + const stateSource = join(fixture.root, 'src/state.ts'); + try { + await mkdir(join(fixture.root, 'src'), { recursive: true }); + await writeFile(stateSource, [ + "import { defineState } from '@agent-bundle/runtime/state';", + 'export default defineState({', + " id: 'fixture/state',", + " lifetime: 'process',", + '});', + '', + ].join('\n')); + const loaded = await loadConfig({ + command: 'build', + mode: 'production', + root: fixture.root, + }); + await expect(discoverProject(fixture.root, loaded.config)).resolves.toMatchObject({ + state: { + definition: { id: 'fixture/state', lifetime: 'process' }, + diagnostics: [], + source: stateSource, + }, + }); + + await expect(discoverProject(fixture.root, { ...loaded.config, state: false })).resolves.not.toHaveProperty('state'); + } finally { + await removeProjectFixture(fixture.root); + } +}); + it('loads sync config objects from relative and absolute explicit paths', async () => { const fixture = await createProjectFixture(); const relativeConfigPath = 'configs/sync.config.ts'; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 36d13f1ef..ad71d9716 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -3,6 +3,7 @@ import { access, readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; +import ts from 'typescript-5'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; @@ -239,6 +240,9 @@ it('keeps the generated server behaviour in the shared runtime module the entry expect(runtime).toContain('server.registerPrompt'); expect(runtime).toContain('createEventRuntimeServer('); expect(runtime).toContain('projectEventDocument('); + expect(runtime).toContain('requestInvocation: context.invocation'); + expect(runtime).toContain('host: context.host'); + expect(runtime).toContain('workspace: context.workspace'); }); it('fails the build on an MCP route the generated server cannot register', () => { @@ -300,3 +304,95 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain('/project/src/events/tool/after.tsx'); expect(source).toContain("message.invocation.kind === 'event'"); }); + +it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { + const route = { + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + source: '/project/src/mcp/curator/tools/inspect.tsx', + } as const; + const state = (lifetime: 'process' | 'request' | 'workspace-durable') => ({ + id: 'project/tasks', + lifetime, + provenance: { kind: 'conventional' as const, sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }); + const base = { + artifactEpoch: 'route-fixture@1.2.3', + routes: [route], + serverName: 'curator', + }; + const stateless = entryShellModule.generatedRouteFlightWorkerSource(base); + for (const identifier of [ + '@agent-bundle/runtime/mount', + '@agent-bundle/runtime/state', + 'noticeLedger', + 'createGeneratedRuntimeState', + 'createSqliteStateDriver', + ]) { + expect(stateless).not.toContain(identifier); + } + + const volatile = entryShellModule.generatedRouteFlightWorkerSource({ + ...base, + state: state('process'), + }); + expect(volatile).toContain('import stateDefinition from "/project/src/state.ts"'); + expect(volatile).toContain("createGeneratedRuntimeState"); + expect(volatile).toContain('createMemoryStateDriver({ lifetime: "process" })'); + expect(volatile).toContain('noticeLedger'); + expect(volatile).not.toContain('@agent-bundle/runtime/state/sqlite'); + expect(volatile).not.toContain('createSqliteStateDriver'); + + const durable = entryShellModule.generatedRouteFlightWorkerSource({ + ...base, + state: state('workspace-durable'), + }); + expect(durable).toContain("from '@agent-bundle/runtime/state/sqlite'"); + expect(durable).toContain('AGENT_BUNDLE_PLUGIN_ROOT'); + expect(durable).toContain("join(durableAnchor, 'state')"); + + const renderedWorker = entryShellModule.generatedRenderedRouteWorkerSource({ + routes: [{ ...route, id: 'script:report', kind: 'script' }], + state: state('workspace-durable'), + }); + expect(renderedWorker).toContain("from '@agent-bundle/runtime/state/sqlite'"); + expect(renderedWorker).toContain('noticeLedger: bindings.noticeLedger'); + expect(renderedWorker).toContain('state: bindings.state'); + + const command = { + aliases: [], + exitCode: 'zero', + options: [], + path: ['inspect'], + rendered: false, + routeId: 'cli:inspect', + } as const; + const cliRoute = { ...route, id: command.routeId, kind: 'cli' as const }; + const statelessCli = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [cliRoute], + }); + expect(statelessCli).not.toContain('@agent-bundle/runtime/mount'); + expect(statelessCli).not.toContain('noticeLedger'); + const volatileCli = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [cliRoute], + state: state('request'), + }); + expect(volatileCli).toContain('createMemoryStateDriver({ lifetime: "request" })'); + expect(volatileCli).not.toContain('@agent-bundle/runtime/state/sqlite'); + expect(volatileCli).toContain('await bindings.close()'); + + for (const generated of [stateless, volatile, durable, renderedWorker, statelessCli, volatileCli]) { + const transpiled = ts.transpileModule(generated, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + reportDiagnostics: true, + }); + expect(transpiled.diagnostics ?? []).toEqual([]); + } +}); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 24af62a6d..457cdbac2 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from '@rstest/core'; +import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; import { AgentTestError } from '../../src/test/errors.ts'; import { @@ -104,6 +106,39 @@ describe('the in-memory MCP projection level', () => { expect(second).toMatchObject({ structuredContent: { message: 'second' } }); }); + it('mounts and tears down optional state with the in-memory warm host', async () => { + const inner = createMemoryStateDriver({ lifetime: 'process' }); + let opens = 0; + let closes = 0; + const driver: AgentStateDriver = { + ...inner, + close: async () => { + closes += 1; + await inner.close(); + }, + open: async (definition) => { + opens += 1; + return inner.open(definition); + }, + }; + const definition = defineState({ + events: { changed: z.object({ value: z.string() }).strict() }, + id: 'mcp-in-memory/state', + initial: { value: '' }, + lifetime: 'process', + reduce: (_state, event) => ({ value: event.payload.value }), + schema: z.object({ value: z.string() }).strict(), + }); + const session = await openInMemoryMcpServer({ state: { definition, driver } }); + try { + await session.client.callTool({ arguments: { message: 'stateful' }, name: 'echo' }); + expect(opens).toBe(2); + } finally { + await session.close(); + } + expect(closes).toBe(1); + }); + it('leaves the browser App surface off the in-memory server', async () => { const surface = await listMcpSurface(); diff --git a/packages/agent-bundle/tests/state-definition-extract.test.ts b/packages/agent-bundle/tests/state-definition-extract.test.ts new file mode 100644 index 000000000..a1788566d --- /dev/null +++ b/packages/agent-bundle/tests/state-definition-extract.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from '@rstest/core'; + +import { extractStateDefinition } from '../src/config/state-extract.ts'; + +const extract = (text: string) => + extractStateDefinition(text, 'src/state.ts', '/project/src/state.ts'); + +describe('state definition extraction', () => { + it('extracts literal id and lifetime without executing the module', () => { + const result = extract([ + "import { defineState } from '@agent-bundle/runtime/state';", + 'throw new Error("must not execute");', + 'export default defineState({', + " id: 'project/tasks',", + " lifetime: 'workspace-durable',", + ' schema: anything,', + '});', + ].join('\n')); + + expect(result).toEqual({ + definition: { id: 'project/tasks', lifetime: 'workspace-durable' }, + diagnostics: [], + }); + expect(Object.isFrozen(result)).toBe(true); + }); + + it.each([ + ['missing default export', 'export const state = defineState({ id: "x", lifetime: "process" });', 'AB4818'], + ['wrong default call', 'export default createState({ id: "x", lifetime: "process" });', 'AB4818'], + ['non-literal id', 'export default defineState({ id: STATE_ID, lifetime: "process" });', 'AB4819'], + ['non-literal lifetime', 'export default defineState({ id: "x", lifetime: lifetime() });', 'AB4819'], + ['unknown lifetime', 'export default defineState({ id: "x", lifetime: "forever" });', 'AB4819'], + ])('diagnoses %s', (_label, source, code) => { + const result = extract(source); + expect(result.definition).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code, + severity: 'error', + sourcePath: '/project/src/state.ts', + }); + }); + + it('rejects external lifetime until embedder driver wiring exists', () => { + const result = extract('export default defineState({ id: "x", lifetime: "external" });'); + expect(result.definition).toBeUndefined(); + expect(result.diagnostics[0]).toMatchObject({ code: 'AB4820', severity: 'error' }); + expect(result.diagnostics[0]!.message).toContain('request, process, and workspace-durable'); + }); +}); diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index c34fecd8f..c2b292cc6 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -53,6 +53,10 @@ "./notices": { "types": "./dist/notices/index.d.ts", "import": "./dist/notices.js" + }, + "./mount": { + "types": "./dist/mount/index.d.ts", + "import": "./dist/mount.js" } }, "scripts": { diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts index 780ad121c..7191880c2 100644 --- a/packages/rsc-runtime/rslib.config.ts +++ b/packages/rsc-runtime/rslib.config.ts @@ -33,6 +33,21 @@ export default defineConfig({ entry: { notices: './src/notices/index.ts' }, }, }, + { + ...sharedLib, + // Generated mounting composes the optional state and notice entries but + // never imports the sqlite driver; durable callers inject that driver. + output: { + cleanDistPath: false, + externals: { + '../notices/index.js': './notices.js', + '../state/index.js': './state.js', + }, + }, + source: { + entry: { mount: './src/mount/index.ts' }, + }, + }, { ...sharedLib, // The sqlite driver is its own entry so `node:sqlite` (and its diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts new file mode 100644 index 000000000..e86f31be7 --- /dev/null +++ b/packages/rsc-runtime/src/mount/index.ts @@ -0,0 +1,221 @@ +import { + agentNoticeStateDefinition, + createAgentNoticeLedger, + type AgentNoticeLedger, +} from '../notices/index.js'; +import { + AgentStateError, + createAgentStateHandle, + type AgentStateDefinition, + type AgentStateDriver, + type AgentStateEventSchemas, + type AgentStateHandle, + type AgentStateStore, +} from '../state/index.js'; + +export interface CreateGeneratedRuntimeStateOptions< + TState, + TEvents extends AgentStateEventSchemas, +> { + readonly definition: AgentStateDefinition; + readonly driver: AgentStateDriver; +} + +export interface GeneratedRuntimeRequestBindings< + TState, + TEvents extends AgentStateEventSchemas, +> { + readonly noticeLedger: AgentNoticeLedger; + readonly state: AgentStateHandle; + /** Releases request-lifetime stores; a no-op for process and durable owners. */ + close(): Promise; +} + +export interface GeneratedRuntimeState< + TState, + TEvents extends AgentStateEventSchemas, +> { + close(): Promise; + requestBindings( + options?: { readonly signal?: AbortSignal }, + ): Promise>; +} + +type OpenResult = + | { readonly kind: 'opened'; readonly value: T } + | { readonly error: AgentStateError; readonly kind: 'failed' }; + +type NoticeStore = Parameters[0]; +type ClosableStore = { close(): Promise }; + +const asStateError = (error: unknown, definitionId: string): AgentStateError => + error instanceof AgentStateError + ? error + : new AgentStateError('unavailable', `State '${definitionId}' store is unavailable`, { cause: error }); + +const failedHandle = ( + lifetime: AgentStateDefinition['lifetime'], + failure: AgentStateError, +): AgentStateHandle => Object.freeze({ + lifetime, + changes: async () => Promise.reject(failure), + dispatch: async () => Promise.reject(failure), + read: async () => Promise.reject(failure), +}); + +const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { + const reject = async (): Promise => Promise.reject(failure); + return Object.freeze({ + expire: reject, + openRequest: async () => Object.freeze({ + close: () => undefined, + handle: Object.freeze({ + publish: reject, + read: reject, + }), + }), + read: reject, + withdraw: reject, + }); +}; + +/** + * Owns the state kernel and notice ledger used by generated request scopes. + * + * The v1 authorizer admits every publish and delivery request. Recipient + * matching remains enforced by the ledger itself; application-specific + * authorization is future embedder policy. + */ +export const createGeneratedRuntimeState = < + TState, + TEvents extends AgentStateEventSchemas, +>( + options: CreateGeneratedRuntimeStateOptions, +): GeneratedRuntimeState => { + const { definition, driver } = options; + const noticeDefinition = agentNoticeStateDefinition(definition.lifetime); + const shared = definition.lifetime !== 'request'; + const liveStores = new Set(); + let projectFailure: AgentStateError | undefined; + let noticeFailure: AgentStateError | undefined; + let projectOpen: Promise>> | undefined; + let noticeOpen: Promise> | undefined; + let closing: Promise | undefined; + let closed = false; + + const open = async ( + stateDefinition: AgentStateDefinition, + cachedFailure: () => AgentStateError | undefined, + rememberFailure: (failure: AgentStateError) => void, + ): Promise>> => { + const failure = cachedFailure(); + if (failure !== undefined) return { error: failure, kind: 'failed' }; + if (closed) { + const closedFailure = new AgentStateError( + 'store-closed', + `State '${stateDefinition.id}' cannot open on a closed generated runtime`, + ); + rememberFailure(closedFailure); + return { error: closedFailure, kind: 'failed' }; + } + try { + const store = await driver.open(stateDefinition); + if (closed) { + await store.close(); + const closedFailure = new AgentStateError( + 'store-closed', + `State '${stateDefinition.id}' opened after its generated runtime closed`, + ); + rememberFailure(closedFailure); + return { error: closedFailure, kind: 'failed' }; + } + liveStores.add(store); + return { kind: 'opened', value: store }; + } catch (error) { + const typed = asStateError(error, stateDefinition.id); + rememberFailure(typed); + return { error: typed, kind: 'failed' }; + } + }; + + const openProject = (): Promise>> => { + if (!shared) { + return open(definition, () => projectFailure, (failure) => { + projectFailure = failure; + }); + } + projectOpen ??= open(definition, () => projectFailure, (failure) => { + projectFailure = failure; + }); + return projectOpen; + }; + + const openNotices = (): Promise> => { + if (!shared) { + return open(noticeDefinition, () => noticeFailure, (failure) => { + noticeFailure = failure; + }); + } + noticeOpen ??= open(noticeDefinition, () => noticeFailure, (failure) => { + noticeFailure = failure; + }); + return noticeOpen; + }; + + const closeStore = async (store: ClosableStore): Promise => { + if (!liveStores.delete(store)) return; + await store.close(); + }; + + return Object.freeze({ + close(): Promise { + if (closing !== undefined) return closing; + closed = true; + closing = (async () => { + await Promise.allSettled([ + ...(projectOpen === undefined ? [] : [projectOpen]), + ...(noticeOpen === undefined ? [] : [noticeOpen]), + ]); + const storeClosures = await Promise.allSettled([...liveStores].map((store) => closeStore(store))); + let driverFailure: unknown; + try { + await driver.close(); + } catch (error) { + driverFailure = error; + } + const storeFailure = storeClosures.find((result) => result.status === 'rejected'); + if (storeFailure?.status === 'rejected') throw storeFailure.reason; + if (driverFailure !== undefined) throw driverFailure; + })(); + return closing; + }, + + async requestBindings( + bindingOptions: { readonly signal?: AbortSignal } = {}, + ): Promise> { + const [project, notices] = await Promise.all([openProject(), openNotices()]); + const requestStores = shared + ? [] + : [project, notices] + .flatMap((result): ClosableStore[] => result.kind === 'opened' ? [result.value] : []); + const state = project.kind === 'opened' + ? createAgentStateHandle(project.value, bindingOptions) + : failedHandle(definition.lifetime, project.error); + const noticeLedger = notices.kind === 'opened' + ? createAgentNoticeLedger(notices.value, { + authorize: () => ({ state: 'authorized' }), + }) + : failedLedger(notices.error); + let released = false; + return Object.freeze({ + noticeLedger, + state, + async close() { + if (released) return; + released = true; + for (const store of requestStores) await closeStore(store); + }, + }); + }, + }); +}; diff --git a/packages/rsc-runtime/src/warm-runtime.ts b/packages/rsc-runtime/src/warm-runtime.ts index d1813176a..9ba15db4f 100644 --- a/packages/rsc-runtime/src/warm-runtime.ts +++ b/packages/rsc-runtime/src/warm-runtime.ts @@ -51,6 +51,8 @@ export interface CreateWarmFlightHostOptions { readonly close?: () => Promise; readonly host: AgentFlightExecutionHost; readonly instanceId?: string; + /** Optional generated state owner whose lifetime is the warm host lifetime. */ + readonly runtimeState?: { close(): Promise }; } const unavailableError = ( @@ -83,7 +85,11 @@ export const createWarmFlightHost = (options: CreateWarmFlightHostOptions): Warm unavailable ??= unavailableError(code); }, async close() { - await options.close?.(); + try { + await options.close?.(); + } finally { + await options.runtimeState?.close(); + } }, async execute(request: AgentRenderDispatch) { if (unavailable !== undefined) throw unavailable; diff --git a/packages/rsc-runtime/tests/mount.test.ts b/packages/rsc-runtime/tests/mount.test.ts new file mode 100644 index 000000000..c6d248e48 --- /dev/null +++ b/packages/rsc-runtime/tests/mount.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import { + AgentStateError, + createMemoryStateDriver, + defineState, + type AgentStateDriver, +} from '../src/state/index.js'; +import { createGeneratedRuntimeState } from '../src/mount/index.js'; +import { agent, runAgentRequest } from '../src/index.js'; + +const definition = (lifetime: 'process' | 'request' = 'process') => defineState({ + events: { incremented: z.object({ by: z.number() }).strict() }, + id: `mount-test/${lifetime}`, + initial: { count: 0 }, + lifetime, + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number() }).strict(), +}); + +describe('createGeneratedRuntimeState', () => { + it('lazily opens one process store and one notice store', async () => { + const inner = createMemoryStateDriver({ lifetime: 'process' }); + let opens = 0; + const driver: AgentStateDriver = { + ...inner, + open: async (stateDefinition) => { + opens += 1; + return inner.open(stateDefinition); + }, + }; + const runtimeState = createGeneratedRuntimeState({ definition: definition(), driver }); + + expect(opens).toBe(0); + const first = await runtimeState.requestBindings(); + const second = await runtimeState.requestBindings(); + expect(opens).toBe(2); + + await first.state.dispatch('incremented', { by: 2 }, { idempotencyKey: 'increment-1' }); + await expect(second.state.read()).resolves.toMatchObject({ state: { count: 2 } }); + await runtimeState.close(); + }); + + it('opens and closes fresh stores for each request lifetime binding', async () => { + const runtimeState = createGeneratedRuntimeState({ + definition: definition('request'), + driver: createMemoryStateDriver({ lifetime: 'request' }), + }); + const first = await runtimeState.requestBindings(); + await first.state.dispatch('incremented', { by: 3 }, { idempotencyKey: 'increment-1' }); + await first.close(); + + const second = await runtimeState.requestBindings(); + await expect(second.state.read()).resolves.toMatchObject({ state: { count: 0 } }); + await second.close(); + await runtimeState.close(); + }); + + it('keeps bindings present but typed-failing when opening is unavailable', async () => { + const failure = new AgentStateError('unavailable', 'storage is offline'); + let opens = 0; + let closes = 0; + const driver: AgentStateDriver = { + durable: false, + kind: 'unavailable-test', + lifetime: 'process', + close: async () => { + closes += 1; + }, + open: async () => { + opens += 1; + throw failure; + }, + }; + const runtimeState = createGeneratedRuntimeState({ definition: definition(), driver }); + + const bindings = await runtimeState.requestBindings(); + await expect(bindings.state.read()).rejects.toBe(failure); + await expect(bindings.noticeLedger.read()).rejects.toBe(failure); + await runAgentRequest({ + invocation: { kind: 'tool' }, + noticeLedger: bindings.noticeLedger, + state: bindings.state, + }, async () => { + const context = await agent(); + expect(context.state).toBeDefined(); + expect(context.notices).toBeDefined(); + await expect(context.state!.read()).rejects.toBe(failure); + await expect(context.notices!.read()).rejects.toBe(failure); + }); + await runtimeState.requestBindings(); + expect(opens).toBe(2); + + await runtimeState.close(); + expect(closes).toBe(1); + }); + + it('closes both stores before closing the driver', async () => { + const order: string[] = []; + const inner = createMemoryStateDriver({ lifetime: 'process' }); + const driver: AgentStateDriver = { + ...inner, + close: async () => { + order.push('driver'); + await inner.close(); + }, + open: async (stateDefinition) => { + const store = await inner.open(stateDefinition); + return { + ...store, + close: async () => { + order.push(`store:${stateDefinition.id}`); + await store.close(); + }, + }; + }, + }; + const runtimeState = createGeneratedRuntimeState({ definition: definition(), driver }); + await runtimeState.requestBindings(); + await runtimeState.close(); + + expect(order).toEqual([ + 'store:mount-test/process', + 'store:@agent-bundle/runtime/agent-notice-ledger/v1', + 'driver', + ]); + }); +}); diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index e178e57d8..d0ad7a9a0 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -58,6 +58,13 @@ describe.sequential('state kernel packaging boundaries', () => { expect(source).not.toContain('DatabaseSync'); }); + it('keeps sqlite out of the generated mount entry', async () => { + const source = await distFile('mount.js'); + expect(source).toContain('createGeneratedRuntimeState'); + 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'); @@ -72,6 +79,7 @@ describe.sequential('state kernel packaging boundaries', () => { './state', './state/sqlite', './notices', + './mount', ]); for (const subpath of Object.keys(packageJson.exports)) { const target = packageJson.exports[subpath]!; diff --git a/packages/rsc-runtime/tests/warm-runtime.test.ts b/packages/rsc-runtime/tests/warm-runtime.test.ts index 4e482f5b9..c5c56f32d 100644 --- a/packages/rsc-runtime/tests/warm-runtime.test.ts +++ b/packages/rsc-runtime/tests/warm-runtime.test.ts @@ -89,4 +89,23 @@ describe('createWarmFlightHost', () => { await expect(host.execute(dispatch())).rejects.toBeInstanceOf(AgentRuntimeError); await expect(host.execute(dispatch())).rejects.toMatchObject({ code: 'runtime-unavailable' }); }); + + it('closes runtime state with the warm host', async () => { + const closed: string[] = []; + const host = createWarmFlightHost({ + artifactEpoch: 'epoch-a', + close: async () => { + closed.push('host'); + }, + host: { execute: async () => emptyFlight() }, + runtimeState: { + close: async () => { + closed.push('state'); + }, + }, + }); + + await host.close(); + expect(closed).toEqual(['host', 'state']); + }); }); From 9e9d46fc08405035c1777cda0d43658da1f0d1cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 00:01:40 +0000 Subject: [PATCH 2/3] test(runtime): prove request-scope state and notice mounting at every harness level (#233) --- .../route-harness/src/events/tool/after.tsx | 17 +- .../src/mcp/harness/tools/journal.tsx | 40 ++++ .../src/mcp/harness/tools/publish-notice.tsx | 41 ++++ .../fixtures/route-harness/src/state.ts | 23 ++ packages/agent-bundle/src/rstest/index.ts | 6 +- .../agent-bundle/src/rstest/setup-module.ts | 3 + packages/agent-bundle/src/test/index.ts | 1 + packages/agent-bundle/src/test/manifest.ts | 24 +- packages/agent-bundle/src/test/registry.ts | 18 +- packages/agent-bundle/src/test/render.ts | 145 ++++++++++-- .../tests/generated-route-server.test.ts | 13 ++ .../tests/packed-stdio-projection.test.ts | 209 +++++++++++++----- .../tests/projection/mcp-in-memory.test.ts | 52 ++++- .../tests/route-unit/render-route.test.ts | 37 +++- .../tests/test-harness-manifest.test.ts | 11 + 15 files changed, 554 insertions(+), 86 deletions(-) create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx create mode 100644 packages/agent-bundle/fixtures/route-harness/src/state.ts diff --git a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx index 4b383c82d..a42c75b19 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx @@ -1,16 +1,19 @@ import { Agent, agent } from '@agent-bundle/runtime'; import type { AgentEventRouteProps } from 'agent-bundle'; -export default async function AfterTool({ canonical, native }: AgentEventRouteProps) { +export default async function AfterTool({ canonical }: AgentEventRouteProps) { const context = await agent(); + const deliveries = await context.notices?.read() ?? []; + const notices = deliveries.map(({ notice }) => ({ + id: notice.id, + message: notice.content.root.kind === 'text' ? notice.content.root.text : '', + })); return ( - + {`Observed ${canonical.event} from ${canonical.provenance.host}.`} + {notices.map((notice) => ( + {`notice ${notice.id}: ${notice.message}`} + ))} ); } diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx new file mode 100644 index 000000000..6f8f7728a --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/journal.tsx @@ -0,0 +1,40 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + description: 'Records and reads durable route-harness journal entries.', + title: 'Journal', +}; + +export const inputSchema = z.object({ note: z.string().optional() }).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default async function Journal({ input }: { readonly input: z.infer }) { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); + if (input.note !== undefined) { + await context.state.dispatch('recorded', { note: input.note }, { + idempotencyKey: `journal:${input.note}`, + }); + } + const snapshot = await context.state.read(); + const state = snapshot.state as JournalState; + const result = { entries: state.entries, revision: snapshot.revision }; + return ( + + {[ + `# Journal revision ${String(snapshot.revision)}`, + '', + ...state.entries.map((entry) => `- ${entry.note}`), + ].join('\n')} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx new file mode 100644 index 000000000..72bfa81c8 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx @@ -0,0 +1,41 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + description: 'Publishes a durable notice for a later session event.', + title: 'Publish notice', +}; + +export const inputSchema = z.object({ + message: z.string(), + recipientSession: z.string(), +}).strict(); + +export const resultSchema = z.object({ + noticeId: z.string(), + state: z.literal('pending'), +}).strict(); + +export default async function PublishNotice({ input }: { readonly input: z.infer }) { + const context = await agent(); + if (context.notices === undefined) throw new TypeError('Notice publishing is unavailable.'); + const published = await context.notices.publish({ + content: { + root: { kind: 'text', text: input.message }, + status: 'success', + version: 1, + }, + priority: 'normal', + recipient: { + session: { sessionId: input.recipientSession }, + }, + }, { + idempotencyKey: `notice:${input.recipientSession}:${input.message}`, + }); + const result = { noticeId: published.notice.id, state: published.notice.state }; + return ( + + {`notice ${result.noticeId}: ${result.state}`} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/state.ts b/packages/agent-bundle/fixtures/route-harness/src/state.ts new file mode 100644 index 000000000..de020e61b --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/state.ts @@ -0,0 +1,23 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const journalEntrySchema = z.object({ + note: z.string(), +}).strict(); + +export default defineState({ + events: { + recorded: journalEntrySchema, + }, + id: 'route-harness/journal', + initial: { + entries: [], + }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ + entries: [...state.entries, event.payload], + }), + schema: z.object({ + entries: z.array(journalEntrySchema), + }).strict(), +}); diff --git a/packages/agent-bundle/src/rstest/index.ts b/packages/agent-bundle/src/rstest/index.ts index 1a159426c..abcdb90a4 100644 --- a/packages/agent-bundle/src/rstest/index.ts +++ b/packages/agent-bundle/src/rstest/index.ts @@ -116,4 +116,8 @@ export const agentBundleRstest = async ( }; }; -export type { AgentBundleTestManifest, TestableRouteDescriptor } from '../test/manifest.ts'; +export type { + AgentBundleTestManifest, + TestableRouteDescriptor, + TestableStateDescriptor, +} from '../test/manifest.ts'; diff --git a/packages/agent-bundle/src/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index f22062c42..fc68a5dc7 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -48,6 +48,9 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string ...loaders, ' },', ` manifest: JSON.parse(${JSON.stringify(JSON.stringify(manifest))}),`, + ...(manifest.state === undefined + ? [] + : [` stateLoader: () => import(${JSON.stringify(specifier(manifest.state.source))}),`]), ` version: ${String(AGENT_TEST_REGISTRY_VERSION)},`, '};', '', diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index fb9731121..36c34ad6c 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -39,6 +39,7 @@ export type { TestManifestPluginIdentity, TestableAppDescriptor, TestableRouteDescriptor, + TestableStateDescriptor, } from './manifest.ts'; export { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, testManifest } from './registry.ts'; export type { AgentTestRouteRegistry } from './registry.ts'; diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 940a577bc..bbf2af109 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -3,7 +3,7 @@ import { relative, resolve, sep } from 'node:path'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; -import type { NormalizedMcpApp } from '../core/types.ts'; +import type { NormalizedMcpApp, NormalizedStateDefinition } from '../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -105,6 +105,14 @@ export interface TestManifestPluginIdentity { readonly version: string; } +/** The conventional state module the generated route-unit registry can load. */ +export interface TestableStateDescriptor { + readonly id: string; + readonly lifetime: NormalizedStateDefinition['lifetime']; + readonly relativePath: string; + readonly source: string; +} + /** One normalized MCP App declaration addressable by the browser proof level. */ export interface TestableAppDescriptor { readonly _meta?: Readonly>; @@ -151,6 +159,8 @@ export interface AgentBundleTestManifest { /** The level the manifest and its registered loaders alone supply; every other level stamps its own. */ readonly proofLevel: AgentTestProofLevel; readonly routes: Readonly>; + /** Conventional project state mounted automatically for manifest route renders. */ + readonly state?: TestableStateDescriptor; /** Host targets the project selected. Route-unit rendering is target-neutral; these name the projection surfaces a later proof level owns. */ readonly targets: readonly string[]; } @@ -233,6 +243,7 @@ export const testManifestFromRouteGraph = (input: { readonly graph: CompiledRouteGraph; readonly plugin?: TestManifestPluginIdentity; readonly projectRoot: string; + readonly state?: NormalizedStateDefinition; readonly targets?: readonly string[]; }): AgentBundleTestManifest => { const routes: Record = {}; @@ -247,6 +258,16 @@ export const testManifestFromRouteGraph = (input: { projectRoot: input.projectRoot, proofLevel: ROUTE_UNIT_PROOF_LEVEL, routes, + ...(input.state === undefined + ? {} + : { + state: { + id: input.state.id, + lifetime: input.state.lifetime, + relativePath: relative(input.projectRoot, input.state.source).split(sep).join('/'), + source: input.state.source, + }, + }), targets: [...(input.targets ?? [])], }); }; @@ -282,6 +303,7 @@ export const compileTestManifest = async ( ? {} : { plugin: { name: prepared.model.metadata.name, version: prepared.model.metadata.version } }), projectRoot: prepared.root, + ...(prepared.model?.state === undefined ? {} : { state: prepared.model.state }), targets: prepared.model?.targets.map((target) => target.name) ?? [], }); }; diff --git a/packages/agent-bundle/src/test/registry.ts b/packages/agent-bundle/src/test/registry.ts index 5a6baf7d0..00886d34a 100644 --- a/packages/agent-bundle/src/test/registry.ts +++ b/packages/agent-bundle/src/test/registry.ts @@ -1,3 +1,5 @@ +import type { AgentStateDefinition, AgentStateEventSchemas } from '@agent-bundle/runtime/state'; + import { AgentTestError } from './errors.ts'; import type { AgentBundleTestManifest } from './manifest.ts'; import type { AgentRouteModuleLoader } from './types.ts'; @@ -14,12 +16,17 @@ export const AGENT_TEST_REGISTRY_SYMBOL_KEY = 'agent-bundle/test-route-registry' const REGISTRY_SYMBOL = Symbol.for(AGENT_TEST_REGISTRY_SYMBOL_KEY); -export const AGENT_TEST_REGISTRY_VERSION = 2; +export const AGENT_TEST_REGISTRY_VERSION = 3; + +export type AgentStateModuleLoader = () => Promise<{ + readonly default: AgentStateDefinition; +}>; export interface AgentTestRouteRegistry { /** Lazy loaders keyed by compiled route id, so a test only compiles the routes it renders. */ readonly loaders: Readonly>; readonly manifest: AgentBundleTestManifest; + readonly stateLoader?: AgentStateModuleLoader; readonly version: number; } @@ -98,6 +105,15 @@ export const registeredRouteLoader = ( return registry.loaders[routeId]; }; +/** The state-module loader generated beside the registered manifest. */ +export const registeredStateLoader = ( + manifest: AgentBundleTestManifest, +): AgentStateModuleLoader | undefined => { + const registry = registered(); + if (registry === undefined || !producedRegisteredLoaders(registry, manifest)) return undefined; + return registry.stateLoader; +}; + /** The registered manifest's identity, so a loader miss can name the mismatch that caused it. */ export const registeredManifestIdentity = (): { readonly digest: string; readonly projectRoot: string } | undefined => { const registry = registered(); diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index c4c0d171e..d6a219e34 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -1,5 +1,11 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type * as AgentFlightServer from '@agent-bundle/runtime/flight/server'; import type * as AgentRuntime from '@agent-bundle/runtime'; +import type * as AgentMount from '@agent-bundle/runtime/mount'; +import type * as AgentState from '@agent-bundle/runtime/state'; import type { AgentDocument, AgentInvocationInput, @@ -14,7 +20,12 @@ import type * as React from 'react'; import { AgentTestError, captured } from './errors.ts'; import { ROUTE_UNIT_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; -import { registeredManifestIdentity, registeredRouteLoader, testManifest } from './registry.ts'; +import { + registeredManifestIdentity, + registeredRouteLoader, + registeredStateLoader, + testManifest, +} from './registry.ts'; import type { AgentRouteModule, RenderableRouteKind, @@ -60,6 +71,8 @@ export type RenderRouteTarget = AgentRouteModule | string; interface Renderer { readonly createAgentRenderDispatcher: typeof AgentRuntime.createAgentRenderDispatcher; readonly createElement: typeof React.createElement; + readonly createGeneratedRuntimeState: typeof AgentMount.createGeneratedRuntimeState; + readonly createMemoryStateDriver: typeof AgentState.createMemoryStateDriver; readonly renderAgentFlight: typeof AgentFlightServer.renderAgentFlight; readonly runAgentRequest: typeof AgentRuntime.runAgentRequest; } @@ -76,14 +89,18 @@ let rendererPromise: Promise | undefined; */ const loadRenderer = async (): Promise => { rendererPromise ??= (async (): Promise => { - const [runtime, flight, react] = await Promise.all([ + const [runtime, mount, state, flight, react] = await Promise.all([ import('@agent-bundle/runtime'), + import('@agent-bundle/runtime/mount'), + import('@agent-bundle/runtime/state'), import('@agent-bundle/runtime/flight/server'), import('react'), ]); return { createAgentRenderDispatcher: runtime.createAgentRenderDispatcher, createElement: react.createElement, + createGeneratedRuntimeState: mount.createGeneratedRuntimeState, + createMemoryStateDriver: state.createMemoryStateDriver, renderAgentFlight: flight.renderAgentFlight, runAgentRequest: runtime.runAgentRequest, }; @@ -204,6 +221,7 @@ const knownRouteIds = (manifest: AgentBundleTestManifest): string => interface ResolvedTarget { readonly component: (props: never) => unknown; readonly kind: RenderableRouteKind; + readonly manifest?: AgentBundleTestManifest; readonly module: AgentRouteModule; readonly provenance: RenderedRouteProvenance; } @@ -354,6 +372,7 @@ const resolveTarget = async ( return { component: componentOf(module, { ...provenance, kind }), kind, + manifest, module, provenance: { ...provenance, kind }, }; @@ -400,7 +419,94 @@ const streamOf = (chunks: readonly Uint8Array[]): ReadableStream => }, }); +interface AutoMountedState { + readonly context: Pick; + close(): Promise; +} + +const noMountedState: AutoMountedState = Object.freeze({ + context: {}, + close: async () => undefined, +}); + +/** + * Mounts one fresh state owner for a manifest render. Durable definitions use + * a disposable sqlite root so repeated route-unit renders are deterministic. + */ +const mountManifestState = async ( + resolved: ResolvedTarget, + context: RenderRouteContext, + renderer: Renderer, + signal: AbortSignal, +): Promise => { + const manifest = resolved.manifest; + const descriptor = manifest?.state; + if ( + manifest === undefined + || descriptor === undefined + || context.state !== undefined + || context.noticeLedger !== undefined + ) return noMountedState; + const loader = registeredStateLoader(manifest); + if (loader === undefined) { + throw new AgentTestError( + 'manifest-unavailable', + `State ${descriptor.id} is declared but no test-time state module loader is registered for it.`, + { + provenance: resolved.provenance, + recovery: 'Build the Rstest configuration with agentBundleRstest() so the generated setup registers the state loader.', + }, + ); + } + const definition = (await loader()).default; + let root: string | undefined; + let driver: AgentState.AgentStateDriver; + try { + if (descriptor.lifetime === 'workspace-durable') { + root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-state-')); + driver = (await import('@agent-bundle/runtime/state/sqlite')).createSqliteStateDriver({ root }); + } else { + driver = renderer.createMemoryStateDriver({ lifetime: descriptor.lifetime }); + } + } catch (error) { + if (root !== undefined) await rm(root, { force: true, recursive: true }); + throw error; + } + const owner = renderer.createGeneratedRuntimeState({ definition, driver }); + try { + const bindings = await owner.requestBindings({ signal }); + let closed = false; + return Object.freeze({ + context: { + noticeLedger: bindings.noticeLedger, + state: bindings.state, + }, + async close() { + if (closed) return; + closed = true; + try { + await bindings.close(); + } finally { + try { + await owner.close(); + } finally { + if (root !== undefined) await rm(root, { force: true, recursive: true }); + } + } + }, + }); + } catch (error) { + try { + await owner.close(); + } finally { + if (root !== undefined) await rm(root, { force: true, recursive: true }); + } + throw error; + } +}; + interface PreparedRender { + readonly close: () => Promise; readonly collected: readonly AgentProgressUpdate[]; readonly dispatcher: AgentRuntime.AgentRenderDispatcher; readonly invocation: AgentRenderInvocation; @@ -423,6 +529,8 @@ const prepareRender = async ( const invocation = invocationFor(resolved.kind, resolved.provenance.routeId, options, resolved.provenance); const collected: AgentProgressUpdate[] = []; const context = options.context ?? {}; + const signal = options.signal ?? new AbortController().signal; + const mounted = await mountManifestState(resolved, context, renderer, signal); /** * Every render collects progress for {@link RenderedRoute.progress}, then * forwards it to the caller's reporter and to the dispatcher's, which is @@ -440,6 +548,7 @@ const prepareRender = async ( const dispatcher = renderer.createAgentRenderDispatcher({ execute: async (request) => streamOf(await renderer.runAgentRequest({ ...context, + ...mounted.context, invocation: { ...requestInvocation(request.invocation, resolved.provenance.routeId), ...context.invocation, @@ -456,11 +565,12 @@ const prepareRender = async ( )))), }, options.limits === undefined ? {} : { limits: options.limits }); return { + close: mounted.close, collected, dispatcher, invocation, resolved, - signal: options.signal ?? new AbortController().signal, + signal, }; }; @@ -490,22 +600,23 @@ export const renderRoute = async ( target: RenderRouteTarget, options: RenderRouteOptions = {}, ): Promise => { - const { collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); - let document: AgentDocument; + const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); try { - document = await dispatcher.dispatch({ invocation, signal }); + const document = await dispatcher.dispatch({ invocation, signal }); + return Object.freeze({ + document, + invocation, + progress: Object.freeze([...collected]), + provenance: resolved.provenance, + ...(resolved.module.resultSchema === undefined + ? {} + : { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) }), + }); } catch (error) { throw renderFailure(error, invocation, resolved.provenance); + } finally { + await close(); } - return Object.freeze({ - document, - invocation, - progress: Object.freeze([...collected]), - provenance: resolved.provenance, - ...(resolved.module.resultSchema === undefined - ? {} - : { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) }), - }); }; export interface RenderedRouteEvents extends RenderedRoute { @@ -524,7 +635,7 @@ export const renderRouteEvents = async ( target: RenderRouteTarget, options: RenderRouteOptions = {}, ): Promise => { - const { collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); + const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options); const events: AgentRenderEvent[] = []; const reader = dispatcher.stream({ invocation, signal }).getReader(); try { @@ -535,6 +646,8 @@ export const renderRouteEvents = async ( } } catch (error) { throw renderFailure(error, invocation, resolved.provenance); + } finally { + await close(); } const complete = events.findLast((event) => event.type === 'complete'); if (complete === undefined) { diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index a4fe7abb7..b560c675c 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -134,6 +134,19 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re const server = compiled.model.mcpServers[0]; expect(server).toMatchObject({ id: 'mcp:curator', name: 'curator' }); const entry = join(output, 'portable', server!.args![0]!); + const worker = entry.replace(/\.mjs$/u, '-flight.mjs'); + const statelessSources = await Promise.all([entry, worker].map((path) => readFile(path, 'utf8'))); + for (const source of statelessSources) { + for (const forbidden of [ + '@agent-bundle/runtime/mount', + 'createGeneratedRuntimeState', + 'node:sqlite', + 'createSqliteStateDriver', + 'noticeLedger: bindings.noticeLedger', + ]) { + expect(source).not.toContain(forbidden); + } + } const client = new Client({ name: 'generated-route-test', version: '0.0.0' }); const transport = new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' }); let diagnostics = ''; diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 7f7ef345a..b1c06ef62 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -1,11 +1,12 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { requestEventRuntime } from '../src/events/ipc.ts'; import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; @@ -13,7 +14,10 @@ const execFile = promisify(executeFile); const fixtureRoot = resolve(import.meta.dirname, '../fixtures/route-harness'); interface McpJson { - readonly mcpServers: Readonly>; + readonly mcpServers: Readonly>; + }>>; } /** @@ -21,9 +25,9 @@ interface McpJson { * proof journey for the consumer test harness (#103 cost rule). * * One tarball (the run-level shared pack), one install, one artifact build, - * one verified source removal, one spawned server. Every per-route assertion - * runs inside that one client session, because a second spawn would double - * the only cost this level has and prove the same thing twice. + * one verified source removal, and two spawned servers over that same built + * artifact. The second spawn exists only to prove workspace-durable state and + * notices survive a process restart; every other route stays in one session. * * The generated entry runs as a separate operating-system process, out of a * built artifact, over real stdio framing after the project source and config @@ -32,7 +36,7 @@ interface McpJson { * (tests/projection/) covers the same route protocol surface at a fraction of * the cost and explicitly does not claim any of this. */ -it('serves every compiled route and embedded App after packed consumer source deletion', async () => { +it('serves compiled routes and durable state across packed process restarts', async () => { const [agentBundle, runtime] = await Promise.all([ sharedPackedTarball('agent-bundle'), sharedPackedTarball('runtime'), @@ -61,71 +65,160 @@ it('serves every compiled route and embedded App after packed consumer source de const pluginRoot = join(artifact, 'claude'); const manifest = JSON.parse(await readFile(join(pluginRoot, '.mcp.json'), 'utf8')) as McpJson; + const serverConfig = manifest.mcpServers['harness']!; // Claude Code expands ${CLAUDE_PLUGIN_ROOT} to the installed plugin root // before it spawns the server; the test stands in for the host there. - const entry = manifest.mcpServers['harness']!.args[0].replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot); + const expandPluginRoot = (value: string): string => + value.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot); + const entry = expandPluginRoot(serverConfig.args[0]); + const serverEnvironment = Object.fromEntries( + Object.entries(serverConfig.env ?? {}).map(([key, value]) => [key, expandPluginRoot(value)]), + ); + expect(serverConfig.env).toMatchObject({ + AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}', + }); + const env = { + ...installedEnvironment(), + ...serverEnvironment, + } as Record; + const worker = entry.replace(/\.mjs$/u, '-flight.mjs'); + const workerSource = await readFile(worker, 'utf8'); + expect(workerSource).toContain('node:sqlite'); + expect(workerSource).toContain('createSqliteStateDriver'); + expect(workerSource).toContain('AGENT_BUNDLE_PLUGIN_ROOT'); + expect(workerSource).toMatch(/new URL\(["']\.\.["'], import\.meta\.url\)/u); const deletedSource = await removeProjectSource({ projectRoot: project }); - await using session = await openPackedMcpServer({ + const firstSession = await openPackedMcpServer({ cwd: project, deletedSource, entry, - env: installedEnvironment() as Record, + env, }); + let noticeId: string; + try { + expect(firstSession.provenance.proofLevel).toBe('packed-deleted-source'); + expect(firstSession.provenance.pid).toBeGreaterThan(0); + expect(firstSession.provenance.sourceRemoved).toEqual(['agent-bundle.config.ts', 'src']); - expect(session.provenance.proofLevel).toBe('packed-deleted-source'); - expect(session.provenance.pid).toBeGreaterThan(0); - expect(session.provenance.sourceRemoved).toEqual(['agent-bundle.config.ts', 'src']); - - const tools = await session.client.listTools(); - expect(tools.tools.map((tool) => tool.name).sort()).toEqual(['catalog', 'echo', 'unavailable']); - const resources = await session.client.listResources(); - expect(resources.resources).toEqual(expect.arrayContaining([ - expect.objectContaining({ mimeType: 'text/markdown', uri: 'harness://notes' }), - expect.objectContaining({ mimeType: 'text/html;profile=mcp-app', uri: 'ui://harness/panel' }), - ])); + const tools = await firstSession.client.listTools(); + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + 'catalog', + 'echo', + 'journal', + 'publish-notice', + 'unavailable', + ]); + const resources = await firstSession.client.listResources(); + expect(resources.resources).toEqual(expect.arrayContaining([ + expect.objectContaining({ mimeType: 'text/markdown', uri: 'harness://notes' }), + expect.objectContaining({ mimeType: 'text/html;profile=mcp-app', uri: 'ui://harness/panel' }), + ])); - // Per-route assertions iterate inside this one session: the packed cost is - // the spawn, not the calls. - await expect(session.client.callTool({ arguments: { message: 'packed' }, name: 'echo' })) - .resolves.toMatchObject({ - content: [{ text: '# Echo\n\npacked', type: 'text' }, { text: expect.stringContaining('workspace:'), type: 'text' }], - structuredContent: { message: 'packed', operationId: 'tool:harness/echo' }, + await expect(firstSession.client.callTool({ arguments: { message: 'packed' }, name: 'echo' })) + .resolves.toMatchObject({ + content: [{ text: '# Echo\n\npacked', type: 'text' }, { text: expect.stringContaining('workspace:'), type: 'text' }], + structuredContent: { message: 'packed', operationId: 'tool:harness/echo' }, + }); + await expect(firstSession.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' })) + .resolves.toMatchObject({ + content: [ + { text: 'catalog: mystery', type: 'text' }, + { text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' }, + ], + structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] }, + }); + await expect(firstSession.client.callTool({ + arguments: { note: 'packed durable proof' }, + name: 'journal', + })).resolves.toMatchObject({ + structuredContent: { entries: [{ note: 'packed durable proof' }], revision: 1 }, }); - await expect(session.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' })) - .resolves.toMatchObject({ - content: [ - { text: 'catalog: mystery', type: 'text' }, - { text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' }, - ], - structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] }, + const published = await firstSession.client.callTool({ + arguments: { message: 'cross-process notice', recipientSession: 'proof-session' }, + name: 'publish-notice', }); - await expect(session.client.callTool({ arguments: {}, name: 'unavailable' })) - .resolves.toMatchObject({ isError: true, structuredContent: { available: false } }); - await expect(session.client.readResource({ uri: 'harness://notes' })).resolves.toEqual({ - contents: [{ mimeType: 'text/markdown', text: '# Notes for harness://notes', uri: 'harness://notes' }], - }); - const panel = await session.client.readResource({ uri: 'ui://harness/panel' }); - expect(panel.contents).toHaveLength(1); - const panelContent = panel.contents[0]; - expect(panelContent).toMatchObject({ - mimeType: 'text/html;profile=mcp-app', - uri: 'ui://harness/panel', - }); - if (panelContent === undefined || !('text' in panelContent)) { - throw new TypeError('The embedded panel resource did not return inline text.'); + expect(published).toMatchObject({ + structuredContent: { noticeId: expect.any(String), state: 'pending' }, + }); + noticeId = String((published.structuredContent as { noticeId: string }).noticeId); + await expect(firstSession.client.callTool({ arguments: {}, name: 'unavailable' })) + .resolves.toMatchObject({ isError: true, structuredContent: { available: false } }); + await expect(firstSession.client.readResource({ uri: 'harness://notes' })).resolves.toEqual({ + contents: [{ mimeType: 'text/markdown', text: '# Notes for harness://notes', uri: 'harness://notes' }], + }); + const panel = await firstSession.client.readResource({ uri: 'ui://harness/panel' }); + expect(panel.contents[0]).toMatchObject({ + mimeType: 'text/html;profile=mcp-app', + uri: 'ui://harness/panel', + }); + const panelContent = panel.contents[0]; + if (panelContent === undefined || !('text' in panelContent)) { + throw new TypeError('The embedded panel resource did not return inline text.'); + } + expect(panelContent.text).toContain('route-harness panel'); + expect(panelContent.text).toMatch(/]+(?:src|href)=/iu); + await expect(firstSession.client.getPrompt({ + arguments: { note: 'chapter one' }, + name: 'summarize', + })).resolves.toEqual({ + messages: [{ content: { text: 'Summarize chapter one', type: 'text' }, role: 'user' }], + }); + expect(firstSession.stderr()).not.toContain('"jsonrpc"'); + } finally { + await firstSession.close(); } - const panelHtml = panelContent.text; - expect(panelHtml).toContain('route-harness panel'); - expect(panelHtml).toMatch(/]+(?:src|href)=/iu); - await expect(session.client.getPrompt({ arguments: { note: 'chapter one' }, name: 'summarize' })).resolves.toEqual({ - messages: [{ content: { text: 'Summarize chapter one', type: 'text' }, role: 'user' }], - }); - // The generated entry keeps stdout for the protocol; anything the routes or - // the warm worker print has to arrive on stderr instead. - expect(session.stderr()).not.toContain('"jsonrpc"'); + const stateRoot = join(pluginRoot, 'state'); + expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ + expect.stringMatching(/\.sqlite$/u), + ])); + + const secondSession = await openPackedMcpServer({ + cwd: project, + deletedSource, + entry, + env, + }); + try { + await expect(secondSession.client.callTool({ arguments: {}, name: 'journal' })) + .resolves.toMatchObject({ + structuredContent: { entries: [{ note: 'packed durable proof' }], revision: 1 }, + }); + const artifactManifest = JSON.parse( + await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8'), + ) as { readonly project: { readonly revision: string } }; + let eventResponse: unknown; + try { + eventResponse = await requestEventRuntime({ + artifactEpoch: artifactManifest.project.revision, + endpointId: `${artifactManifest.project.revision}:claude:${dirname(dirname(resolve(entry)))}`, + event: 'tool/after', + hostContractRevision: 'packed-proof', + native: { + cwd: project, + hook_event_name: 'PostToolUse', + session_id: 'proof-session', + tool_input: { proof: true }, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'packed-proof', + transcript_path: join(project, 'transcript.jsonl'), + }, + signal: AbortSignal.timeout(10_000), + target: 'claude', + timeoutMs: 10_000, + }); + } catch (error) { + throw new Error(`Packed event route failed.\nserver stderr:\n${secondSession.stderr()}`, { cause: error }); + } + expect(JSON.stringify(eventResponse)).toContain(noticeId); + expect(JSON.stringify(eventResponse)).toContain('cross-process notice'); + expect(secondSession.stderr()).not.toContain('"jsonrpc"'); + } finally { + await secondSession.close(); + } } finally { await rm(consumer, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 457cdbac2..ef493b0d2 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -1,7 +1,13 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from '@rstest/core'; import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state'; +import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; import { z } from 'zod'; +import stateDefinition from '../../fixtures/route-harness/src/state.ts'; import { AgentTestError } from '../../src/test/errors.ts'; import { getMcpPrompt, @@ -24,7 +30,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'echo', 'unavailable']); + expect(surface.tools).toEqual(['catalog', 'echo', 'journal', 'publish-notice', 'unavailable']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -34,6 +40,8 @@ describe('the in-memory MCP projection level', () => { 'resource:harness/notes', 'tool:harness/catalog', 'tool:harness/echo', + 'tool:harness/journal', + 'tool:harness/publish-notice', 'tool:harness/unavailable', ], serverName: 'harness', @@ -139,6 +147,48 @@ describe('the in-memory MCP projection level', () => { expect(closes).toBe(1); }); + it('persists journal state and publishes a pending notice through one mounted session', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-state-')); + const session = await openInMemoryMcpServer({ + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + }, + }); + try { + await expect(session.client.callTool({ + arguments: { note: 'protocol proof' }, + name: 'journal', + })).resolves.toMatchObject({ + structuredContent: { + entries: [{ note: 'protocol proof' }], + revision: 1, + }, + }); + await expect(session.client.callTool({ + arguments: {}, + name: 'journal', + })).resolves.toMatchObject({ + structuredContent: { + entries: [{ note: 'protocol proof' }], + revision: 1, + }, + }); + await expect(session.client.callTool({ + arguments: { message: 'next event', recipientSession: 'proof-session' }, + name: 'publish-notice', + })).resolves.toMatchObject({ + structuredContent: { + noticeId: expect.any(String), + state: 'pending', + }, + }); + } finally { + await session.close(); + await rm(root, { force: true, recursive: true }); + } + }); + it('leaves the browser App surface off the in-memory server', async () => { const surface = await listMcpSurface(); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index b0f5759ec..7133634c2 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -66,6 +66,41 @@ describe('renderRoute through the real renderer', () => { }); }); + it('auto-mounts isolated declared state into each route-unit render', async () => { + const first = await renderRoute('tool:harness/journal', { input: { note: 'route-unit proof' } }); + const second = await renderRoute('tool:harness/journal'); + + expectDocument(first) + .toHaveStatus('success') + .toContainMarkdown('route-unit proof') + .toHaveValue({ entries: [{ note: 'route-unit proof' }], revision: 1 }); + expectDocument(second) + .toHaveStatus('success') + .toHaveValue({ entries: [], revision: 0 }); + }); + + it('preserves caller-supplied state instead of auto-mounting the manifest definition', async () => { + const entries: Array<{ readonly note: string }> = []; + const state = { + lifetime: 'workspace-durable', + changes: async function*() {}, + dispatch: async (_name: string, payload: { readonly note: string }) => { + entries.push(payload); + return { replayed: false, revision: 41, state: { entries } }; + }, + read: async () => ({ revision: 41, state: { entries } }), + } as never; + const rendered = await renderRoute('tool:harness/journal', { + context: { state }, + input: { note: 'caller-owned' }, + }); + + expectDocument(rendered).toHaveValue({ + entries: [{ note: 'caller-owned' }], + revision: 41, + }); + }); + it('records progress even when the caller supplies its own reporter', async () => { const delegated: unknown[] = []; const rendered = await renderRoute('tool:harness/echo', { @@ -138,7 +173,7 @@ describe('renderRoute through the real renderer', () => { expectDocument(rendered) .toHaveStatus('success') .toContainMarkdown('Observed tool/after from claude.') - .toHaveValue({ event: 'tool/after', invocationKind: 'event', tool: 'Write' }); + .toHaveValue(undefined); }); it('renders a route module handed in directly, without the compiled manifest', async () => { diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index addad2713..46a9e3c9f 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -71,6 +71,8 @@ describe('the compiled test manifest', () => { 'resource:harness/notes', 'tool:harness/catalog', 'tool:harness/echo', + 'tool:harness/journal', + 'tool:harness/publish-notice', 'tool:harness/unavailable', ]); expect(manifest.diagnostics).toEqual([]); @@ -87,6 +89,12 @@ describe('the compiled test manifest', () => { }); expect(manifest.proofLevel).toBe('route-unit'); expect(manifest.projectRoot).toBe(fixtureRoot); + expect(manifest.state).toEqual({ + id: 'route-harness/journal', + lifetime: 'workspace-durable', + relativePath: 'src/state.ts', + source: resolve(fixtureRoot, 'src/state.ts'), + }); expect(manifest.targets).toEqual(['claude']); expect(manifest.apps).toEqual({ panel: { @@ -210,6 +218,7 @@ describe('the generated route registry', () => { expect(loaders).toContain('"event:tool/after": () => import('); expect(loaders).toContain('"tool:harness/echo": () => import('); + expect(loaders).toContain('"tool:harness/journal": () => import('); expect(loaders).toContain('"tool:harness/unavailable": () => import('); expect(loaders).not.toContain('app:harness/panel'); expect(loaders).toContain('"resource:harness/notes": () => import('); @@ -221,6 +230,8 @@ describe('the generated route registry', () => { it('carries the manifest and the registry version the helpers require', () => { expect(source).toContain(`version: ${String(AGENT_TEST_REGISTRY_VERSION)}`); expect(source).toContain('globalThis[Symbol.for("agent-bundle/test-route-registry")]'); + expect(source).toContain('stateLoader: () => import('); + expect(source).toContain('/src/state.ts'); expect(JSON.parse(/manifest: JSON\.parse\((".*?")\),/u.exec(source)![1]!) as string) .toBe(JSON.stringify(manifest)); }); From 74d2e58fef519a62432ae14bfb2fc7c05ed9bfcb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 00:22:46 +0000 Subject: [PATCH 3/3] chore: changeset for request-scope mounting; parse npm 12 pack output in the packed consumer proof --- .../generated-request-scope-state-mounting.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/generated-request-scope-state-mounting.md diff --git a/.changeset/generated-request-scope-state-mounting.md b/.changeset/generated-request-scope-state-mounting.md new file mode 100644 index 000000000..e54609601 --- /dev/null +++ b/.changeset/generated-request-scope-state-mounting.md @@ -0,0 +1,22 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Mount the #98 state kernel and #99 notice ledger into generated request +scopes (#233). `@agent-bundle/runtime/mount` exports +`createGeneratedRuntimeState`, which owns the project state store and the +notice ledger over one driver and returns typed-failing handles when that +driver cannot open. `createWarmFlightHost` accepts optional `runtimeState` +ownership so the warm host closes the generated owner with the process. +Conventional `src/state.ts` default-exports `defineState({ ... })` with +statically extracted literal `id` and `lifetime` (`AB4818`–`AB4820`); +`state: false` opts out. Generated MCP flight workers, routed CLI bins, and +rendered workers and scripts mount `state` and `noticeLedger` into every +request scope — memory driver for `request`/`process` lifetimes, +`node:sqlite` at the `AGENT_BUNDLE_PLUGIN_ROOT`-anchored `state/` root for +`workspace-durable`, and a cwd `.agent-bundle/state` fallback for package +bins. Event invocations run notice admission once in the render scope with +invocation identity forwarded from the host process. Stateless projects +emit none of this. The test harness auto-mounts declared state at +route-unit level, and `openInMemoryMcpServer` accepts a state owner.