diff --git a/.changeset/remove-runtime-provider-mcp.md b/.changeset/remove-runtime-provider-mcp.md new file mode 100644 index 000000000..a9326f94a --- /dev/null +++ b/.changeset/remove-runtime-provider-mcp.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Remove the runtime provider MCP contract from `agent-bundle/api`. `DevRuntimeSession` no longer has `mcpRegistry` or `clientSurface()`, `createRuntimeMcpRegistry` and the `DevRuntimeMcp*`, `RuntimeMcp*`, `DevRuntimeProviderMcpRegistry`, and `DevRuntimeClientSurfaceEndpoint` types are gone, run inspections (`DevRuntimeInspectionEnvelope`) no longer carry `app`, and `DevRuntimeEventInput` and the root `RuntimeEvent` type drop the `runtime.mcp.*` events with their `mcpRegistryRevision`, `mcpSessionId`, and `mcpSessionRevision` fields. The Workbench rejects a run inspection that still includes `app` (`AB8206`). `createRuntimeGenerationStore` and the generation store contracts are unchanged. (#855) diff --git a/agent-patterns/effect-errors.md b/agent-patterns/effect-errors.md index 12b6d3783..de59169fd 100644 --- a/agent-patterns/effect-errors.md +++ b/agent-patterns/effect-errors.md @@ -26,8 +26,8 @@ onto those classes at the boundary. It does not replace them. - Typed fail, framework-process class (dev seam / eval service, extends `YieldableFrameworkError` or `YieldableCodedError` from `packages/agent-bundle/src/effect/errors.ts`): - `return yield* new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CLOSED', message)`. - `Effect.fail(new RuntimeMcpRegistryError(...))` is equally valid; do not + `return yield* new RuntimeGenerationStoreError('RUNTIME_GENERATION_CLOSED', message)`. + `Effect.fail(new RuntimeGenerationStoreError(...))` is equally valid; do not churn call sites for style. - Defect (bug): `Effect.die(defect)`: not for expected fail-closed states. - Recover: `Effect.catch`, `Effect.catchTag` when the error is tagged. diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index ad6c2a387..ea26bd484 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -39,7 +39,6 @@ packages/ src/dev/runtime-app-message-limits.ts src/dev/runtime-controller.ts src/dev/runtime-generation-store.ts - src/dev/runtime-mcp-registry.ts src/dev/runtime-provider-loader.ts src/dev/runtime-provider.ts src/dev/runtime-routes.ts @@ -80,7 +79,6 @@ packages/ tests/rsc-runtime-optional-packaging.test.ts tests/rsc-runtime-topology-script.test.ts tests/runtime-generation-store.test.ts - tests/runtime-mcp-registry.test.ts tests/runtime-provider.test.ts tests/runtime-routes.test.ts workbench/ @@ -208,12 +206,10 @@ is local to that invocation. Durable Runtime export/evaluation promotion, provider adapters, authenticated APIs, and timeline UI ownership are not wired by this topology. -Artifact epoch, runtime generation, state version, definition digest, MCP -session, and run identity are separate axes. A generation is staged and -validated, then atomically activated; leases keep the immutable generation -available for a selected run. Failed preparation retains the last good active -generation. Static MCP definitions and the broker survive independently of -generation-pinned invocations and binding authority. +Artifact epoch, runtime generation, state version, and run identity are +separate axes. A generation is staged and validated, then atomically +activated; leases keep the immutable generation available for a selected run. +Failed preparation retains the last good active generation. Host-facing adoption is a further, separately gated axis (#179 / #218 stage 4). `EpochAdoptionPolicy` sits between `artifact.available` and the two surfaces a diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 2dc3185bc..85f5eb737 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -66,8 +66,7 @@ Internals keep the existing classes. Framework-process error classes, the ones raised inside Effect programs in the dev seam and the eval service whose declarations no package export -reaches (today: `DevCoordinatorCloseError`, `RuntimeMcpRegistryError` / -`RuntimeMcpRegistryCloseError`, `RuntimeGenerationStoreError` / +reaches (today: `DevCoordinatorCloseError`, `RuntimeGenerationStoreError` / `RuntimeGenerationStoreCloseError`, `DevRuntimeProviderLoadError`, `ScriptPlaygroundFailure` / `ScriptPlaygroundAbortError`, `LifecycleReplayRequestError`, `ArtifactInspectionServiceError`, diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 941ea229a..9952fb004 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -80,15 +80,18 @@ console before rejecting. The published Agent Bundle library is built with Rslib. This example's separate production RSC/runtime artifacts are built by its explicit Rsbuild production command (`pnpm --filter @agent-bundle/rsc-agent-runtime-demo build`); its provider -uses a separate long-lived Rsbuild development/HMR session only when an -`agent-bundle dev` project opts into `dev.runtime.provider`. The session uses -development entries, output roots, and HMR topology while compiling every +uses a separate long-lived Rsbuild development session only when an +`agent-bundle dev` project opts into `dev.runtime.provider`. That session +compiles each change into a runtime generation, publishes its hook, MCP tool, +resource, and App surfaces to the Workbench runtime routes, and serves +generation assets through them; no browser connects to its loopback Rsbuild +server. The session uses development entries and output roots while compiling every environment in production mode: production decoders cannot read development Flight payloads, and Rsbuild only inlines the App's scripts and styles in production mode. `@rsbuild/plugin-react` is configured as -`pluginReact({ fastRefresh: false })`: the compiler App is an opaque srcdoc -child with `hmr: false`, and the runtime-surface outer document owns the one -HMR socket. The `widget` and `app` web environments set +`pluginReact({ fastRefresh: false })` and the App environment sets +`hmr: false`: the self-contained App document never receives a browser HMR +credential or connection. The `widget` and `app` web environments set `overrideBrowserslist: ['chrome >= 144']` for the Chromium MCP App hosts (Cursor 3.18.25 still ships Chromium 144; Claude Desktop Electron 42 and Cursor 3.19.7 ship Chromium 148; ChatGPT/Codex Desktop reports Chromium 151). diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index b6ad82ee6..6773841cb 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -5,7 +5,6 @@ import { dirname, join } from 'node:path'; import { defineConfig, type RsbuildConfig, - type RsbuildDevServer, type RsbuildPlugin, type Rspack, } from '@rsbuild/core'; @@ -29,11 +28,10 @@ import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js'; export const rscRuntimeBrowserHost = Object.freeze(['chrome >= 144'] as const); /** - * The compiler App is an opaque srcdoc child (`hmr: false`) and the - * runtime-surface outer document owns the one HMR socket. Fast Refresh - * would inject a refresh runtime into a self-contained HTML document that - * must never receive a browser HMR credential. The Flight widget is client - * JS, not a refreshable SPA. + * The development session serves no browser client (`hmr: false`). Fast + * Refresh would inject a refresh runtime into a self-contained HTML document + * that must never receive a browser HMR credential. The Flight widget is + * client JS, not a refreshable SPA. */ export const rscRuntimeReactPluginOptions = Object.freeze({ fastRefresh: false } as const); @@ -75,13 +73,6 @@ const isCompileEnvironmentName = (value: string): value is RscRuntimeCompileEnvi export interface RscRuntimeRsbuildConfigOptions { readonly compilerRoot?: string; readonly mode: 'development' | 'production'; - /** - * Provider-owned reload signal: invoked once for each later successful, - * changed App environment compilation. This callback replaces - * `hot.send('full-reload')`, so no consumer has to parse Rsbuild's private - * WebSocket envelope to learn that the App surface changed. - */ - readonly onAppReload?: () => void; readonly onCompile?: Readonly<{ /** * Allocates the monotonic identity for one completed MultiStats cohort. @@ -122,65 +113,6 @@ export interface RscRuntimeRsbuildConfigOptions { }>; } -const appOutputContentHash = (stats: Rspack.Stats): string | undefined => { - try { - const assets = [...stats.compilation.getAssets()].sort((left, right) => - left.name < right.name ? -1 : left.name > right.name ? 1 : 0); - const hash = createHash('sha256'); - hash.update(`${assets.length}:`); - for (const asset of assets) { - const name = Buffer.from(asset.name); - const content = asset.source.buffer(); - hash.update(`${name.byteLength}:`); - hash.update(name); - hash.update(`${content.byteLength}:`); - hash.update(content); - } - return hash.digest('hex'); - } catch { - return undefined; - } -}; - -const runtimeAppReloadPlugin = ( - onAppReload: NonNullable, -): RsbuildPlugin => { - let devServer: RsbuildDevServer | undefined; - let lastAppOutput: string | undefined; - return { - name: 'agent-bundle:rsc-runtime-app-reload', - setup(api) { - api.onAfterCreateCompiler(({ environments }) => { - if (environments.app === undefined) { - throw new Error('RSC runtime compiler did not expose the App environment.'); - } - }); - api.onBeforeStartDevServer(({ server }) => { - devServer = server; - lastAppOutput = undefined; - }); - api.onCloseDevServer(() => { - devServer = undefined; - lastAppOutput = undefined; - }); - api.onAfterEnvironmentCompile(({ environment, isFirstCompile, stats }) => { - if (devServer === undefined || environment.name !== 'app' || stats === undefined || stats.hasErrors()) return; - // Rspack stats hashes can change across watch completions whose - // emitted App bytes are identical. The complete asset set is the - // browser-visible identity; an unreadable set remains unidentifiable - // and reloads at least once without clobbering the retained identity. - const output = appOutputContentHash(stats); - if (output !== undefined) { - if (lastAppOutput === output) return; - lastAppOutput = output; - } - if (isFirstCompile) return; - onAppReload(); - }); - }, - }; -}; - const emitRuntimeManifest = (): RsbuildPlugin => ({ apply: 'build', name: 'emit-rsc-agent-runtime-manifest', @@ -316,10 +248,10 @@ const runtimeCompileObserverPlugin = ( const environmentHashes = Object.freeze(Object.fromEntries( compileEnvironmentNames.map((name) => [name, cohortHashes.get(name) as string]), )) as RscRuntimeCompileEnvironmentHashes; - // The App environment ships through its own dev-server surface, so - // only the rsc and widget children define the source revision that - // decides whether a new runtime generation is needed. The App child - // hash still selects which staged App checkpoint joins the cohort. + // Only the rsc and widget children define the source revision that + // decides whether a new runtime generation is needed; an App-only + // edit joins the next generation. The App child hash still selects + // which staged App checkpoint joins the cohort. const hashes = (['rsc', 'widget'] as const).map((name) => [name, cohortHashes.get(name) as string]); const sourceRevision = createHash('sha256').update(JSON.stringify(hashes)).digest('hex'); snapshot = await observer.capture({ @@ -383,8 +315,7 @@ export const createRscRuntimeRsbuildConfig = ( // Port 0 lets the OS assign the listener. Rsbuild's default (3000 with an // incrementing probe) makes every concurrent runtime session on a host // race for the same first candidate, which surfaces as EADDRINUSE when - // suites run in parallel. Consumers read the resolved port back from - // `rsbuild.context.devServer`. + // suites run in parallel. server: { host: '127.0.0.1', port: 0, printUrls: false }, } : {}), plugins: [ @@ -392,7 +323,6 @@ export const createRscRuntimeRsbuildConfig = ( pluginRSC({ environments: { server: 'rsc', client: 'widget' } }), emitRuntimeManifest(), selfContainedAppPlugin(), - ...(options.onAppReload === undefined ? [] : [runtimeAppReloadPlugin(options.onAppReload)]), ...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]), ], environments: { @@ -456,9 +386,8 @@ export const createRscRuntimeRsbuildConfig = ( app: { ...(development ? { dev: { - // The trusted runtime-surface outer document owns the one HMR - // socket. The compiler App itself runs in an opaque srcdoc child - // and must never receive a browser HMR credential or connection. + // The development session serves no browser client; the compiled + // App must never receive a browser HMR credential or connection. hmr: false, liveReload: false, }, diff --git a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts index 2b47b48b6..a1f08bd2c 100644 --- a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts +++ b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts @@ -2,7 +2,7 @@ import { lstat, mkdir, readdir, readFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { dirname, isAbsolute, join } from 'node:path'; -import { canonicalJson, digestValue, freezeJson } from './canonical-json.js'; +import { canonicalJson, freezeJson } from './canonical-json.js'; import { assertInside, copyTree, digestBytes, fsyncPath, isSafeSegment, writeFileDurably } from './durable-tree.js'; import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; import type { @@ -18,7 +18,6 @@ import type { } from '../runtime/contracts.js'; import type { JsonObject, JsonValue } from 'agent-bundle'; import type { - DevRuntimeMcpServerDescriptor, DevRuntimePreparedProject, RuntimeGenerationActivationGuard, RuntimeGenerationAsset, @@ -546,71 +545,17 @@ const validateAppSurfaceAssets = ( } }; -const transportProjection = (preparedRuntime: DevRuntimePreparedProject): JsonValue => freezeJson({ - provider: preparedRuntime.provider, - servers: preparedRuntime.servers.map((server) => ({ - args: server.args === undefined ? undefined : [...server.args], - command: server.command, - cwd: server.cwd, - env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), - headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), - id: server.id, - name: server.name, - source: server.source, - targets: [...server.targets], - transport: server.transport, - url: server.url, - })), -}); - -type RuntimeDefinitionPreparedProject = Readonly<{ - readonly apps: readonly RscRuntimeAppDefinition[]; -}>; - -const appDefinitions = (preparedRuntime: RuntimeDefinitionPreparedProject): readonly RscRuntimeAppDefinition[] => +const appDefinitions = (preparedRuntime: DevRuntimePreparedProject): readonly RscRuntimeAppDefinition[] => freezeJson(preparedRuntime.apps.map((app) => ({ - ...(app._meta === undefined ? {} : { _meta: app._meta }), id: app.id, name: app.name, resourceUri: app.resourceUri, - serverId: app.serverId, - serverName: app.serverName, - targets: [...app.targets], })).sort((left, right) => { const leftJson = canonicalJson(left); const rightJson = canonicalJson(right); return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0; })) as unknown as readonly RscRuntimeAppDefinition[]; -const runtimeDefinitionProjection = ( - definition: SerializedRuntimeDefinition, - preparedRuntime: RuntimeDefinitionPreparedProject, -): JsonValue => freezeJson({ - apps: appDefinitions(preparedRuntime), - definition, -}); - -export const runtimeDefinitionDigest = ( - definition: SerializedRuntimeDefinition, - preparedRuntime: RuntimeDefinitionPreparedProject, -): string => digestValue(runtimeDefinitionProjection(definition, preparedRuntime)); - -const descriptors = ( - preparedRuntime: DevRuntimePreparedProject, - definition: SerializedRuntimeDefinition, - definitionDigest: string, - serverDigest: string, - transportDigest: string, -): readonly DevRuntimeMcpServerDescriptor[] => Object.freeze(preparedRuntime.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ - definitionDigest, - name: server.name, - resources: Object.freeze(definition.resources.map((resource) => freezeJson(resource) as JsonObject)), - serverDigest, - target, - tools: Object.freeze(definition.tools.map((tool) => freezeJson(tool) as JsonObject)), - transportDigest, -})))); - const metadataFromSnapshot = async ( snapshot: RscRuntimeCapturedGenerationSnapshot, assets: readonly RuntimeGenerationAsset[], @@ -625,14 +570,6 @@ const metadataFromSnapshot = async ( if (canonicalJson(parsedDefinition) !== definitionBytes.toString('utf8')) { throw new Error('Captured runtime definition is not canonical.'); } - const capturedAppDefinitions = appDefinitions(snapshot.preparedRuntime); - const definitionDigest = runtimeDefinitionDigest(snapshot.definition, snapshot.preparedRuntime); - const environmentHashes = Object.freeze({ - rsc: digestValue(assets.filter((asset) => asset.path.startsWith('rsc/'))), - widget: digestValue(assets.filter((asset) => asset.path.startsWith('widget/'))), - }); - const serverDigest = digestValue(environmentHashes); - const transportDigest = digestValue(transportProjection(snapshot.preparedRuntime)); const entries = Object.freeze(Object.fromEntries(requiredEntries.map((entry) => { const assetsForEntry = runtimeAssets.entries[entry]; const path = assetsForEntry?.initial?.js?.[0]; @@ -640,16 +577,10 @@ const metadataFromSnapshot = async ( return [entry, `rsc/${path}`]; }))); return Object.freeze({ - appDefinitions: capturedAppDefinitions, - definitionDigest, + appDefinitions: appDefinitions(snapshot.preparedRuntime), entries, - environmentHashes, - preparedRevision: snapshot.preparedRuntime.sourceRevision, - serverDigest, - servers: descriptors(snapshot.preparedRuntime, parsedDefinition, definitionDigest, serverDigest, transportDigest), stateStoreId, surfaceAssets: surfaceAssets(snapshot.preparedRuntime, assets), - transportDigest, }); }; @@ -688,66 +619,27 @@ export const captureRuntimeGenerationSnapshot = async ( const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => { if (!isJsonObject(value)) throw new TypeError('Runtime generation metadata is malformed.'); - const required = ['appDefinitions', 'definitionDigest', 'entries', 'environmentHashes', 'preparedRevision', 'serverDigest', 'servers', 'stateStoreId', 'surfaceAssets', 'transportDigest']; + const required = ['appDefinitions', 'entries', 'stateStoreId', 'surfaceAssets']; if (Object.keys(value).some((key) => !required.includes(key)) || required.some((key) => !(key in value))) { throw new TypeError('Runtime generation metadata has an invalid schema.'); } - const { appDefinitions, definitionDigest, entries, environmentHashes, preparedRevision, serverDigest, servers, stateStoreId, surfaceAssets, transportDigest } = value; - if (typeof definitionDigest !== 'string' || typeof serverDigest !== 'string' || typeof transportDigest !== 'string' || - typeof preparedRevision !== 'string' || typeof stateStoreId !== 'string' || - !sha256Expression.test(definitionDigest) || !sha256Expression.test(serverDigest) || !sha256Expression.test(transportDigest) || - !Array.isArray(appDefinitions) || !isJsonObject(entries) || !isJsonObject(environmentHashes) || !Array.isArray(servers) || !isJsonObject(surfaceAssets)) { + const { appDefinitions, entries, stateStoreId, surfaceAssets } = value; + if (typeof stateStoreId !== 'string' || !Array.isArray(appDefinitions) || !isJsonObject(entries) || !isJsonObject(surfaceAssets)) { throw new TypeError('Runtime generation metadata is malformed.'); } - if (preparedRevision.length === 0 || stateStoreId.length === 0 || - Object.keys(entries).length !== requiredEntries.length || requiredEntries.some((entry) => typeof entries[entry] !== 'string') || - Object.keys(environmentHashes).length !== 2 || typeof environmentHashes.rsc !== 'string' || typeof environmentHashes.widget !== 'string' || - !sha256Expression.test(environmentHashes.rsc) || !sha256Expression.test(environmentHashes.widget)) { - throw new TypeError('Runtime generation environment digests are malformed.'); + if (stateStoreId.length === 0 || + Object.keys(entries).length !== requiredEntries.length || requiredEntries.some((entry) => typeof entries[entry] !== 'string')) { + throw new TypeError('Runtime generation entries are malformed.'); } const decodedAppDefinitions = appDefinitions.map((value): RscRuntimeAppDefinition => { if (!isJsonObject(value)) throw new TypeError('Runtime generation App definition is malformed.'); - const fields = ['_meta', 'id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; - const requiredFields = ['id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; - if (Object.keys(value).some((key) => !fields.includes(key)) || requiredFields.some((field) => !(field in value)) || - typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.resourceUri !== 'string' || - typeof value.serverId !== 'string' || typeof value.serverName !== 'string' || - !Array.isArray(value.targets) || !value.targets.every((target) => typeof target === 'string') || - ('template' in value && typeof value.template !== 'string')) { - throw new TypeError('Runtime generation App definition is malformed.'); - } - const meta = '_meta' in value ? freezeJson(value._meta) : undefined; - if (meta !== undefined && !isJsonObject(meta)) throw new TypeError('Runtime generation App definition metadata is malformed.'); - return Object.freeze({ - ...(meta === undefined ? {} : { _meta: meta }), - id: value.id, - name: value.name, - resourceUri: value.resourceUri, - serverId: value.serverId, - serverName: value.serverName, - targets: Object.freeze([...value.targets]), - }); - }); - - const decodedServers = servers.map((value): DevRuntimeMcpServerDescriptor => { - if (!isJsonObject(value)) throw new TypeError('Runtime generation server descriptor is malformed.'); - const fields = ['definitionDigest', 'name', 'resources', 'serverDigest', 'target', 'tools', 'transportDigest']; + const fields = ['id', 'name', 'resourceUri']; if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) || - typeof value.definitionDigest !== 'string' || typeof value.name !== 'string' || typeof value.serverDigest !== 'string' || - typeof value.target !== 'string' || typeof value.transportDigest !== 'string' || - !Array.isArray(value.resources) || !value.resources.every(isJsonObject) || !Array.isArray(value.tools) || !value.tools.every(isJsonObject)) { - throw new TypeError('Runtime generation server descriptor is malformed.'); + typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.resourceUri !== 'string') { + throw new TypeError('Runtime generation App definition is malformed.'); } - return Object.freeze({ - definitionDigest: value.definitionDigest, - name: value.name, - resources: Object.freeze(value.resources.map((resource) => freezeJson(resource) as JsonObject)), - serverDigest: value.serverDigest, - target: value.target, - tools: Object.freeze(value.tools.map((tool) => freezeJson(tool) as JsonObject)), - transportDigest: value.transportDigest, - }); + return Object.freeze({ id: value.id, name: value.name, resourceUri: value.resourceUri }); }); const decodedSurfaceAssets: Record = {}; @@ -773,15 +665,9 @@ const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => { } return Object.freeze({ appDefinitions: Object.freeze(decodedAppDefinitions), - definitionDigest, entries: Object.freeze(Object.fromEntries(requiredEntries.map((entry) => [entry, entries[entry] as string]))), - environmentHashes: Object.freeze({ rsc: environmentHashes.rsc, widget: environmentHashes.widget }), - preparedRevision, - serverDigest, - servers: Object.freeze(decodedServers), stateStoreId, surfaceAssets: Object.freeze(decodedSurfaceAssets), - transportDigest, }); }; @@ -811,19 +697,10 @@ export const validateRscRuntimeGenerationMetadata = async ( validateRuntimeAssetCoverage(runtimeAssets, input.assets); const definitionBytes = await readFile(join(input.root, ...definitionFile.split('/'))); const definition = parseDefinition(JSON.parse(definitionBytes.toString('utf8'))); - if (canonicalJson(definition) !== definitionBytes.toString('utf8') || - runtimeDefinitionDigest(definition, Object.freeze({ apps: metadata.appDefinitions })) !== metadata.definitionDigest) { - throw new TypeError('Runtime generation definition digest is inconsistent.'); + if (canonicalJson(definition) !== definitionBytes.toString('utf8')) { + throw new TypeError('Runtime generation definition is not canonical.'); } await validateClientReferenceRelationship(input.root, input.assets); - const expectedEnvironmentHashes = Object.freeze({ - rsc: digestValue(input.assets.filter((asset) => asset.path.startsWith('rsc/'))), - widget: digestValue(input.assets.filter((asset) => asset.path.startsWith('widget/'))), - }); - if (metadata.environmentHashes.rsc !== expectedEnvironmentHashes.rsc || metadata.environmentHashes.widget !== expectedEnvironmentHashes.widget || - metadata.serverDigest !== digestValue(expectedEnvironmentHashes)) { - throw new TypeError('Runtime generation implementation digest is inconsistent.'); - } const declaredSurfaceAssets = metadata.surfaceAssets as Readonly>; for (const [surface, descriptors] of Object.entries(declaredSurfaceAssets)) { const requestPaths = new Set(); @@ -835,11 +712,6 @@ export const validateRscRuntimeGenerationMetadata = async ( } } validateAppSurfaceAssets(metadata.appDefinitions, declaredSurfaceAssets); - for (const descriptor of metadata.servers) { - if (descriptor.definitionDigest !== metadata.definitionDigest || descriptor.serverDigest !== metadata.serverDigest || descriptor.transportDigest !== metadata.transportDigest) { - throw new TypeError('Runtime generation server descriptor digest is inconsistent.'); - } - } return metadata; }; diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index 06d67ea3d..599c95747 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -13,7 +13,6 @@ import { type RscRuntimeCompileFailureKind, type RscRuntimeCompileSnapshot, } from '../../rsbuild.config.js'; -import { projectName, projectVersion } from '../project-identity.js'; import { describeRspackCompileErrors } from './compile-diagnostics.js'; import { createRscEnvironmentCheckpointStore, @@ -25,7 +24,6 @@ import { captureRuntimeGenerationSnapshot, materializeRuntimeGeneration, rscRuntimeGenerationMetadataCodec, - runtimeDefinitionDigest, validateRscRuntimeGenerationMetadata, validateStagedRscEnvironmentCheckpoint, type RscRuntimeCapturedGenerationSnapshot, @@ -47,10 +45,8 @@ import { DevRuntimeGenerationConflictError, DevRuntimeUnavailableError, createRuntimeGenerationStore, - createRuntimeMcpRegistry, type DevRuntimeAsset, type DevRuntimeAssetRequest, - type DevRuntimeClientSurfaceEndpoint, type DevRuntimeDescriptor, type DevRuntimeDiagnostic, type DevRuntimeEventInput, @@ -58,13 +54,7 @@ import { type DevRuntimeGenerationStore, type DevRuntimeInspectionEnvelope, type DevRuntimeInvocationRequest, - type DevRuntimeMcpConnectionState, - type DevRuntimeMcpRegistryReconcileInput, - type DevRuntimeMcpSession, - type DevRuntimeMcpSessionBinding, - type DevRuntimeMcpSessionCloseObservation, type DevRuntimePreparedProject, - type DevRuntimeProviderMcpRegistry, type DevRuntimeReplayRequest, type DevRuntimeRun, type DevRuntimeSession, @@ -77,10 +67,6 @@ import { type RuntimeGenerationActivationGuard, type RuntimeGenerationCandidate, type RuntimeGenerationPreparedActivation, - type RuntimeMcpConnection, - type RuntimeMcpConnector, - type RuntimeMcpExecutionContext, - type RuntimeMcpPreparedActivationReconcile, type RuntimeVector, } from 'agent-bundle/api'; @@ -90,8 +76,6 @@ const descriptor: DevRuntimeDescriptor = Object.freeze({ label: 'RSC agent runtime', schemaVersion: 1, }); -const clientSurfaceId = 'mcp.edit-timeline'; -const clientSurfaceEntry = '/edit-timeline-v1.html'; const maximumAssetBytes = 8 * 1024 * 1024; const stateStoreId = 'playground'; const maximumInvocationWorkers = 4; @@ -125,7 +109,7 @@ const fixturesForHook = (host: 'claude' | 'codex'): readonly DevRuntimeFixture[] * share two cores between Chrome, dev servers, and compiles, so fixed budgets * tuned on many-core machines starve there. Scaling costs nothing on green * runs - the activation resolves long before the deadline - while a wedged - * materialization or MCP reconcile becomes a loud `runtime.generation.failed` + * materialization becomes a loud `runtime.generation.failed` * (with the phase in its diagnostic) instead of a silent permanent hang that * also blocks `close()` behind the provider tail (#38). */ @@ -135,7 +119,7 @@ const runtimeTimeScale = process.env['CI'] !== undefined : Number.isSafeInteger(localTimeScale) && localTimeScale >= 1 ? localTimeScale : 1; const defaultActivationPhaseBudgetMs = 30_000 * runtimeTimeScale; -type ActivationPhase = 'activation-guard' | 'generation-store' | 'mcp-registry' | 'prepared-runtime-reconcile'; +type ActivationPhase = 'activation-guard' | 'generation-store'; const withinDeadline = (promise: Promise, timeoutMs: number, message: string): Promise => new Promise((resolve, reject) => { @@ -221,19 +205,6 @@ interface InvocationWorker { terminate(reason: Error): void; } -interface RuntimeAppBroker { - closedObservation: DevRuntimeMcpSessionCloseObservation | undefined; - opening: Promise | undefined; - session: DevRuntimeMcpSession | undefined; -} - -interface RuntimeAppLink { - readonly descriptor: DevRuntimeMcpRegistryReconcileInput['servers'][number]; - readonly key: string; - readonly resourceUri: string; - readonly surfaceId: string; -} - interface WindowsJobOwner { readonly closed: Promise; readonly done: Promise; @@ -267,8 +238,7 @@ type LiveSessionCleanupResource = | 'generation-store' | 'owned-runs-root' | 'rsbuild-dev-server' - | 'run-artifact' - | 'runtime-mcp-registry'; + | 'run-artifact'; interface LabeledCleanupFailure { readonly error: unknown; @@ -489,72 +459,15 @@ const validateTree = (value: unknown): void => { } }; -const validateAppBinding = (value: unknown): void => { - const app = plainRecord(value, 'RSC invocation worker App binding is invalid.'); - assertExactKeys(app, ['mcpBinding', 'resourceUri', 'surfaceId'], 'RSC invocation worker App binding is invalid.'); - if (typeof app.resourceUri !== 'string' || app.resourceUri.length === 0 || typeof app.surfaceId !== 'string' || app.surfaceId.length === 0) { - throw new Error('RSC invocation worker App binding is invalid.'); - } - const binding = plainRecord(app.mcpBinding, 'RSC invocation worker App binding is invalid.'); - assertExactKeys(binding, ['definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest'], 'RSC invocation worker App binding is invalid.'); - if (typeof binding.definitionDigest !== 'string' || typeof binding.serverDigest !== 'string' || typeof binding.serverName !== 'string' || - typeof binding.sessionId !== 'string' || typeof binding.target !== 'string' || typeof binding.transportDigest !== 'string' || - !Number.isSafeInteger(binding.registryRevision) || !Number.isSafeInteger(binding.sessionRevision)) { - throw new Error('RSC invocation worker App binding is invalid.'); - } -}; - const clonePrepared = (prepared: DevRuntimePreparedProject): DevRuntimePreparedProject => deepFreeze(structuredClone(prepared)); -const transportDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ - provider: prepared.provider, - servers: prepared.servers.map((server) => ({ - args: server.args === undefined ? undefined : [...server.args], - command: server.command, - cwd: server.cwd, - env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, digestValue(value)])), - headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, digestValue(value)])), - id: server.id, - name: server.name, - source: server.source, - targets: [...server.targets], - transport: server.transport, - url: server.url, - })), -}); - const preparedRuntimeAuthorityDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ apps: prepared.apps, provider: prepared.provider, servers: prepared.servers, }); -const asJsonObject = (value: unknown): JsonObject => value as JsonObject; - -const descriptorsFor = ( - prepared: DevRuntimePreparedProject, - metadata: RscRuntimeGenerationMetadata, - definitionDigest: string, - nextTransportDigest: string, -) => { - const template = metadata.servers[0]; - if (template === undefined) throw new Error('The active runtime generation has no MCP server descriptor.'); - return Object.freeze(prepared.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ - definitionDigest, - name: server.name, - resources: Object.freeze(template.resources.map(asJsonObject)), - serverDigest: metadata.serverDigest, - target, - tools: Object.freeze(template.tools.map(asJsonObject)), - transportDigest: nextTransportDigest, - })))); -}; - const lifecycleDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.freeze({ code: 'AB8200', message: error instanceof Error ? error.message : 'RSC runtime provider failed.', @@ -588,10 +501,7 @@ export interface RsbuildRuntimeSessionStartTesting { readonly beforeOwnedRunsRootCleanup?: () => Promise | void; readonly onStartupCleanupClosed?: () => void; readonly beforeGenerationCapture?: () => Promise | void; - readonly afterActivationPrepare?: (input: Readonly<{ - readonly phase: 'store' | 'registry'; - readonly session: RsbuildRuntimeSession; - }>) => Promise | void; + readonly afterActivationPrepare?: (input: Readonly<{ readonly session: RsbuildRuntimeSession }>) => Promise | void; /** * Test-only barrier between the final activation-guard wait and the commit * check, so supersession races (a newer attempt registering or failing @@ -602,7 +512,6 @@ export interface RsbuildRuntimeSessionStartTesting { readonly request: DevRuntimeAssetRequest; readonly runtimeGenerationId: string; }>) => Promise | void; - readonly beforeMcpRelist?: () => Promise | void; readonly afterInvocationWorkerResponse?: (input: Readonly<{ readonly runId: string; readonly surfaceId: string; @@ -631,17 +540,13 @@ export interface RsbuildRuntimeSessionStartTesting { readonly activationPhaseBudgetMs?: number; } -/** - * One provider-owned compiler, generation store, and runtime MCP registry. - * The private compiler URL is exposed only through `clientSurface`. - */ +/** One provider-owned compiler and generation store. */ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #checkpointStore: RscEnvironmentCheckpointStore; readonly #candidatesByAttempt = new Map(); readonly #captureTasks = new Set>(); readonly #context: DevRuntimeStartContext; readonly #generationStore: DevRuntimeGenerationStore; - readonly #mcpRegistry: DevRuntimeProviderMcpRegistry; readonly #preparedRevisions = new Set(); readonly #invocations = new Set>(); readonly #invocationAbort = new AbortController(); @@ -654,7 +559,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #stateFile: string; readonly #stateKernel: ReturnType; readonly #activeRuns = new Map(); - readonly #appBrokers = new Map(); readonly #terminalRuns = new Map(); readonly #surfaceAssetApps = new Map(); readonly #surfaces = new Map(); @@ -664,12 +568,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #workers = new Map(); readonly #failedAttempts = new Set(); #active: RuntimeGeneration | undefined; - /** - * Wrapper objects, not raw listeners, so one relay subscribing the same - * function twice still owns two independently detachable subscriptions. - */ - readonly #appReloadSubscriptions = new Set void }>>(); - #clientSurface: DevRuntimeClientSurfaceEndpoint | undefined; #closePromise: Promise | undefined; #closed = false; #completedCohortSequence = 0; @@ -687,6 +585,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { #generationSequence = 0; #failureTail: Promise = Promise.resolve(); #hmrReady = false; + #reconcileDegraded = false; #latestPreparedRuntime: DevRuntimePreparedProject; #latestRscCohortRevision = 0; #invocationReservations = 0; @@ -699,7 +598,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly checkpointStore: RscEnvironmentCheckpointStore; readonly context: DevRuntimeStartContext; readonly generationStore: DevRuntimeGenerationStore; - readonly mcpRegistry: DevRuntimeProviderMcpRegistry; readonly ownedRunsRoot: OwnedRunsRoot; readonly preparedRuntime: DevRuntimePreparedProject; readonly testing: RsbuildRuntimeSessionStartTesting; @@ -707,7 +605,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { this.#context = input.context; this.#checkpointStore = input.checkpointStore; this.#generationStore = input.generationStore; - this.#mcpRegistry = input.mcpRegistry; this.#latestPreparedRuntime = input.preparedRuntime; this.#testing = input.testing; this.#maximumRunHistory = input.testing.maximumRunHistory ?? defaultMaximumRunHistory; @@ -795,59 +692,14 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } context.signal.throwIfAborted(); - const connectionState: DevRuntimeMcpConnectionState = Object.freeze({ - capabilities: Object.freeze({ - resources: Object.freeze({}), - tools: Object.freeze({}), - }), - protocolEra: 'modern', - protocolVersion: '2025-06-18', - server: Object.freeze({ name: projectName, version: projectVersion }), - }); - const sessionReference: { current: RsbuildRuntimeSession | undefined } = { current: undefined }; - const connector: RuntimeMcpConnector = Object.freeze({ - connect: async ({ signal }: Parameters[0]) => { - signal.throwIfAborted(); - const connection: RuntimeMcpConnection = Object.freeze({ - close: async () => undefined, - relist: async () => { - signal.throwIfAborted(); - await testing.beforeMcpRelist?.(); - signal.throwIfAborted(); - return connectionState; - }, - state: connectionState, - }); - return connection; - }, - }); - const mcpRegistry = createRuntimeMcpRegistry({ - artifactEpochId: () => undefined, - connector, - emit: (event) => { - const session = sessionReference.current; - if (session !== undefined) session.#emit(event); - }, - executor: async (execution) => { - const session = sessionReference.current; - if (session === undefined) throw new Error('RSC runtime session is unavailable.'); - return session.#executeMcp(execution); - }, - generationStore: generationStore as DevRuntimeGenerationStore, - providerSessionId: context.providerSessionId, - stateStoreId, - }); - ledger.add(() => mcpRegistry.close(), 'runtime-mcp-registry'); const session = new RsbuildRuntimeSession({ checkpointStore, context, generationStore, - mcpRegistry, ownedRunsRoot, preparedRuntime, testing, }); - sessionReference.current = session; context.signal.throwIfAborted(); const rsbuild = await (testing.createRsbuild ?? createRsbuild)({ @@ -855,7 +707,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { config: createRscRuntimeRsbuildConfig({ compilerRoot: join(storageRoot, 'compiler'), mode: 'development', - onAppReload: () => { session.#emitAppReload(); }, onCompile: session.#compileObserver(), }), cwd: context.projectRoot, @@ -864,7 +715,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const started = await rsbuild.startDevServer({ getPortSilently: true }); await ledger.add(() => started.server.close(), 'rsbuild-dev-server'); context.signal.throwIfAborted(); - session.#attachServer(started, rsbuild.context.devServer); + session.#attachServer(started.server); // startDevServer does not guarantee that the initial compile or async // onAfterDevCompile work has finished. In 2.2.1 that work often starts // before this return, but providerTail is not a documented readiness @@ -887,18 +738,10 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } } - get mcpRegistry(): DevRuntimeProviderMcpRegistry { - return this.#mcpRegistry; - } - get providerSessionId(): string { return this.#context.providerSessionId; } - clientSurface(surfaceId: string): DevRuntimeClientSurfaceEndpoint | undefined { - return !this.#closed && surfaceId === clientSurfaceId ? this.#clientSurface : undefined; - } - close(): Promise { this.#closePromise ??= this.#close(); return this.#closePromise; @@ -1032,7 +875,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } let surface: DevRuntimeSurface; try { - surface = await this.#historicalSurface(retained.generation, historical.surfaceId); + surface = await this.#historicalSurface(retained.generation, historical); } catch { throw new DevRuntimeGenerationConflictError(historicalGenerationId, this.#active?.id); } @@ -1173,9 +1016,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const stateAfter = await this.#stateKernel.readSnapshot({ stateVersion: inspectedStateVersion }); if (stateAfter.stateVersion !== inspectedStateVersion) throw new Error('RSC invocation inspection state version is not durable.'); this.#assertInvocationOpen(); - const app = await this.#runtimeAppResult(generationLease.generation, invocation); - this.#assertInvocationOpen(); - const result = this.#inspectionResult(response.inspection, flight, stateAfter, runId, app); + const result = this.#inspectionResult(response.inspection, flight, stateAfter, runId); if (artifact === undefined) throw new Error('RSC runtime Flight artifact is unavailable.'); await this.#writeRunFlight(artifact, flight); const completed = Object.freeze({ @@ -1339,8 +1180,9 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { async #historicalSurface( generation: RuntimeGeneration, - surfaceId: string, + historical: DevRuntimeRun, ): Promise { + const surfaceId = historical.surfaceId; const definitionPath = join(generation.root, 'rsc', 'runtime-definition.json'); const asset = generation.manifest.assets.find((candidate) => candidate.path === 'rsc/runtime-definition.json'); if (asset === undefined || !isInside(generation.root, definitionPath)) throw new Error('Historical runtime generation has no definition asset.'); @@ -1349,7 +1191,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const bytes = await readFile(definitionPath); if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256) throw new Error('Historical runtime definition changed.'); const definition = JSON.parse(bytes.toString('utf8')) as Partial; - const targets = Object.freeze([...new Set(generation.manifest.metadata.servers.map((server) => server.target))]); + const targets = Object.freeze([historical.target]); if (surfaceId.startsWith('hook.')) { const host = surfaceId.slice('hook.'.length); if ((host !== 'claude' && host !== 'codex') || !definition.nativeHooks?.some((hook) => hook.host === host)) { @@ -1364,10 +1206,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { if (definition.resources?.some((resource) => resource.name === name)) { return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-resource', label: name, readOnly: true, targets }); } - const app = generation.manifest.metadata.appDefinitions.find((candidate) => candidate.name === name); - if (app !== undefined) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-app', label: name, readOnly: true, targets: app.targets }); - } throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); } @@ -1482,12 +1320,9 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { flight: Buffer, snapshot: RuntimeSnapshot, runId: string, - app: DevRuntimeInspectionEnvelope['app'], ): DevRuntimeInspectionEnvelope { - const { app: _workerApp, ...workerInspection } = inspection; return Object.freeze({ - ...workerInspection, - ...(app === undefined ? {} : { app }), + ...inspection, flight: Object.freeze({ bytes: flight.byteLength, downloadPath: `/api/runtime/runs/${encodeURIComponent(runId)}/flight`, @@ -1502,156 +1337,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { }); } - #runtimeAppLink( - generation: RuntimeGeneration, - invocation: ValidatedInvocation, - ): RuntimeAppLink | undefined { - if (invocation.surface.id !== 'mcp.render_edit_timeline') return undefined; - const registry = this.#mcpRegistry.snapshot(); - const metadata = generation.manifest.metadata; - if ( - this.#active?.id !== generation.id || registry?.runtimeGenerationId !== generation.id - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - const toolName = invocation.surface.id.slice('mcp.'.length); - const matches = registry.servers.flatMap((descriptor) => { - if ( - descriptor.target !== invocation.request.target || descriptor.definitionDigest !== registry.definitionDigest || - descriptor.transportDigest !== registry.transportDigest || descriptor.serverDigest !== metadata.serverDigest - ) return []; - const tool = descriptor.tools.find((candidate) => candidate.name === toolName); - const toolMeta = tool?._meta; - const outputTemplate = toolMeta === null || typeof toolMeta !== 'object' || Array.isArray(toolMeta) - ? undefined - : Object.getOwnPropertyDescriptor(toolMeta, 'openai/outputTemplate')?.value; - const resourceUri = typeof outputTemplate === 'string' ? outputTemplate : undefined; - if (resourceUri === undefined) return []; - return metadata.appDefinitions - .filter((app) => app.serverName === descriptor.name && app.resourceUri === resourceUri && app.targets.includes(invocation.request.target) && metadata.surfaceAssets[`mcp.${app.name}`] !== undefined) - .map((app) => Object.freeze({ app, descriptor, resourceUri })); - }); - if (matches.length !== 1) throw new Error('Runtime App invocation has no unambiguous current-generation App definition.'); - const match = matches[0]!; - return Object.freeze({ - descriptor: match.descriptor, - key: `${match.descriptor.name}\u0000${invocation.request.target}`, - resourceUri: match.resourceUri, - surfaceId: clientSurfaceId, - }); - } - - #assertRuntimeAppAuthority( - generation: RuntimeGeneration, - link: RuntimeAppLink, - ): NonNullable> { - this.#assertInvocationOpen(); - const registry = this.#mcpRegistry.snapshot(); - if ( - registry === undefined || this.#active?.id !== generation.id || registry.runtimeGenerationId !== generation.id || - link.descriptor.definitionDigest !== registry.definitionDigest || link.descriptor.transportDigest !== registry.transportDigest || - !registry.servers.some((descriptor) => descriptor.name === link.descriptor.name && descriptor.target === link.descriptor.target && - descriptor.definitionDigest === link.descriptor.definitionDigest && descriptor.serverDigest === link.descriptor.serverDigest && - descriptor.transportDigest === link.descriptor.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - return registry; - } - - #matchesRuntimeAppBinding( - binding: DevRuntimeMcpSessionBinding, - link: RuntimeAppLink, - registry: NonNullable>, - ): boolean { - return binding.definitionDigest === registry.definitionDigest && binding.registryRevision === registry.registryRevision && - binding.serverDigest === link.descriptor.serverDigest && binding.serverName === link.descriptor.name && - binding.target === link.descriptor.target && binding.transportDigest === registry.transportDigest; - } - - async #runtimeAppSession( - generation: RuntimeGeneration, - link: RuntimeAppLink, - ): Promise { - const registry = this.#assertRuntimeAppAuthority(generation, link); - const existing = this.#appBrokers.get(link.key); - const broker = existing ?? { closedObservation: undefined, opening: undefined, session: undefined }; - if (existing === undefined) this.#appBrokers.set(link.key, broker); - const current = broker.session; - if (current !== undefined) { - const snapshot = current.snapshot(); - if (snapshot.state === 'ready' && this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) return current; - broker.closedObservation?.unsubscribe(); - broker.closedObservation = undefined; - broker.session = undefined; - if (this.#appBrokers.get(link.key) === broker) this.#appBrokers.delete(link.key); - return this.#runtimeAppSession(generation, link); - } - if (broker.opening !== undefined) return broker.opening; - const opening = (async (): Promise => { - let session: DevRuntimeMcpSession | undefined; - try { - session = await this.#mcpRegistry.open(Object.freeze({ - expectedRegistryRevision: registry.registryRevision, - serverName: link.descriptor.name, - target: link.descriptor.target, - })); - const currentRegistry = this.#assertRuntimeAppAuthority(generation, link); - const snapshot = session.snapshot(); - if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, currentRegistry)) { - throw new Error('Runtime App broker session did not negotiate the current generation authority.'); - } - broker.session = session; - broker.closedObservation = session.watchClosed(() => { - if (this.#appBrokers.get(link.key) !== broker) return; - broker.closedObservation?.unsubscribe(); - broker.closedObservation = undefined; - broker.session = undefined; - this.#appBrokers.delete(link.key); - }); - return session; - } catch (error) { - if (session !== undefined) await session.close().catch(() => undefined); - if (this.#appBrokers.get(link.key) === broker && broker.session === undefined) this.#appBrokers.delete(link.key); - throw error; - } - })(); - broker.opening = opening; - void opening.finally(() => { - if (broker.opening === opening) broker.opening = undefined; - }).catch(() => undefined); - return opening; - } - - async #runtimeAppResult( - generation: RuntimeGeneration, - invocation: ValidatedInvocation, - ): Promise { - const link = this.#runtimeAppLink(generation, invocation); - if (link === undefined) return undefined; - const session = await this.#runtimeAppSession(generation, link); - const registry = this.#assertRuntimeAppAuthority(generation, link); - const snapshot = session.snapshot(); - if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) { - throw new Error('Runtime App broker session became stale before invocation completion.'); - } - const binding = snapshot.binding; - return Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: binding.definitionDigest, - registryRevision: binding.registryRevision, - serverDigest: binding.serverDigest, - serverName: binding.serverName, - sessionId: binding.sessionId, - sessionRevision: binding.sessionRevision, - target: binding.target, - transportDigest: binding.transportDigest, - }), - resourceUri: link.resourceUri, - surfaceId: link.surfaceId, - }); - } - #validateWorkerResponse(value: unknown, flightBytes: number, surfaceId: string): DevRuntimeInspectionEnvelope { const response = plainRecord(value, 'RSC invocation worker emitted an invalid response.'); assertExactKeys(response, ['flightBytes', 'inspection'], 'RSC invocation worker response has unsupported fields.'); @@ -1663,7 +1348,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } const inspection = plainRecord(response.inspection, 'RSC invocation worker inspection is invalid.'); const hook = surfaceId === 'hook.claude' || surfaceId === 'hook.codex'; - if ('app' in inspection) validateAppBinding(inspection.app); optionalExactKeys( inspection, hook ? ['agentVisible', 'flight', 'native', 'state', 'trace', 'tree'] : ['flight', 'modelVisible', 'protocol', 'state', 'trace', 'tree'], @@ -2151,49 +1835,13 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { return response; } - #attachServer( - started: StartDevServerResult, - devServer: Readonly<{ readonly hostname: string; readonly https: boolean; readonly port: number }> | undefined, - ): void { + #attachServer(server: StartDevServerResult['server']): void { if (this.#closed) return; - if ( - devServer === undefined || devServer.hostname !== '127.0.0.1' || devServer.https || - !Number.isSafeInteger(devServer.port) || devServer.port < 1 || devServer.port > 65_535 - ) throw new Error('RSC runtime dev server did not expose a valid loopback HTTP origin.'); - const origin = new URL(`http://${devServer.hostname}:${String(devServer.port)}`).origin; - this.#server = started.server; - this.#clientSurface = Object.freeze({ - entryPath: clientSurfaceEntry, - httpOrigin: origin, - httpPathPrefixes: Object.freeze(['/']), - subscribeReload: (listener: () => void) => this.#subscribeAppReload(listener), - surfaceId: clientSurfaceId, - }); + this.#server = server; this.#hmrReady = true; this.#setStatus(this.#active === undefined ? 'compiling' : 'active'); } - #subscribeAppReload(listener: () => void): () => void { - if (typeof listener !== 'function') { - throw new TypeError('RSC runtime App reload subscription requires a listener function.'); - } - if (this.#closed) return () => undefined; - const subscription = Object.freeze({ listener }); - this.#appReloadSubscriptions.add(subscription); - return () => { this.#appReloadSubscriptions.delete(subscription); }; - } - - #emitAppReload(): void { - if (this.#closed) return; - for (const subscription of [...this.#appReloadSubscriptions]) { - try { - subscription.listener(); - } catch { - // One relay's failure must not starve the remaining subscribers. - } - } - } - #compileObserver(): NonNullable[0]['onCompile']> { return Object.freeze({ beginCompletedCohort: () => this.#beginCompletedCohort(), @@ -2465,7 +2113,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { * its budget fails the attempt loudly (the page recovers through its * `runtime.generation.failed` bootstrap path) instead of silently wedging * the provider tail; if the abandoned step settles later, its resources are - * released so a stray success cannot leak store or registry reservations. + * released so a stray success cannot leak store reservations. */ async #boundedActivationPhase( phase: ActivationPhase, @@ -2484,63 +2132,42 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { async #activate(snapshot: RscRuntimeCapturedGenerationSnapshot): Promise<'activated' | 'failed'> { const guard = this.#activationGuard(snapshot); let preparedGeneration: RuntimeGenerationPreparedActivation | undefined; - let preparedRegistry: RuntimeMcpPreparedActivationReconcile | undefined; try { - preparedGeneration = await this.#boundedActivationPhase( + const prepared = await this.#boundedActivationPhase( 'generation-store', materializeRuntimeGeneration({ guard, snapshot, stateStoreId, store: this.#generationStore, + }).then(async (next) => { + try { + await this.#testing.afterActivationPrepare?.(Object.freeze({ session: this })); + } catch (error) { + await this.#generationStore.abort(next).catch(() => undefined); + throw error; + } + return next; }), - (prepared) => this.#generationStore.abort(prepared), - ); - await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'store', session: this })); - const metadata = preparedGeneration.generation.manifest.metadata; - preparedRegistry = await this.#boundedActivationPhase( - 'mcp-registry', - this.#mcpRegistry.prepareActivationReconcile({ - definitionDigest: metadata.definitionDigest, - runtimeGenerationId: preparedGeneration.generation.id, - servers: metadata.servers, - transportDigest: metadata.transportDigest, - }), - (prepared) => this.#mcpRegistry.abortActivationReconcile(prepared), + (late) => this.#generationStore.abort(late), ); - await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'registry', session: this })); - await this.#boundedActivationPhase('activation-guard', guard.wait(preparedGeneration.generation.manifest)); + preparedGeneration = prepared; + await this.#boundedActivationPhase('activation-guard', guard.wait(prepared.generation.manifest)); await this.#testing.beforeActivationCommit?.(); - if (!guard.check(preparedGeneration.generation.manifest) || !this.#generationStore.canCommit(preparedGeneration)) { + if (!guard.check(prepared.generation.manifest) || !this.#generationStore.canCommit(prepared)) { throw new Error('RSC runtime generation activation was superseded.'); } - const generation = this.#generationStore.commit(preparedGeneration); - const committed = this.#mcpRegistry.commitActivationReconcile(preparedRegistry); + const generation = this.#generationStore.commit(prepared); preparedGeneration = undefined; - preparedRegistry = undefined; this.#active = generation; this.#updateSurfaces(snapshot, snapshot.preparedRuntime); this.#updateSurfaceAssetApps(snapshot.preparedRuntime); + this.#reconcileDegraded = false; this.#setStatus('active'); - this.#emit(Object.freeze({ - mcpRegistryRevision: this.#mcpRegistry.snapshot()?.registryRevision, - runtimeGenerationId: generation.id, - type: 'runtime.generation.activated', - })); - committed.publish(); - try { - await committed.finalize(); - } catch (error) { - if (!this.#closed) this.#setStatus('degraded', [lifecycleDiagnostic(error)]); - } + this.#emit(Object.freeze({ runtimeGenerationId: generation.id, type: 'runtime.generation.activated' })); return 'activated'; } catch (error) { - if (preparedGeneration !== undefined || preparedRegistry !== undefined) { - await Promise.allSettled([ - ...(preparedGeneration === undefined ? [] : [this.#generationStore.abort(preparedGeneration)]), - ...(preparedRegistry === undefined ? [] : [this.#mcpRegistry.abortActivationReconcile(preparedRegistry)]), - ]); - } + if (preparedGeneration !== undefined) await this.#generationStore.abort(preparedGeneration).catch(() => undefined); await this.#failAttempt(snapshot.attemptId, error); return 'failed'; } finally { @@ -2551,205 +2178,25 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { async #reconcilePreparedRuntime(prepared: DevRuntimePreparedProject): Promise { const active = this.#active; if (active === undefined || this.#closed) return; - const metadata = active.manifest.metadata; - const definition = JSON.parse(await readFile(join(active.root, 'rsc', 'runtime-definition.json'), 'utf8')) as SerializedRuntimeDefinition; - const nextDefinitionDigest = runtimeDefinitionDigest(definition, prepared); - const nextTransportDigest = transportDigest(prepared); - const current = this.#mcpRegistry.snapshot(); - if ( - current?.runtimeGenerationId === active.id && - current.definitionDigest === nextDefinitionDigest && - current.transportDigest === nextTransportDigest - ) return; - const input: DevRuntimeMcpRegistryReconcileInput = Object.freeze({ - definitionDigest: nextDefinitionDigest, - runtimeGenerationId: active.id, - servers: descriptorsFor(prepared, metadata, nextDefinitionDigest, nextTransportDigest), - transportDigest: nextTransportDigest, - }); - this.#setStatus('compiling'); try { - await this.#boundedActivationPhase('prepared-runtime-reconcile', this.#mcpRegistry.reconcile(input)); + const definition = JSON.parse(await readFile(join(active.root, 'rsc', 'runtime-definition.json'), 'utf8')) as SerializedRuntimeDefinition; this.#updateSurfaces({ definition }, prepared); this.#updateSurfaceAssetApps(prepared); - this.#setStatus('active'); + if (this.#reconcileDegraded) { + this.#reconcileDegraded = false; + this.#setStatus('active'); + } } catch (error) { + this.#reconcileDegraded = true; this.#setStatus('degraded', [lifecycleDiagnostic(error)]); throw error; } } - async #executeMcp(execution: RuntimeMcpExecutionContext): Promise> { - execution.signal.throwIfAborted(); - const generation = execution.generation as RuntimeGeneration; - this.#assertMcpExecutionAuthority(execution, generation); - if (execution.request.kind === 'read-resource') { - const resource = this.#appResource(execution, generation, execution.request.uri); - const asset = await this.#readGenerationSurfaceHtml(generation, resource.surfaceId); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - return Object.freeze({ - stateVersion: 0, - value: Object.freeze({ - contents: Object.freeze([Object.freeze({ - _meta: resource.metadata, - mimeType: resource.mimeType, - text: asset, - uri: resource.uri, - })]), - }), - }); - } - if (execution.request.kind === 'call-tool') { - this.#appTool(execution, generation, execution.request.name); - return this.#executeTimelineTool(execution, generation, execution.request.arguments); - } - throw new Error(`Runtime MCP operation ${JSON.stringify(execution.request.kind)} is not available.`); - } - - #assertMcpExecutionAuthority( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - ): NonNullable> { - this.#assertInvocationOpen(); - const registry = this.#mcpRegistry.snapshot(); - const binding = this.#mcpRegistry.session(execution.sessionId)?.snapshot().binding; - if ( - registry === undefined || binding === undefined || this.#active?.id !== generation.id || - registry.runtimeGenerationId !== generation.id || - binding.sessionId !== execution.sessionId || binding.registryRevision !== registry.registryRevision || - !registry.servers.some((descriptor) => descriptor.name === execution.descriptor.name && descriptor.target === execution.descriptor.target && - descriptor.definitionDigest === execution.descriptor.definitionDigest && descriptor.serverDigest === execution.descriptor.serverDigest && - descriptor.transportDigest === execution.descriptor.transportDigest && descriptor.definitionDigest === registry.definitionDigest && - descriptor.transportDigest === registry.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) || - binding.definitionDigest !== execution.descriptor.definitionDigest || binding.serverDigest !== execution.descriptor.serverDigest || - binding.serverName !== execution.descriptor.name || binding.target !== execution.descriptor.target || - binding.transportDigest !== execution.descriptor.transportDigest - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - return registry; - } - - #appResource( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - uri: string, - ): Readonly<{ readonly metadata: JsonObject; readonly mimeType: string; readonly surfaceId: string; readonly uri: string }> { - const resource = execution.descriptor.resources.filter((candidate) => - candidate.uri === uri && candidate.mimeType === 'text/html;profile=mcp-app' && isJsonObject(candidate._meta), - ); - const app = generation.manifest.metadata.appDefinitions.filter((candidate) => - candidate.resourceUri === uri && candidate.serverName === execution.descriptor.name && candidate.targets.includes(execution.descriptor.target), - ); - if (resource.length !== 1 || app.length !== 1) throw new Error('Runtime MCP App resource is not owned by the current generation.'); - const surfaceId = `mcp.${app[0]!.name}`; - if (generation.manifest.metadata.surfaceAssets[surfaceId] === undefined) { - throw new Error('Runtime MCP App resource has no current-generation asset.'); - } - return Object.freeze({ metadata: resource[0]!._meta as JsonObject, mimeType: resource[0]!.mimeType as string, surfaceId, uri }); - } - - #appTool( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - name: string, - ): void { - const tool = execution.descriptor.tools.filter((candidate) => candidate.name === name); - if (tool.length !== 1 || tool[0]!.handlerId !== 'render_edit_timeline' || !isJsonObject(tool[0]!._meta)) { - throw new Error('Runtime MCP App tool is not owned by the current generation.'); - } - const uri = tool[0]!._meta['openai/outputTemplate']; - if (typeof uri !== 'string') throw new Error('Runtime MCP App tool has no App resource binding.'); - this.#appResource(execution, generation, uri); - } - - #timelineLimit(argumentsValue: JsonValue | undefined): Readonly<{ readonly limit?: number }> { - if (argumentsValue === undefined) return Object.freeze({}); - if (!isJsonObject(argumentsValue) || Object.keys(argumentsValue).some((key) => key !== 'limit')) { - throw new TypeError('Runtime MCP App tool arguments are invalid.'); - } - const limit = argumentsValue.limit; - if (limit === undefined) return Object.freeze({}); - if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 50) { - throw new TypeError('Runtime MCP App tool arguments are invalid.'); - } - return Object.freeze({ limit }); - } - - async #executeTimelineTool( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - argumentsValue: JsonObject, - ): Promise> { - const release = this.#reserveInvocation(); - const runId = `runtime-mcp-${randomUUID()}`; - const abort = (): void => this.#workers.get(runId)?.terminate(new Error('Runtime MCP operation was aborted.')); - execution.signal.addEventListener('abort', abort, { once: true }); - try { - const snapshot = await this.#stateKernel.readSnapshot(this.#timelineLimit(argumentsValue)); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - const response = await this.#runInvocationWorker({ - generation, - input: Object.freeze({ - snapshot: cloneJson(snapshot), - stateFile: this.#stateFile, - stateStoreId, - type: 'mcp/render-timeline', - }), - runId, - surfaceId: 'mcp.render_edit_timeline', - }); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - const stateVersion = response.inspection.state.identity.stateVersion; - const durable = await this.#stateKernel.readSnapshot({ stateVersion }); - const protocol = response.inspection.protocol; - if (durable.stateVersion !== stateVersion || protocol === undefined || !isJsonObject(protocol)) { - throw new Error('Runtime MCP App tool result is not a durable protocol response.'); - } - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - return Object.freeze({ stateVersion, value: cloneJson(protocol) }); - } finally { - execution.signal.removeEventListener('abort', abort); - release(); - } - } - - async #readGenerationSurfaceHtml( - generation: RuntimeGeneration, - surfaceId: string, - ): Promise { - const matches = generation.manifest.metadata.surfaceAssets[surfaceId]?.filter((asset) => - asset.contentType === 'text/html' && asset.requestPath === clientSurfaceEntry, - ) ?? []; - if (matches.length !== 1) throw new Error('Runtime MCP App resource has no canonical HTML asset.'); - const asset = matches[0]!; - if (asset.bytes > maximumAssetBytes) throw new Error('Runtime MCP App HTML exceeds the asset limit.'); - const segments = asset.generationPath.split('/'); - if (segments.some((segment) => !safeSegment(segment))) throw new Error('Runtime MCP App HTML asset path is unsafe.'); - const path = join(generation.root, ...segments); - if (!isInside(generation.root, path)) throw new Error('Runtime MCP App HTML asset escaped its generation root.'); - const details = await lstat(path); - if (!details.isFile() || details.isSymbolicLink() || details.size !== asset.bytes) { - throw new Error('Runtime MCP App HTML asset changed.'); - } - const body = await readFile(path); - if (body.byteLength !== asset.bytes || createHash('sha256').update(body).digest('hex') !== asset.sha256) { - throw new Error('Runtime MCP App HTML asset changed.'); - } - const text = body.toString('utf8'); - if (Buffer.byteLength(text, 'utf8') !== body.byteLength) throw new Error('Runtime MCP App HTML asset is not UTF-8.'); - return text; - } - async #close(): Promise { this.#closed = true; this.#invocationAbort.abort(new Error('RSC runtime session is closing.')); this.#hmrReady = false; - this.#appReloadSubscriptions.clear(); this.#pendingCohortIds.clear(); this.#settleCompileObservation(); for (const worker of this.#workers.values()) { @@ -2760,11 +2207,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const checkpointStoreClose = this.#checkpointStore.close(); void checkpointStoreClose.catch(() => undefined); this.#setStatus('closed'); - for (const broker of this.#appBrokers.values()) broker.closedObservation?.unsubscribe(); - this.#appBrokers.clear(); this.#surfaceAssetApps.clear(); - const mcpRegistryClose = this.#closeLiveSessionResource('runtime-mcp-registry', () => this.#mcpRegistry.close()); - void mcpRegistryClose.catch(() => undefined); while (this.#captureTasks.size > 0) await Promise.all([...this.#captureTasks]); while (this.#invocations.size > 0) await Promise.allSettled([...this.#invocations]); while (this.#runReadTasks.size > 0) { @@ -2790,7 +2233,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { 'rsbuild-dev-server', () => this.#server?.close() ?? Promise.resolve(), ) }), - Object.freeze({ label: 'runtime-mcp-registry' as const, close: () => mcpRegistryClose }), Object.freeze({ label: 'environment-checkpoints' as const, close: () => this.#closeLiveSessionResource( 'environment-checkpoints', () => checkpointStoreClose, diff --git a/examples/rsc-agent-runtime/src/runtime/contracts.ts b/examples/rsc-agent-runtime/src/runtime/contracts.ts index 85108aa6e..e7f46e3f7 100644 --- a/examples/rsc-agent-runtime/src/runtime/contracts.ts +++ b/examples/rsc-agent-runtime/src/runtime/contracts.ts @@ -1,9 +1,5 @@ import type { ZodType } from 'zod'; -import type { JsonObject } from 'agent-bundle'; -import type { - DevRuntimeInspectionEnvelope, - DevRuntimeMcpServerDescriptor, -} from 'agent-bundle/api'; +import type { DevRuntimeInspectionEnvelope } from 'agent-bundle/api'; export interface EditEvent { eventId: string; @@ -89,14 +85,14 @@ export interface DevRuntimeHookInspectionRequest { readonly type: 'hook/after-file-edit'; } -export interface DevRuntimeMcpTimelineInspectionRequest { +export interface DevRuntimeTimelineInspectionRequest { readonly snapshot: RuntimeSnapshot; readonly stateFile: string; readonly stateStoreId: string; readonly type: 'mcp/render-timeline'; } -export interface DevRuntimeMcpStatusInspectionRequest { +export interface DevRuntimeStatusInspectionRequest { readonly stateFile: string; readonly stateStoreId: string; readonly type: 'mcp/runtime-status'; @@ -104,8 +100,8 @@ export interface DevRuntimeMcpStatusInspectionRequest { export type DevRuntimeInspectionRequest = | DevRuntimeHookInspectionRequest - | DevRuntimeMcpTimelineInspectionRequest - | DevRuntimeMcpStatusInspectionRequest; + | DevRuntimeTimelineInspectionRequest + | DevRuntimeStatusInspectionRequest; export interface DevRuntimeInspectionResponse { /** Raw Flight bytes are sent over the provider-owned fd 3 side channel. */ @@ -178,24 +174,14 @@ export interface RscRuntimeSurfaceAsset { } export interface RscRuntimeAppDefinition { - readonly _meta?: JsonObject; readonly id: string; readonly name: string; readonly resourceUri: string; - readonly serverId: string; - readonly serverName: string; - readonly targets: readonly string[]; } export interface RscRuntimeGenerationMetadata { readonly appDefinitions: readonly RscRuntimeAppDefinition[]; - readonly definitionDigest: string; readonly entries: Readonly>; - readonly environmentHashes: Readonly>; - readonly preparedRevision: string; - readonly serverDigest: string; - readonly servers: readonly DevRuntimeMcpServerDescriptor[]; readonly stateStoreId: string; readonly surfaceAssets: Readonly>; - readonly transportDigest: string; } diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index ac3d24361..5cfc6edaa 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -888,20 +888,6 @@ test('preserves the Claude fixture seed in post-state while exact replay stays v }); expect(timeline).toMatchObject({ result: { - app: { - mcpBinding: { - definitionDigest: expect.any(String), - registryRevision: expect.any(Number), - serverDigest: expect.any(String), - serverName: 'timeline', - sessionId: expect.any(String), - sessionRevision: expect.any(Number), - target: timelineTarget, - transportDigest: expect.any(String), - }, - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - surfaceId: 'mcp.edit-timeline', - }, protocol: { structuredContent: { edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], @@ -911,90 +897,23 @@ test('preserves the Claude fixture seed in post-state while exact replay stays v }, status: 'succeeded', }); - if (timeline.status !== 'succeeded' || timeline.result.protocol === null || typeof timeline.result.protocol !== 'object' || Array.isArray(timeline.result.protocol) || timeline.result.app === undefined) { + if (timeline.status !== 'succeeded' || timeline.result.protocol === null || typeof timeline.result.protocol !== 'object' || Array.isArray(timeline.result.protocol)) { throw new Error('Timeline protocol was unavailable.'); } expect(timeline.surfaceId).toBe('mcp.render_edit_timeline'); - expect(timeline.result.app.surfaceId).toBe('mcp.edit-timeline'); - expect(Object.keys(timeline.result.app.mcpBinding).sort()).toEqual([ - 'definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest', - ]); - expect(Object.isFrozen(timeline.result.app)).toBe(true); - expect(Object.isFrozen(timeline.result.app.mcpBinding)).toBe(true); - expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)?.snapshot()).toMatchObject({ - binding: timeline.result.app.mcpBinding, - state: 'ready', - }); - const broker = session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId); - if (broker === undefined) throw new Error('Timeline App broker was unavailable.'); - const listedTools = await broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'list-tools', - }); - const listedResources = await broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'list-resources', - }); - expect(listedTools.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); - expect(listedResources.value).toEqual(expect.arrayContaining([expect.objectContaining({ - mimeType: 'text/html;profile=mcp-app', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })])); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).resolves.toEqual(expect.objectContaining({ - value: { - contents: [{ - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - 'ui.csp': { connectDomains: [], resourceDomains: [] }, - 'ui.prefersBorder': true, - }, - mimeType: 'text/html;profile=mcp-app', - text: expect.stringMatching(/^/iu), - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - }], - }, - })); - await expect(broker.execute({ - arguments: { limit: 1 }, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).resolves.toMatchObject({ - sessionId: timeline.result.app.mcpBinding.sessionId, - sessionRevision: timeline.result.app.mcpBinding.sessionRevision, - value: { - content: [{ text: 'Showing 1 recorded edits.', type: 'text' }], - structuredContent: { edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], stateVersion: 2 }, + expect(timeline.result).not.toHaveProperty('app'); + expect((timeline.result.protocol as Record).structuredContent).not.toHaveProperty('seed'); + await expect(session.replay({ mode: 'exact', runId: timeline.id })).resolves.toMatchObject({ + result: { + protocol: { + structuredContent: { edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], stateVersion: 2 }, + }, }, + status: 'succeeded', + surfaceId: 'mcp.render_edit_timeline', + target: timelineTarget, vector: { runtimeGenerationId: generationId, stateVersion: 2 }, }); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/foreign.html', - })).rejects.toThrow('not declared'); - await expect(broker.execute({ - arguments: {}, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'foreign_tool', - })).rejects.toThrow('not declared'); - await expect(broker.execute({ - arguments: { limit: 0 }, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).rejects.toThrow('arguments'); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision + 1, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).rejects.toThrow('revision'); - expect(session.clientSurface(timeline.result.app.surfaceId)).toMatchObject({ surfaceId: 'mcp.edit-timeline' }); - expect((timeline.result.protocol as Record).structuredContent).not.toHaveProperty('seed'); const timelineRequest = Object.freeze({ expectedGenerationId: generationId, @@ -1007,29 +926,9 @@ test('preserves the Claude fixture seed in post-state while exact replay stays v session.invoke(timelineRequest), ]); for (const candidate of [repeatedTimeline, concurrentTimeline]) { - expect(candidate).toMatchObject({ status: 'succeeded' }); - if (candidate.status !== 'succeeded' || candidate.result.app === undefined) throw new Error('Repeated timeline App result was unavailable.'); - expect(candidate.result.app.mcpBinding).toEqual(timeline.result.app.mcpBinding); + expect(candidate).toMatchObject({ status: 'succeeded', vector: { stateVersion: timeline.vector.stateVersion } }); } - await session.mcpRegistry.closeSession({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - sessionId: timeline.result.app.mcpBinding.sessionId, - }); - expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)).toBeUndefined(); - const reopenedTimeline = await session.invoke(timelineRequest); - expect(reopenedTimeline).toMatchObject({ status: 'succeeded' }); - if (reopenedTimeline.status !== 'succeeded' || reopenedTimeline.result.app === undefined) throw new Error('Reopened timeline App result was unavailable.'); - expect(reopenedTimeline.result.app.mcpBinding).toMatchObject({ - definitionDigest: timeline.result.app.mcpBinding.definitionDigest, - registryRevision: timeline.result.app.mcpBinding.registryRevision, - serverDigest: timeline.result.app.mcpBinding.serverDigest, - serverName: timeline.result.app.mcpBinding.serverName, - target: timeline.result.app.mcpBinding.target, - transportDigest: timeline.result.app.mcpBinding.transportDigest, - }); - expect(reopenedTimeline.result.app.mcpBinding.sessionId).not.toBe(timeline.result.app.mcpBinding.sessionId); - await expect(session.resetState({ expectedGenerationId: generationId, seed: { authorization: 'Bearer sk-live-abcdefghijklmnopqrstuvwxyz' }, @@ -2053,7 +1952,7 @@ process.stdout.end(${JSON.stringify(`${JSON.stringify({ flightBytes: 1, inspecti await malformed({ ...validInspection, trace: [{ id: '', phase: 'render', startedAt: 'not-a-date', status: 'unknown' }] }); await malformed({ ...validInspection, trace: [{ details: null, id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); await malformed({ ...validInspection, trace: [{ details: [], id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); - await malformed({ ...validInspection, app: { mcpBinding: {}, resourceUri: 'ui://unsafe', surfaceId: 'mcp.timeline' } }); + await malformed({ ...validInspection, app: { resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', surfaceId: 'mcp.edit-timeline' } }); expect(await readdir(join(storageRoot, 'runs'))).toEqual(['.agent-bundle-runtime-owner']); } finally { await session.close().catch(() => undefined); diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 30d94c2cf..4a3a11f5d 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -271,117 +271,6 @@ const introduceWorkerSyntaxError = async (projectRoot: string): Promise return line; }; -test('requires the App environment through the public Rsbuild compiler hook', async () => { - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-provider-app-environment'), - mode: 'development', - onAppReload: () => undefined, - }); - const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload'); - if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.'); - let afterCreate: ((input: unknown) => void) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: () => undefined, - onBeforeStartDevServer: () => undefined, - onCloseDevServer: () => undefined, - }); - expect(() => afterCreate?.({ environments: {} })).toThrow('App environment'); - expect(() => afterCreate?.({ environments: { app: {} } })).not.toThrow(); -}); - -test('emits one owned App reload for changed output and none for an unchanged-output recompile', async () => { - const reloads: number[] = []; - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'), - mode: 'development', - onAppReload: () => { reloads.push(reloads.length + 1); }, - }); - const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload'); - if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.'); - - let afterCompiler: ((input: unknown) => void) | undefined; - let afterEnvironmentCompile: ((input: unknown) => void) | undefined; - let beforeStartDevServer: ((input: unknown) => unknown) | undefined; - let closeDevServer: (() => unknown) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCompiler = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: (callback: unknown) => { afterEnvironmentCompile = callback as (input: unknown) => void; }, - onBeforeStartDevServer: (callback: unknown) => { beforeStartDevServer = callback as (input: unknown) => unknown; }, - onCloseDevServer: (callback: unknown) => { closeDevServer = callback as () => unknown; }, - }); - - const appUpdate = (statsHash: string, outputContent: string, isFirstCompile = false) => Object.freeze({ - environment: Object.freeze({ name: 'app' }), - isFirstCompile, - stats: Object.freeze({ - compilation: Object.freeze({ - getAssets: () => Object.freeze([ - Object.freeze({ - name: 'edit-timeline-v1.html', - source: Object.freeze({ buffer: () => Buffer.from(outputContent) }), - }), - ]), - }), - hasErrors: () => false, - hash: statsHash, - }), - }); - const firstAppUpdate = appUpdate('app-change-a', 'app-output-a', true); - const duplicateFirstAppUpdate = appUpdate('split-app-change-a', 'app-output-a'); - const appBUpdate = appUpdate('app-change-b', 'app-output-b'); - const duplicateAppBUpdate = appUpdate('split-app-change-b', 'app-output-b'); - const appAUpdate = appUpdate('returned-app-change-a', 'app-output-a'); - const repeatedAppBUpdate = appUpdate('returned-app-change-b', 'app-output-b'); - const failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } }); - const unidentifiableAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'unreadable-output' } }); - const hashlessAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false } }); - const nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } }); - - afterCompiler?.({ environments: { app: {}, widget: {} } }); - afterEnvironmentCompile?.(appBUpdate); - expect(reloads).toEqual([]); - beforeStartDevServer?.({ server: { environments: { app: {}, widget: {} } } }); - afterEnvironmentCompile?.(firstAppUpdate); - afterEnvironmentCompile?.(nonAppUpdate); - afterEnvironmentCompile?.(failedAppUpdate); - afterEnvironmentCompile?.(duplicateFirstAppUpdate); - expect(reloads).toEqual([]); - - afterEnvironmentCompile?.(appBUpdate); - expect(reloads).toEqual([1]); - afterEnvironmentCompile?.(duplicateAppBUpdate); - expect(reloads).toEqual([1]); - afterEnvironmentCompile?.(appAUpdate); - expect(reloads).toEqual([1, 2]); - afterEnvironmentCompile?.(repeatedAppBUpdate); - expect(reloads).toEqual([1, 2, 3]); - // An unreadable asset set is unidentifiable even when stats has a hash. It - // reloads at least once without clobbering the retained output identity, so - // the later readable completion remains deduped. - afterEnvironmentCompile?.(unidentifiableAppUpdate); - afterEnvironmentCompile?.(hashlessAppUpdate); - expect(reloads).toEqual([1, 2, 3, 4, 5]); - afterEnvironmentCompile?.(repeatedAppBUpdate); - expect(reloads).toEqual([1, 2, 3, 4, 5]); - - await closeDevServer?.(); - afterEnvironmentCompile?.(appAUpdate); - expect(reloads).toEqual([1, 2, 3, 4, 5]); - - beforeStartDevServer?.({ server: { environments: { app: {}, widget: {} } } }); - afterEnvironmentCompile?.(appBUpdate); - expect(reloads).toEqual([1, 2, 3, 4, 5, 6]); -}); - test('keeps compiler-App HMR out of the opaque browser child', () => { const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-outer-hmr'), @@ -444,41 +333,27 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil try { await waitFor(() => session.status().state === 'active'); expect(session.status()).toMatchObject({ hmrReady: true, state: 'active' }); - expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ - entryPath: '/edit-timeline-v1.html', - httpOrigin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:[1-9]\d*$/u), - httpPathPrefixes: ['/'], - subscribeReload: expect.any(Function), - surfaceId: 'mcp.edit-timeline', - }); - expect(session.status()).not.toHaveProperty('clientSurface'); expect(session.surfaces()).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'hook' }), expect.objectContaining({ id: 'mcp.render_edit_timeline', kind: 'mcp-tool' }), expect.objectContaining({ id: 'mcp.edit-timeline', kind: 'mcp-resource' }), expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app' }), ])); - const registry = session.mcpRegistry.snapshot(); - expect(registry).toMatchObject({ runtimeGenerationId: expect.any(String) }); - expect([...new Set([ - registry!.definitionDigest, - registry!.servers[0]!.serverDigest, - registry!.transportDigest, - ])]).toHaveLength(3); + const runtimeGenerationId = session.status().activeVector!.runtimeGenerationId; await expect(session.readAsset({ path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, + runtimeGenerationId, surfaceId: 'mcp.timeline', })).resolves.toMatchObject({ contentType: 'text/html' }); await expect(session.readAsset({ path: ['..'], - runtimeGenerationId: registry!.runtimeGenerationId, + runtimeGenerationId, surfaceId: 'mcp.timeline', })).resolves.toBeUndefined(); await expect(session.readAsset({ path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, + runtimeGenerationId, surfaceId: 'mcp.unknown', })).resolves.toBeUndefined(); for (const path of [ @@ -491,7 +366,7 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil ]) { await expect(session.readAsset({ path, - runtimeGenerationId: registry!.runtimeGenerationId, + runtimeGenerationId, surfaceId: 'mcp.timeline', })).resolves.toBeUndefined(); } @@ -509,7 +384,7 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil runtimeStorageRoot, 'generation-store', 'generations', - registry!.runtimeGenerationId, + runtimeGenerationId, 'widget', 'rsc', 'index.html', @@ -517,7 +392,7 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil const originalAsset = await readFile(assetPath); const readTimelineAsset = () => session.readAsset({ path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, + runtimeGenerationId, surfaceId: 'mcp.timeline', }); const digestTampered = Buffer.from(originalAsset); @@ -537,16 +412,6 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil await rm(assetPath, { recursive: true }); await writeFile(assetPath, originalAsset); - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - const initialCapabilities = mcp.snapshot().connection.capabilities; - if (initialCapabilities === undefined) throw new Error('Expected runtime MCP capabilities.'); - expect(initialCapabilities).toEqual({ resources: {}, tools: {} }); - expect(Object.isFrozen(initialCapabilities)).toBe(true); - expect(Object.isFrozen(initialCapabilities.resources)).toBe(true); - expect(Object.isFrozen(initialCapabilities.tools)).toBe(true); - const list = await mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' }); - expect(list.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); - const originalBinding = mcp.snapshot().binding; await session.reconcilePreparedRuntime({ ...prepared.devRuntime!, apps: prepared.devRuntime!.apps.map((app) => ({ @@ -555,39 +420,47 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil })), sourceRevision: `${prepared.devRuntime!.sourceRevision}-app-metadata`, }); - const reconciledRegistry = session.mcpRegistry.snapshot(); - expect(reconciledRegistry!.definitionDigest).not.toBe(registry!.definitionDigest); - expect(reconciledRegistry).toMatchObject({ - registryRevision: originalBinding.registryRevision + 1, - runtimeGenerationId: registry!.runtimeGenerationId, - }); - expect(mcp.snapshot().binding.sessionRevision).toBe(originalBinding.sessionRevision + 1); - await expect(mcp.execute({ expectedSessionRevision: originalBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - await expect(mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' })).resolves.toMatchObject({ - vector: { runtimeGenerationId: registry!.runtimeGenerationId }, - }); - expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); - await session.reconcilePreparedRuntime({ + expect(session.surfaces()).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'mcp.render_edit_timeline', targets: prepared.devRuntime!.servers[0]!.targets }), + expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app' }), + ])); + await expect(readTimelineAsset()).resolves.toMatchObject({ contentType: 'text/html' }); + + const definitionPath = join(runtimeStorageRoot, 'generation-store', 'generations', runtimeGenerationId, 'rsc', 'runtime-definition.json'); + await rename(definitionPath, `${definitionPath}.hidden`); + await expect(session.reconcilePreparedRuntime({ ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-p1-revert`, - }); - const revertedRegistry = session.mcpRegistry.snapshot(); - expect(revertedRegistry).toMatchObject({ - definitionDigest: registry!.definitionDigest, - registryRevision: originalBinding.registryRevision + 2, - runtimeGenerationId: registry!.runtimeGenerationId, - }); - const revertedRevision = mcp.snapshot().binding.sessionRevision; + sourceRevision: `${prepared.devRuntime!.sourceRevision}-unreadable-definition`, + })).rejects.toThrow(); + expect(session.status()).toMatchObject({ state: 'degraded' }); + await rename(`${definitionPath}.hidden`, definitionPath); + await session.reconcilePreparedRuntime({ ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-p3-repeat`, + servers: prepared.devRuntime!.servers.map((server) => ({ ...server, targets: ['portable'] })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-portable-only`, }); - expect(session.mcpRegistry.snapshot()).toMatchObject({ - definitionDigest: registry!.definitionDigest, - registryRevision: revertedRegistry!.registryRevision, + expect(session.surfaces()).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'mcp.render_edit_timeline', targets: ['portable'] }), + expect.objectContaining({ id: 'mcp.edit-timeline', targets: ['portable'] }), + ])); + expect(session.status()).toMatchObject({ diagnostics: [], state: 'active' }); + const timeline = await session.invoke({ + expectedGenerationId: runtimeGenerationId, + input: {}, + surfaceId: 'mcp.render_edit_timeline', + target: 'portable', }); - expect(mcp.snapshot().binding.sessionRevision).toBe(revertedRevision); - await mcp.close(); + expect(timeline).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId } }); + if (timeline.status !== 'succeeded') throw new Error('Timeline invocation did not succeed.'); + expect(timeline.result).not.toHaveProperty('app'); + await expect(session.invoke({ + expectedGenerationId: runtimeGenerationId, + input: {}, + surfaceId: 'mcp.render_edit_timeline', + target: 'claude', + })).rejects.toThrow('does not support target'); + const closing = session.close(); await expect(session.reconcilePreparedRuntime({ ...prepared.devRuntime!, @@ -595,7 +468,6 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil })).rejects.toThrow('RSC runtime session is closed.'); await closing; expect(session.status()).toMatchObject({ hmrReady: false, state: 'closed' }); - expect(session.clientSurface('mcp.edit-timeline')).toBeUndefined(); } finally { await session.close(); } @@ -1064,7 +936,7 @@ test('binds renamed and added App surfaces to the active generation assets witho })); try { await waitFor(() => session.status().state === 'active'); - const runtimeGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const runtimeGenerationId = session.status().activeVector!.runtimeGenerationId; const original = prepared.devRuntime!.apps[0]!; await session.reconcilePreparedRuntime({ ...prepared.devRuntime!, @@ -1132,7 +1004,7 @@ test('rebinds current App surfaces across retained generations after a later con })); try { await waitFor(() => session.status().state === 'active'); - const firstGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const firstGenerationId = session.status().activeVector!.runtimeGenerationId; await changeWorkerImplementation(copied.projectRoot, 'reconciled-retained-app-assets-generation-two'); await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGenerationId); const secondGenerationId = session.status().activeVector!.runtimeGenerationId; @@ -1171,50 +1043,7 @@ test('rebinds current App surfaces across retained generations after a later con } }, 30_000 * timeScale); -test('keeps the same MCP session and revision across an implementation-only generation', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-implementation-only', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-implementation-only'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const beforeGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const before = mcp.snapshot(); - await changeWorkerImplementation(copied.projectRoot, 'implementation-only'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== beforeGeneration); - const after = mcp.snapshot(); - expect(after.binding).toMatchObject({ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - }); - await expect(mcp.execute({ - expectedSessionRevision: after.binding.sessionRevision, - kind: 'list-tools', - })).resolves.toMatchObject({ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - vector: { runtimeGenerationId: session.status().activeVector!.runtimeGenerationId }, - }); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000 * timeScale); - -test('restarts and relists an open MCP session after a warm-cache definition change', async () => { +test('activates a warm-cache definition change and republishes the changed tool description as its surface label', async () => { const copied = await copyProviderExample(); try { const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); @@ -1225,156 +1054,21 @@ test('restarts and relists an open MCP session after a warm-cache definition cha signal: new AbortController().signal, storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-definition-change'), })); + const statusLabel = () => session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')?.label; try { await waitFor(() => session.status().state === 'active'); - const beforeRegistry = session.mcpRegistry.snapshot()!; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const before = mcp.snapshot().binding; - await changeDefinition(copied.projectRoot, 'Read the freshly rebuilt shared runtime state.'); - await waitFor(() => session.mcpRegistry.snapshot()!.definitionDigest !== beforeRegistry.definitionDigest); - const afterRegistry = session.mcpRegistry.snapshot()!; - const after = mcp.snapshot(); - expect(afterRegistry.runtimeGenerationId).not.toBe(beforeRegistry.runtimeGenerationId); - expect(after.binding.sessionRevision).toBe(before.sessionRevision + 1); - await expect(mcp.execute({ - expectedSessionRevision: after.binding.sessionRevision, - kind: 'list-tools', - })).resolves.toMatchObject({ vector: { runtimeGenerationId: afterRegistry.runtimeGenerationId } }); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000 * timeScale); - -test('uses the live registry authority after a transport-only runtime MCP reconciliation', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-live-transport-authority', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-live-transport-authority'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const initialRegistry = session.mcpRegistry.snapshot()!; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const initialBinding = mcp.snapshot().binding; - const definitionPrepared = Object.freeze({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => Object.freeze({ - ...app, - _meta: Object.freeze({ ...app._meta, 'openai/widgetDescription': 'Live definition authority.' }), - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-definition-v2`, - }); - await session.reconcilePreparedRuntime(definitionPrepared); - const definitionRegistry = session.mcpRegistry.snapshot()!; - const definitionBinding = mcp.snapshot().binding; - expect(definitionRegistry).toMatchObject({ - registryRevision: initialRegistry.registryRevision + 1, - runtimeGenerationId: initialRegistry.runtimeGenerationId, - transportDigest: initialRegistry.transportDigest, - }); - expect(definitionRegistry.definitionDigest).not.toBe(initialRegistry.definitionDigest); - expect(definitionBinding).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision, - sessionId: initialBinding.sessionId, - sessionRevision: initialBinding.sessionRevision + 1, - }); - await expect(mcp.execute({ expectedSessionRevision: initialBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - const definitionRun = await session.invoke({ - expectedGenerationId: definitionRegistry.runtimeGenerationId, - input: {}, - surfaceId: 'mcp.render_edit_timeline', - target: 'portable', - }); - expect(definitionRun).toMatchObject({ - status: 'succeeded', vector: { runtimeGenerationId: definitionRegistry.runtimeGenerationId }, - }); - if (definitionRun.status !== 'succeeded' || definitionRun.result.app === undefined) throw new Error('Definition reconciliation run omitted its Runtime App binding.'); - const definitionAppBinding = definitionRun.result.app.mcpBinding; - expect(definitionAppBinding).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision, - sessionId: expect.any(String), - sessionRevision: expect.any(Number), - transportDigest: definitionRegistry.transportDigest, - }); - - await session.reconcilePreparedRuntime({ - ...definitionPrepared, - servers: definitionPrepared.servers.map((server) => Object.freeze({ - ...server, - env: Object.freeze({ ...(server.env ?? {}), TIMELINE_TRANSPORT_SENTINEL: 'transport-v2' }), - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-transport-v2`, - }); - const registry = session.mcpRegistry.snapshot()!; - const currentBinding = mcp.snapshot().binding; - expect(registry).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision + 1, - runtimeGenerationId: definitionRegistry.runtimeGenerationId, - }); - expect(registry.transportDigest).not.toBe(definitionRegistry.transportDigest); - expect(currentBinding).toMatchObject({ - registryRevision: registry.registryRevision, - sessionId: definitionBinding.sessionId, - sessionRevision: definitionBinding.sessionRevision + 1, - transportDigest: registry.transportDigest, - }); - await expect(mcp.execute({ expectedSessionRevision: definitionBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - - const appRun = await session.invoke({ - expectedGenerationId: registry.runtimeGenerationId, - input: {}, - surfaceId: 'mcp.render_edit_timeline', - target: 'portable', - }); - expect(appRun).toMatchObject({ - status: 'succeeded', vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - if (appRun.status !== 'succeeded' || appRun.result.app === undefined) throw new Error('Transport reconciliation run omitted its Runtime App binding.'); - expect(appRun.result.app.mcpBinding).toMatchObject({ - definitionDigest: registry.definitionDigest, - registryRevision: registry.registryRevision, - sessionId: definitionAppBinding.sessionId, - sessionRevision: definitionAppBinding.sessionRevision + 1, - transportDigest: registry.transportDigest, - }); - await expect(mcp.execute({ - expectedSessionRevision: currentBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).resolves.toMatchObject({ - sessionId: currentBinding.sessionId, - sessionRevision: currentBinding.sessionRevision, - vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - await expect(mcp.execute({ - arguments: { limit: 1 }, - expectedSessionRevision: currentBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).resolves.toMatchObject({ - sessionId: currentBinding.sessionId, - sessionRevision: currentBinding.sessionRevision, - vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - } finally { - await mcp.close(); - } + const beforeGeneration = session.status().activeVector!.runtimeGenerationId; + expect(statusLabel()).toBe('Read the current shared runtime state.'); + await changeDefinition(copied.projectRoot, 'Read the freshly rebuilt shared runtime state.'); + await waitFor(() => statusLabel() === 'Read the freshly rebuilt shared runtime state.'); + const afterGeneration = session.status().activeVector!.runtimeGenerationId; + expect(afterGeneration).not.toBe(beforeGeneration); + await expect(session.invoke({ + expectedGenerationId: afterGeneration, + input: {}, + surfaceId: 'mcp.runtime_status', + target: 'portable', + })).resolves.toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: afterGeneration } }); } finally { await session.close(); } @@ -1383,145 +1077,67 @@ test('uses the live registry authority after a transport-only runtime MCP reconc } }, 30_000 * timeScale); -test('rejects MCP admission until a deferred public prepared-config restart has relisted', async () => { +test('aborts a stale activation transaction at its private preparation boundary', async () => { const copied = await copyProviderExample(); try { const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const relistReached = deferred(); - const allowRelist = deferred(); - let deferRelist = false; - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-deferred-restart', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-deferred-restart'), - }), { - beforeMcpRelist: async () => { - if (!deferRelist) return; - relistReached.resolve(); - await allowRelist.promise; + const reached = deferred(); + const allow = deferred(); + const events: Array<{ readonly runtimeGenerationId?: string; readonly type: string }> = []; + let armBarrier = false; + let held = false; + const session = await RsbuildRuntimeSession.start({ + ...startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-store-prepare', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-store-prepare'), + }), + emit: (event) => { events.push(event); }, + }, { + afterActivationPrepare: async () => { + if (!armBarrier || held) return; + held = true; + reached.resolve(); + await allow.promise; }, }); try { await waitFor(() => session.status().state === 'active'); - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); - const before = mcp.snapshot().binding; - deferRelist = true; - const reconciling = session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => ({ - ...app, - _meta: { ...app._meta, 'openai/widgetDescription': 'Restart after deferred relist.' }, - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-deferred-public-restart`, - }); - await relistReached.promise; - const restarting = mcp.snapshot(); - expect(restarting).toMatchObject({ state: 'restarting' }); - await expect(mcp.execute({ - expectedSessionRevision: restarting.binding.sessionRevision, - kind: 'list-tools', - })).rejects.toThrow('Runtime MCP session is restarting.'); - allowRelist.resolve(); - await reconciling; - expect(mcp.snapshot()).toMatchObject({ - binding: { sessionRevision: before.sessionRevision + 1 }, - state: 'ready', - }); - const restartedCapabilities = mcp.snapshot().connection.capabilities; - if (restartedCapabilities === undefined) throw new Error('Expected restarted runtime MCP capabilities.'); - expect(restartedCapabilities).toEqual({ resources: {}, tools: {} }); - expect(Object.isFrozen(restartedCapabilities)).toBe(true); - expect(Object.isFrozen(restartedCapabilities.resources)).toBe(true); - expect(Object.isFrozen(restartedCapabilities.tools)).toBe(true); - } finally { - await mcp.close(); - } + const firstGeneration = session.status().activeVector!.runtimeGenerationId; + armBarrier = true; + await changeDefinition(copied.projectRoot, 'Read state after store preparation.'); + await reached.promise; + expect(session.status().activeVector?.runtimeGenerationId).toBe(firstGeneration); + const reconciled = session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: prepared.devRuntime!.apps.map((app) => ({ + ...app, + source: './src/widget/App.tsx', + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-store-superseding-prepared`, + }); + allow.resolve(); + await reconciled; + await new Promise((resolve) => { setTimeout(resolve, 50); }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: 'generation-2', + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + expect(session.status().activeVector?.runtimeGenerationId).toBe(firstGeneration); + expect(events.filter((event) => event.type === 'runtime.generation.activated' && event.runtimeGenerationId === 'generation-2')).toHaveLength(0); + armBarrier = false; + await changeWorkerImplementation(copied.projectRoot, 'store-current-generation'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGeneration); + expect(session.status().activeVector?.runtimeGenerationId).not.toBe('generation-2'); } finally { await session.close(); } } finally { await rm(copied.workspaceRoot, { force: true, recursive: true }); } -}, 30_000 * timeScale); - -test('aborts stale activation transactions at both private preparation boundaries', async () => { - for (const phase of ['store', 'registry'] as const) { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const reached = deferred(); - const allow = deferred(); - const events: Array<{ readonly runtimeGenerationId?: string; readonly type: string }> = []; - let armBarrier = false; - let held = false; - const session = await RsbuildRuntimeSession.start({ - ...startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: `provider-${phase}-prepare`, - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', `runtime-${phase}-prepare`), - }), - emit: (event) => { events.push(event); }, - }, { - afterActivationPrepare: async (input) => { - if (!armBarrier || held || input.phase !== phase) return; - held = true; - reached.resolve(); - await allow.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const firstBinding = mcp.snapshot().binding; - armBarrier = true; - await changeDefinition(copied.projectRoot, `Read state after ${phase} preparation.`); - await reached.promise; - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); - const reconciled = session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => ({ - ...app, - source: './src/widget/App.tsx', - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-${phase}-superseding-prepared`, - }); - allow.resolve(); - await reconciled; - await new Promise((resolve) => { setTimeout(resolve, 50); }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: 'generation-2', - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); - expect(mcp.snapshot().binding).toMatchObject({ - sessionId: firstBinding.sessionId, - sessionRevision: firstBinding.sessionRevision, - }); - expect(events.filter((event) => event.type === 'runtime.generation.activated' && event.runtimeGenerationId === 'generation-2')).toHaveLength(0); - armBarrier = false; - await changeWorkerImplementation(copied.projectRoot, `${phase}-current-generation`); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGeneration); - expect(session.status().activeVector?.runtimeGenerationId).not.toBe('generation-2'); - expect(mcp.snapshot().binding.sessionRevision).toBe(firstBinding.sessionRevision + 1); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } - } }, 60_000 * timeScale); test('commits a compiled generation across an equivalent prepared-runtime revision', { timeout: 0 }, async () => { @@ -1539,8 +1155,8 @@ test('commits a compiled generation across an equivalent prepared-runtime revisi signal: new AbortController().signal, storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-equivalent-prepared-revision'), }), { - afterActivationPrepare: async (input) => { - if (!armBarrier || held || input.phase !== 'store') return; + afterActivationPrepare: async () => { + if (!armBarrier || held) return; held = true; reached.resolve(); await allow.promise; @@ -1548,7 +1164,7 @@ test('commits a compiled generation across an equivalent prepared-runtime revisi }); try { await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const firstGeneration = session.status().activeVector!.runtimeGenerationId; armBarrier = true; await changeDefinition(copied.projectRoot, 'Read state after equivalent prepared revision.'); await reached.promise; @@ -1570,7 +1186,6 @@ test('commits a compiled generation across an equivalent prepared-runtime revisi const committedGeneration = session.status().activeVector?.runtimeGenerationId; expect(committedGeneration).toEqual(expect.any(String)); expect(committedGeneration).not.toBe(firstGeneration); - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: committedGeneration }); expect(session.status()).toMatchObject({ activeVector: { runtimeGenerationId: committedGeneration }, diagnostics: [], @@ -1769,8 +1384,8 @@ test('collapses an in-flight activation into a compile observed during its guard }), emit: (event) => { events.push(event); }, }, { - afterActivationPrepare: async (input) => { - if (!armPrepareBarrier || input.phase !== 'registry') return; + afterActivationPrepare: async () => { + if (!armPrepareBarrier) return; armPrepareBarrier = false; prepareReached.resolve(); await allowPrepare.promise; @@ -1834,8 +1449,8 @@ test('commits after the observation grace when an observed compile never complet emit: (event) => { events.push(event); }, }, { activationPhaseBudgetMs: 4_000 * timeScale, - afterActivationPrepare: async (input) => { - if (!armPrepareBarrier || input.phase !== 'registry') return; + afterActivationPrepare: async () => { + if (!armPrepareBarrier) return; armPrepareBarrier = false; prepareReached.resolve(); await allowPrepare.promise; @@ -1868,7 +1483,7 @@ test('commits after the observation grace when an observed compile never complet } }); -test('fails a wedged activation reconcile within the budget and releases its late reservation', { timeout: 120_000 * timeScale }, async () => { +test('fails a wedged activation store step within the budget and releases its late reservation', { timeout: 120_000 * timeScale }, async () => { const copied = await copyProviderExample(); try { const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); @@ -1888,7 +1503,7 @@ test('fails a wedged activation reconcile within the budget and releases its lat emit: (event) => { events.push(event); }, }, { activationPhaseBudgetMs: activationBudgetMs * timeScale, - beforeMcpRelist: async () => { + afterActivationPrepare: async () => { if (!armWedge) return; wedgeReached.resolve(); await releaseWedge.promise; @@ -1897,34 +1512,29 @@ test('fails a wedged activation reconcile within the budget and releases its lat try { await waitFor(() => session.status().state === 'active'); const firstGeneration = session.status().activeVector!.runtimeGenerationId; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - armWedge = true; - await changeDefinition(copied.projectRoot, 'Read state after a wedged activation reconcile.'); - await wedgeReached.promise; - // A wedged MCP reconcile must become a loud, phase-attributed failure - // within the scaled budget instead of the silent permanent hang from - // #38; the page recovers through its failed-event bootstrap path. - await waitForWithin( - () => session.status().diagnostics.some((diagnostic) => diagnostic.message.includes('mcp-registry activation step exceeded')), - activationBudgetMs + 15_000, - ); - expect(events.some((event) => event.type === 'runtime.generation.failed')).toBe(true); - expect(session.status().activeVector?.runtimeGenerationId).toBe(firstGeneration); - // Releasing the wedge lets the abandoned preparation settle late; its - // registry reservation must be released, or every later activation - // would wedge behind it. - armWedge = false; - releaseWedge.resolve(); - await changeWorkerImplementation(copied.projectRoot, 'post-wedge-activation'); - await waitForWithin( - () => session.status().activeVector?.runtimeGenerationId !== firstGeneration, - activationBudgetMs + 15_000, - ); - expect(session.status().activeVector?.runtimeGenerationId).not.toBe(firstGeneration); - } finally { - await mcp.close(); - } + armWedge = true; + await changeDefinition(copied.projectRoot, 'Read state after a wedged activation store step.'); + await wedgeReached.promise; + // A wedged store step must become a loud, phase-attributed failure + // within the scaled budget instead of the silent permanent hang from + // #38; the page recovers through its failed-event bootstrap path. + await waitForWithin( + () => session.status().diagnostics.some((diagnostic) => diagnostic.message.includes('generation-store activation step exceeded')), + activationBudgetMs + 15_000, + ); + expect(events.some((event) => event.type === 'runtime.generation.failed')).toBe(true); + expect(session.status().activeVector?.runtimeGenerationId).toBe(firstGeneration); + // Releasing the wedge lets the abandoned preparation settle late; its + // store reservation must be released, or every later activation would + // wedge behind it. + armWedge = false; + releaseWedge.resolve(); + await changeWorkerImplementation(copied.projectRoot, 'post-wedge-activation'); + await waitForWithin( + () => session.status().activeVector?.runtimeGenerationId !== firstGeneration, + activationBudgetMs + 15_000, + ); + expect(session.status().activeVector?.runtimeGenerationId).not.toBe(firstGeneration); } finally { await session.close(); } @@ -1956,7 +1566,7 @@ test('retains a leased inactive generation through pruning and prunes it after t }); try { await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const firstGeneration = session.status().activeVector!.runtimeGenerationId; const heldRead = session.readAsset({ path: ['rsc', 'index.html'], runtimeGenerationId: firstGeneration, @@ -2038,8 +1648,7 @@ test('returns a compiling session without treating provider activation work as a signal: new AbortController().signal, storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-compiling-startup'), }), { - afterActivationPrepare: async (input) => { - if (input.phase !== 'store') return; + afterActivationPrepare: async () => { activationReached.resolve(); await releaseActivation.promise; }, @@ -2074,58 +1683,6 @@ test('returns a compiling session without treating provider activation work as a } }, 60_000); -test('uses the bound Rsbuild dev-server context instead of a stale port-zero start result', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - let closeCalls = 0; - const create = async (input: Readonly<{ readonly config: unknown }>) => { - const plugin = ((input.config as Readonly<{ readonly plugins?: readonly unknown[] }>).plugins ?? []).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload'); - if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.'); - let afterCreate: ((input: unknown) => void) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: () => undefined, - onBeforeStartDevServer: () => undefined, - onCloseDevServer: () => undefined, - }); - afterCreate?.({ environments: { app: {} } }); - return Object.freeze({ - context: Object.freeze({ - devServer: Object.freeze({ hostname: '127.0.0.1', https: false, port: 41_103 }), - }), - startDevServer: async () => Object.freeze({ - port: 0, - server: Object.freeze({ close: async () => { closeCalls += 1; } }), - urls: Object.freeze(['http://127.0.0.1:0']), - }) as unknown as StartDevServerResult, - }) as unknown as Awaited>; - }; - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-bound-dev-server-context', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-bound-dev-server-context'), - }), { createRsbuild: create as typeof createRsbuild }); - try { - expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ - httpOrigin: 'http://127.0.0.1:41103', - subscribeReload: expect.any(Function), - }); - } finally { - await session.close(); - } - expect(closeCalls).toBe(1); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - test('waits for a late Rsbuild server closer after aborting startup', async () => { const copied = await copyProviderExample(); try { @@ -2375,7 +1932,6 @@ test('drains every live-session cleanup group once when independent closers reje 'owned-runs-root', 'rsbuild-dev-server', 'run-artifact', - 'runtime-mcp-registry', ])); expect(new Set(attempted).size).toBe(attempted.length); expect(session.close()).toBe(closing); diff --git a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts index 0d203bd69..b95830e64 100644 --- a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts +++ b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts @@ -20,7 +20,6 @@ import { captureRuntimeGenerationSnapshot, materializeRuntimeGeneration, rscRuntimeGenerationMetadataCodec, - runtimeDefinitionDigest, validateRscRuntimeGenerationMetadata, validateStagedRscEnvironmentCheckpoint, type RscRuntimeCapturedGenerationSnapshot, @@ -338,89 +337,36 @@ test('captures immutable paired compiler outputs and records every digested asse 'widget/rsc/index.html', 'widget/static/js/rsc/index.js', ])); - expect(prepared.generation.manifest.metadata.definitionDigest) - .toBe(sha256('{"apps":[],"definition":{"nativeHooks":[],"resources":[],"tools":[]}}')); - expect(prepared.generation.manifest.metadata.environmentHashes).toEqual(expect.objectContaining({ - rsc: expect.stringMatching(/^[a-f0-9]{64}$/u), - widget: expect.stringMatching(/^[a-f0-9]{64}$/u), - })); + expect(Object.keys(prepared.generation.manifest.metadata).sort()) + .toEqual(['appDefinitions', 'entries', 'stateStoreId', 'surfaceAssets']); } finally { await store.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); } }); -test('includes prepared App definitions in the captured runtime definition digest', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-definition-digest-')); +test('captures canonical, ordered, frozen App definitions', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-definitions-')); const compilerRoot = join(storageRoot, 'compiler'); const store = createStore(storageRoot); try { await writeCompilerCohort(compilerRoot); - const metadataFor = async ( - id: string, - prepared: DevRuntimePreparedProject, - sourceRevision = 'captured-r1', - ) => { - const candidate = await store.begin({ id, sourceRevision }); + const metadataFor = async (id: string, prepared: DevRuntimePreparedProject) => { + const candidate = await store.begin({ id, sourceRevision: 'captured-r1' }); const snapshot = await captureCompilerCohort({ attemptId: `attempt-${id}`, candidate, compilerRoot, preparedRuntime: prepared, rscCohortRevision: 1, - sourceRevision, + sourceRevision: 'captured-r1', }); - const generation = await materializeRuntimeGeneration({ snapshot, store }); - return Object.freeze({ generation: generation.generation, metadata: generation.generation.manifest.metadata, snapshot }); + return (await materializeRuntimeGeneration({ snapshot, store })).generation.manifest.metadata; }; - const baseline = await metadataFor('baseline', preparedRuntimeWithApp()); - const appDefinitionVariants: readonly Readonly<{ readonly id: string; readonly prepared: DevRuntimePreparedProject }>[] = [ - { id: 'meta', prepared: preparedRuntimeWithApp({ _meta: Object.freeze({ presentation: Object.freeze({ accent: 'teal', version: 2 }) }) }) }, - { id: 'id', prepared: preparedRuntimeWithApp({ id: 'timeline-app-v2' }) }, - { id: 'name', prepared: preparedRuntimeWithApp({ name: 'Timeline v2' }) }, - { id: 'server-id', prepared: preparedRuntimeWithApp({ serverId: 'timeline-server-v2' }) }, - { id: 'server-name', prepared: preparedRuntimeWithApp({ serverName: 'Timeline MCP v2' }) }, - { id: 'resource-uri', prepared: preparedRuntimeWithApp({ resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v2.html' }) }, - { id: 'targets', prepared: preparedRuntimeWithApp({ targets: Object.freeze(['codex']) }) }, - ]; - - for (const variant of appDefinitionVariants) { - const captured = await metadataFor(variant.id, variant.prepared); - expect(captured.metadata.definitionDigest).not.toBe(baseline.metadata.definitionDigest); - expect(captured.metadata.servers.map((server) => server.definitionDigest)).toEqual([ - captured.metadata.definitionDigest, - captured.metadata.definitionDigest, - ]); - } - - const sourceAndTransportNoise = await metadataFor('noise', preparedRuntimeWithApp({ - source: '/other-machine/plugin/agent-bundle.config.ts', - template: '/other-machine/plugin/src/app/edit-timeline.html', - }, { - provider: '/other-machine/plugin/src/dev/provider.ts', - servers: Object.freeze([Object.freeze({ - args: Object.freeze(['--serve', '--token=top-secret']), - command: '/other-machine/bin/timeline-server', - cwd: '/other-machine/plugin', - env: Object.freeze({ API_TOKEN: 'top-secret' }), - headers: Object.freeze({ Authorization: 'Bearer top-secret' }), - id: 'timeline-server', - name: 'Timeline MCP', - source: '/other-machine/plugin/agent-bundle.config.ts', - targets: Object.freeze(['claude', 'codex']), - transport: 'streamable-http' as const, - url: 'https://other-machine.invalid/mcp', - })]), - sourceRevision: 'prepared-r2', - }), 'captured-r2'); - expect(sourceAndTransportNoise.metadata.definitionDigest).toBe(baseline.metadata.definitionDigest); - - expect(runtimeDefinitionDigest(baseline.snapshot.definition, baseline.snapshot.preparedRuntime)) - .toBe(baseline.metadata.definitionDigest); - - const [timelineApp] = baseline.snapshot.preparedRuntime.apps; - if (timelineApp === undefined) throw new Error('Baseline prepared App was not captured.'); + const baselinePrepared = preparedRuntimeWithApp(); + const [timelineApp] = baselinePrepared.apps; + if (timelineApp === undefined) throw new Error('Baseline prepared App was not declared.'); const activityApp = Object.freeze({ ...timelineApp, id: 'activity-app', @@ -428,23 +374,22 @@ test('includes prepared App definitions in the captured runtime definition diges resourceUri: 'ui://rsc-agent-runtime/activity-v1.html', }); const orderedForward = await metadataFor('ordered-forward', Object.freeze({ - ...baseline.snapshot.preparedRuntime, + ...baselinePrepared, apps: Object.freeze([timelineApp, activityApp]), })); const orderedReverse = await metadataFor('ordered-reverse', Object.freeze({ - ...baseline.snapshot.preparedRuntime, + ...baselinePrepared, apps: Object.freeze([activityApp, timelineApp]), })); - expect(orderedReverse.metadata.definitionDigest).toBe(orderedForward.metadata.definitionDigest); - expect(orderedForward.metadata.appDefinitions.map((app) => app.id)).toEqual(['activity-app', 'timeline-app']); - expect(orderedForward.metadata.appDefinitions.every((app) => !('template' in app))).toBe(true); - const [firstAppDefinition] = orderedForward.metadata.appDefinitions; - if (firstAppDefinition === undefined || firstAppDefinition._meta === undefined) throw new Error('Ordered App definition was malformed.'); - expect(Object.isFrozen(orderedForward.metadata.appDefinitions)).toBe(true); + expect(orderedForward.appDefinitions).toEqual([ + { id: 'activity-app', name: 'Activity', resourceUri: 'ui://rsc-agent-runtime/activity-v1.html' }, + { id: 'timeline-app', name: 'Timeline', resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, + ]); + expect(orderedReverse.appDefinitions).toEqual(orderedForward.appDefinitions); + const [firstAppDefinition] = orderedForward.appDefinitions; + if (firstAppDefinition === undefined) throw new Error('Ordered App definition was malformed.'); + expect(Object.isFrozen(orderedForward.appDefinitions)).toBe(true); expect(Object.isFrozen(firstAppDefinition)).toBe(true); - expect(Object.isFrozen(firstAppDefinition.targets)).toBe(true); - expect(Object.isFrozen(firstAppDefinition._meta)).toBe(true); - expect(Object.isFrozen(firstAppDefinition._meta.presentation)).toBe(true); } finally { await store.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 77b9ee6d9..898948636 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -348,7 +348,7 @@ export type { // The `dev.runtime.provider` protocol (#485): everything a provider module // accepts through `start()` or hands back through its session, the two errors // a provider throws to get the documented Workbench behaviour, and the -// generation store and MCP registry a session drives (below). Test-only +// generation store a session drives (below). Test-only // services (`ProjectService`, `EpochStore`, the provider loader) stay internal. export { DevRuntimeGenerationConflictError, @@ -356,16 +356,7 @@ export { } from './dev/runtime-provider.ts'; export type { CreateDevRuntimeProvider, - DevRuntimeClientSurfaceEndpoint, DevRuntimeEventInput, - DevRuntimeMcpRegistry, - DevRuntimeMcpRegistryListener, - DevRuntimeMcpRegistryMessage, - DevRuntimeMcpRegistrySubscription, - DevRuntimeMcpSession, - DevRuntimeMcpSessionCloseObservation, - DevRuntimeMcpSessionExecuteOptions, - DevRuntimeMcpSessionView, DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject, @@ -382,20 +373,6 @@ export type { DevRuntimeFixture, DevRuntimeInspectionEnvelope, DevRuntimeInvocationRequest, - DevRuntimeMcpAppRunBinding, - DevRuntimeMcpConnectionState, - DevRuntimeMcpInvalidatedBinding, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpOperationResult, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpRegistryReplayGap, - DevRuntimeMcpRegistrySnapshot, - DevRuntimeMcpServerDescriptor, - DevRuntimeMcpSessionBinding, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, - DevRuntimeMcpSessionSnapshot, DevRuntimeReplayRequest, DevRuntimeRun, DevRuntimeStateIdentity, @@ -406,13 +383,12 @@ export type { DevRuntimeTreeNode, RuntimeVector, } from './dev/runtime-protocol.ts'; -// The generation store and MCP registry a session drives, as effect-free -// contracts plus their constructors: the classes behind them throw -// `YieldableFrameworkError`s and so may not enter a public declaration graph. -export { createRuntimeGenerationStore, createRuntimeMcpRegistry } from './dev/runtime-store-factories.ts'; +// The generation store a session drives, as an effect-free contract plus its +// constructor: the class behind it throws `YieldableFrameworkError`s and so may +// not enter a public declaration graph. +export { createRuntimeGenerationStore } from './dev/runtime-store-factories.ts'; export type { DevRuntimeGenerationStore, - DevRuntimeProviderMcpRegistry, RuntimeGeneration, RuntimeGenerationActivationGuard, RuntimeGenerationAsset, @@ -428,15 +404,6 @@ export type { RuntimeGenerationStoreOptions, RuntimeGenerationValidationInput, RuntimeGenerationValidator, - RuntimeMcpCommittedActivationReconcile, - RuntimeMcpConnection, - RuntimeMcpConnector, - RuntimeMcpExecutionContext, - RuntimeMcpExecutionValue, - RuntimeMcpPreparedActivationReconcile, - RuntimeMcpRegistryCloseFailure, - RuntimeMcpRegistryErrorCode, - RuntimeMcpRegistryOptions, } from './dev/runtime-store-contracts.ts'; export interface StructuredLogger { diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 141341c8c..5fb255f78 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -179,31 +179,9 @@ export { type RuntimeGenerationValidationInput, type RuntimeGenerationValidator, } from './runtime-generation-store.ts'; -export { - RuntimeMcpRegistry, - RuntimeMcpRegistryCloseError, - RuntimeMcpRegistryError, - type RuntimeMcpCommittedActivationReconcile, - type RuntimeMcpConnection, - type RuntimeMcpConnector, - type RuntimeMcpExecutionContext, - type RuntimeMcpExecutionValue, - type RuntimeMcpPreparedActivationReconcile, - type RuntimeMcpRegistryCloseFailure, - type RuntimeMcpRegistryErrorCode, - type RuntimeMcpRegistryOptions, -} from './runtime-mcp-registry.ts'; export type { CreateDevRuntimeProvider, - DevRuntimeClientSurfaceEndpoint, DevRuntimeEventInput, - DevRuntimeMcpRegistry, - DevRuntimeMcpRegistryListener, - DevRuntimeMcpRegistryMessage, - DevRuntimeMcpRegistrySubscription, - DevRuntimeMcpSession, - DevRuntimeMcpSessionCloseObservation, - DevRuntimeMcpSessionView, DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject, @@ -220,20 +198,6 @@ export type { DevRuntimeFixture, DevRuntimeInspectionEnvelope, DevRuntimeInvocationRequest, - DevRuntimeMcpAppRunBinding, - DevRuntimeMcpConnectionState, - DevRuntimeMcpInvalidatedBinding, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpOperationResult, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpRegistryReplayGap, - DevRuntimeMcpRegistrySnapshot, - DevRuntimeMcpServerDescriptor, - DevRuntimeMcpSessionBinding, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, - DevRuntimeMcpSessionSnapshot, DevRuntimeReplayRequest, DevRuntimeRun, DevRuntimeRunResponse, diff --git a/packages/agent-bundle/src/dev/runtime-controller.ts b/packages/agent-bundle/src/dev/runtime-controller.ts index 25d4241e1..22ddbf867 100644 --- a/packages/agent-bundle/src/dev/runtime-controller.ts +++ b/packages/agent-bundle/src/dev/runtime-controller.ts @@ -6,13 +6,7 @@ import { isRecord } from '../core/strict-json.ts'; import type { ArtifactStatus, JsonObject, JsonValue, RuntimeEvent } from './types.ts'; import { DevRuntimeUnavailableError, - type DevRuntimeClientSurfaceEndpoint, type DevRuntimeEventInput, - type DevRuntimeMcpRegistry, - type DevRuntimeMcpRegistryListener, - type DevRuntimeMcpRegistrySubscription, - type DevRuntimeMcpSession, - type DevRuntimeMcpSessionView, type DevRuntimePreparedProject, type DevRuntimeProvider, type DevRuntimeSession, @@ -23,11 +17,6 @@ import type { DevRuntimeDescriptor, DevRuntimeDiagnostic, DevRuntimeInvocationRequest, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, DevRuntimeReplayRequest, DevRuntimeRun, DevRuntimeStateIdentity, @@ -295,12 +284,6 @@ const snapshotSurface = (value: unknown): DevRuntimeSurface => { const snapshotSurfaces = (value: unknown): readonly DevRuntimeSurface[] => Object.freeze(snapshotArray(value).map(snapshotSurface)); -const call = (owner: object, key: PropertyKey, args: readonly unknown[] = []): TResult => { - const candidate = (owner as Record)[key]; - if (typeof candidate !== 'function') throw new DevRuntimeUnavailableError(); - return Reflect.apply(candidate, owner, args) as TResult; -}; - export interface DevRuntimeControllerOptions { readonly artifactStatus: () => ArtifactStatus; readonly emit: (event: RuntimeEvent) => void; @@ -324,7 +307,6 @@ export class DevRuntimeController implements DevRuntimeSession { readonly #emit: (event: RuntimeEvent) => void; readonly #environment: Readonly>; readonly #initialProviderPath: string; - readonly #mcpRegistry: DevRuntimeMcpRegistry; readonly #projectRoot: string; readonly #provider: DevRuntimeProvider | undefined; readonly #providerSessionId: string; @@ -364,11 +346,6 @@ export class DevRuntimeController implements DevRuntimeSession { this.#status = options.provider === undefined ? statusFor(unavailableDescriptor, 'failed', [lifecycleDiagnostic()]) : statusFor(options.provider.descriptor, 'starting'); - this.#mcpRegistry = this.#createMcpRegistry(); - } - - get mcpRegistry(): DevRuntimeMcpRegistry { - return this.#mcpRegistry; } get providerSessionId(): string { @@ -380,10 +357,6 @@ export class DevRuntimeController implements DevRuntimeSession { return this.#startPromise; } - clientSurface(surfaceId: string): DevRuntimeClientSurfaceEndpoint | undefined { - return this.#activeSession().clientSurface(surfaceId); - } - close(): Promise { this.#closePromise ??= this.#close(); return this.#closePromise; @@ -458,73 +431,6 @@ export class DevRuntimeController implements DevRuntimeSession { return this.#session; } - #rawRegistry(): DevRuntimeMcpRegistry { - const registry = this.#activeSession().mcpRegistry; - if (!isRecord(registry)) throw new DevRuntimeUnavailableError(); - for (const name of ['close', 'closeSession', 'open', 'reconcile', 'restart', 'session', 'snapshot', 'subscribe'] as const) { - if (typeof registry[name] !== 'function') throw new DevRuntimeUnavailableError(); - } - return registry as unknown as DevRuntimeMcpRegistry; - } - - #createMcpView(resolveView: () => DevRuntimeMcpSessionView | undefined): DevRuntimeMcpSessionView { - const current = (): DevRuntimeMcpSessionView => { - this.#rawRegistry(); - const view = resolveView(); - if (view === undefined) throw new DevRuntimeUnavailableError(); - return view; - }; - return Object.freeze({ - execute: async (request: DevRuntimeMcpOperationRequest) => - call>>>(current(), 'execute', [request]), - snapshot: () => call>(current(), 'snapshot'), - watchClosed: (listener: (reason?: unknown) => Promise | void) => - call>(current(), 'watchClosed', [listener]), - }); - } - - #createMcpSession(resolveSession: () => DevRuntimeMcpSession | undefined): DevRuntimeMcpSession { - const view = this.#createMcpView(resolveSession); - return Object.freeze({ - ...view, - close: async () => { - this.#rawRegistry(); - return call>(resolveSession() ?? this.#activeSession(), 'close'); - }, - }); - } - - #createMcpRegistry(): DevRuntimeMcpRegistry { - return Object.freeze({ - close: async () => call>(this.#rawRegistry(), 'close'), - closeSession: async (request: DevRuntimeMcpSessionControlRequest) => - call>(this.#rawRegistry(), 'closeSession', [request]), - open: async (request: DevRuntimeMcpSessionRequest) => { - const opened = await call>(this.#rawRegistry(), 'open', [request]); - return this.#createMcpSession(() => opened); - }, - reconcile: async (input: DevRuntimeMcpRegistryReconcileInput): Promise => - call>(this.#rawRegistry(), 'reconcile', [input]), - restart: async (request: DevRuntimeMcpSessionControlRequest): Promise => - call>(this.#rawRegistry(), 'restart', [request]), - session: (sessionId: string): DevRuntimeMcpSessionView | undefined => { - const existing = call(this.#rawRegistry(), 'session', [sessionId]); - return existing === undefined - ? undefined - : this.#createMcpView(() => call(this.#rawRegistry(), 'session', [sessionId])); - }, - snapshot: () => call>(this.#rawRegistry(), 'snapshot'), - subscribe: ( - options: Readonly<{ readonly afterSequence?: number }>, - listener: DevRuntimeMcpRegistryListener, - ): DevRuntimeMcpRegistrySubscription => call( - this.#rawRegistry(), - 'subscribe', - [options, listener], - ), - }); - } - #captureStatus(session: DevRuntimeSession): DevRuntimeStatus { if (!isRecord(session) || typeof session.status !== 'function' || typeof session.close !== 'function') { throw new DevRuntimeUnavailableError(); diff --git a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts b/packages/agent-bundle/src/dev/runtime-mcp-registry.ts deleted file mode 100644 index 03a6ccd0d..000000000 --- a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts +++ /dev/null @@ -1,1163 +0,0 @@ -import type { - DevRuntimeEventInput, - DevRuntimeMcpRegistryListener, - DevRuntimeMcpRegistrySubscription, - DevRuntimeMcpSession, - DevRuntimeMcpSessionCloseObservation, - DevRuntimeMcpSessionExecuteOptions, - DevRuntimeMcpSessionView, -} from './runtime-provider.ts'; -import type { - DevRuntimeMcpConnectionState, - DevRuntimeMcpInvalidatedBinding, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpOperationResult, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpRegistryReplayGap, - DevRuntimeMcpRegistrySnapshot, - DevRuntimeMcpServerDescriptor, - DevRuntimeMcpSessionBinding, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, - DevRuntimeMcpSessionSnapshot, - RuntimeVector, -} from './runtime-protocol.ts'; -import { isRecord } from '../core/strict-json.ts'; -import type { - DevRuntimeProviderMcpRegistry, - RuntimeGeneration, - RuntimeMcpCommittedActivationReconcile, - RuntimeMcpConnection, - RuntimeMcpExecutionValue, - RuntimeMcpPreparedActivationReconcile, - RuntimeMcpRegistryCloseFailure, - RuntimeMcpRegistryErrorCode, - RuntimeMcpRegistryOptions, -} from './runtime-store-contracts.ts'; -import type { JsonObject, JsonValue } from './types.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; - -// The contracts live in `runtime-store-contracts.ts` (effect-free, exported -// from `agent-bundle/api`); re-exported here for the internal callers. -export type { - DevRuntimeProviderMcpRegistry, - RuntimeMcpCommittedActivationReconcile, - RuntimeMcpConnection, - RuntimeMcpConnector, - RuntimeMcpExecutionContext, - RuntimeMcpExecutionValue, - RuntimeMcpPreparedActivationReconcile, - RuntimeMcpRegistryCloseFailure, - RuntimeMcpRegistryErrorCode, - RuntimeMcpRegistryOptions, -} from './runtime-store-contracts.ts'; - -const maxRetainedResults = 64; -const restartDrainTimeoutMs = 10_000; - -type MutationLane = 'activation' | 'none' | 'public'; -type RuntimeMcpRegistryMessage = DevRuntimeMcpRegistryReconcileResult | DevRuntimeMcpRegistryReplayGap; - -interface RegistryState { - readonly descriptors: ReadonlyMap; - readonly input: DevRuntimeMcpRegistryReconcileInput; - readonly registryRevision: number; -} - -interface OperationRecord { - readonly controller: AbortController; - readonly done: Promise; - readonly sessionAbort: AbortController; -} - -interface SessionRecord { - abort: AbortController; - binding: DevRuntimeMcpSessionBinding; - connection: RuntimeMcpConnection | undefined; - connectionState: DevRuntimeMcpConnectionState; - descriptor: DevRuntimeMcpServerDescriptor; - readonly id: string; - operations: Set; - readonly watchers: Set<(reason?: unknown) => Promise | void>; - closed: boolean; - state: DevRuntimeMcpSessionSnapshot['state']; -} - -interface PreparedActivationRecord { - readonly current: RegistryState | undefined; - readonly input: DevRuntimeMcpRegistryReconcileInput; - readonly invalidatedBindings: readonly DevRuntimeMcpInvalidatedBinding[]; - readonly nextDescriptors: ReadonlyMap; - readonly replacements: ReadonlyMap; - readonly requiresRestart: boolean; - readonly reservationRevision: number; - readonly stagedAbort: AbortController; -} - -interface ConnectedRuntimeMcp { - readonly connection: RuntimeMcpConnection; - readonly state: DevRuntimeMcpConnectionState; -} - -interface RetiredConnectionBatch { - readonly entries: readonly Readonly<{ - readonly abort: AbortController; - readonly connection: RuntimeMcpConnection | undefined; - readonly operations: readonly OperationRecord[]; - }>[]; - finalization: Promise | undefined; -} - -interface OrphanedConnection { - readonly connection: RuntimeMcpConnection; - finalization: Promise | undefined; -} - -interface Subscription { - closed: boolean; - lastDeliveredSequence: number; - readonly listener: DevRuntimeMcpRegistryListener; - readonly pending: RuntimeMcpRegistryMessage[]; - replaying: boolean; -} - -export class RuntimeMcpRegistryCloseError extends YieldableFrameworkError { - readonly failures: readonly RuntimeMcpRegistryCloseFailure[]; - - constructor(failures: readonly RuntimeMcpRegistryCloseFailure[]) { - super('Runtime MCP registry could not release every resource.'); - this.name = 'RuntimeMcpRegistryCloseError'; - this.failures = Object.freeze([...failures]); - } -} - -export class RuntimeMcpRegistryError extends YieldableFrameworkError { - readonly code: RuntimeMcpRegistryErrorCode; - - constructor(code: RuntimeMcpRegistryErrorCode, message: string) { - super(message); - this.name = 'RuntimeMcpRegistryError'; - this.code = code; - } -} - -const registryClosed = (): RuntimeMcpRegistryError => - new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CLOSED', 'Runtime MCP registry is closed.'); - -const registryConflict = (message: string): RuntimeMcpRegistryError => - new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CONFLICT', message); - -const registryInvalid = (message: string): RuntimeMcpRegistryError => - new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_INVALID', message); - -const registryNotFound = (message: string): RuntimeMcpRegistryError => - new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_NOT_FOUND', message); - -const descriptorKey = (name: string, target: string): string => `${name}\u0000${target}`; - -const finiteJson = (value: unknown, seen = new WeakSet()): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw registryInvalid('Runtime MCP definitions must be finite JSON values.'); - return value; - } - if (Array.isArray(value)) { - if (seen.has(value)) throw registryInvalid('Runtime MCP definitions must not contain cycles.'); - seen.add(value); - const copied = Object.freeze(value.map((item) => finiteJson(item, seen))); - seen.delete(value); - return copied; - } - if (!isRecord(value)) throw registryInvalid('Runtime MCP definitions must be JSON values.'); - if (seen.has(value)) throw registryInvalid('Runtime MCP definitions must not contain cycles.'); - const prototype = Object.getPrototypeOf(value); - if (prototype !== null && prototype !== Object.prototype) { - throw registryInvalid('Runtime MCP definitions must be plain JSON objects.'); - } - seen.add(value); - const copied: Record = {}; - for (const key of Object.keys(value).sort()) { - const property = Object.getOwnPropertyDescriptor(value, key); - if (property === undefined || property.get !== undefined || property.set !== undefined) { - throw registryInvalid('Runtime MCP definitions must not contain accessors.'); - } - copied[key] = finiteJson(property.value, seen); - } - seen.delete(value); - return Object.freeze(copied); -}; - -const jsonObject = (value: unknown, label: string): JsonObject => { - const copied = finiteJson(value); - if (!isRecord(copied)) throw registryInvalid(`${label} must be a JSON object.`); - return copied; -}; - -const nonempty = (value: unknown, label: string): string => { - if (typeof value !== 'string' || value.length === 0) throw registryInvalid(`${label} must be a nonempty string.`); - return value; -}; - -const freezeDescriptor = (input: DevRuntimeMcpServerDescriptor): DevRuntimeMcpServerDescriptor => Object.freeze({ - definitionDigest: nonempty(input.definitionDigest, 'Runtime MCP descriptor definition digest'), - name: nonempty(input.name, 'Runtime MCP descriptor name'), - resources: Object.freeze(input.resources.map((resource) => jsonObject(resource, 'Runtime MCP resource'))), - serverDigest: nonempty(input.serverDigest, 'Runtime MCP descriptor server digest'), - target: nonempty(input.target, 'Runtime MCP descriptor target'), - tools: Object.freeze(input.tools.map((tool) => jsonObject(tool, 'Runtime MCP tool'))), - transportDigest: nonempty(input.transportDigest, 'Runtime MCP descriptor transport digest'), -}); - -const freezeInput = (input: DevRuntimeMcpRegistryReconcileInput): DevRuntimeMcpRegistryReconcileInput => { - const definitionDigest = nonempty(input.definitionDigest, 'Runtime MCP definition digest'); - const runtimeGenerationId = nonempty(input.runtimeGenerationId, 'Runtime MCP generation id'); - const transportDigest = nonempty(input.transportDigest, 'Runtime MCP transport digest'); - const keys = new Set(); - const descriptors = input.servers.map((server) => { - const descriptor = freezeDescriptor(server); - if (descriptor.definitionDigest !== definitionDigest || descriptor.transportDigest !== transportDigest) { - throw registryInvalid('Runtime MCP server descriptors must match the registry definition and transport digests.'); - } - const key = descriptorKey(descriptor.name, descriptor.target); - if (keys.has(key)) throw registryInvalid(`Runtime MCP registry has duplicate server ${JSON.stringify(descriptor.name)} for target ${JSON.stringify(descriptor.target)}.`); - keys.add(key); - return descriptor; - }); - return Object.freeze({ - definitionDigest, - runtimeGenerationId, - servers: Object.freeze(descriptors), - transportDigest, - }); -}; - -const descriptorMap = (input: DevRuntimeMcpRegistryReconcileInput): ReadonlyMap => - new Map(input.servers.map((descriptor) => [descriptorKey(descriptor.name, descriptor.target), descriptor])); - -const staticValue = (descriptor: DevRuntimeMcpServerDescriptor, request: DevRuntimeMcpOperationRequest): JsonValue | undefined => { - if (request.kind === 'list-tools') return descriptor.tools; - if (request.kind === 'list-resources') return descriptor.resources; - return undefined; -}; - -const declaredTool = (descriptor: DevRuntimeMcpServerDescriptor, name: string): boolean => - descriptor.tools.some((tool) => tool.name === name); - -const declaredResource = (descriptor: DevRuntimeMcpServerDescriptor, uri: string): boolean => - descriptor.resources.some((resource) => resource.uri === uri); - -const timeout = (durationMs: number): Promise => new Promise((resolve) => { - setTimeout(resolve, durationMs); -}); - -export class RuntimeMcpRegistry implements DevRuntimeProviderMcpRegistry { - readonly #activation = new WeakMap(); - readonly #preparedActivations = new Set(); - readonly #closeAbort = new AbortController(); - readonly #history: DevRuntimeMcpRegistryReconcileResult[] = []; - readonly #opening = new Map>(); - readonly #publicMutations = new Set>(); - readonly #options: Required> & RuntimeMcpRegistryOptions; - readonly #sessions = new Map(); - readonly #subscriptions = new Set(); - readonly #pendingPublications = new Map(); - readonly #orphanedConnections = new Map(); - readonly #retirements = new Set(); - #closePromise: Promise | undefined; - #closed = false; - #preparingActivation: Readonly<{ readonly abort: AbortController; readonly settled: Promise }> | undefined; - #mutation: MutationLane = 'none'; - #nextPublicationSequence = 1; - #publishing = false; - #reservationRevision = 0; - #sequence = 0; - #state: RegistryState | undefined; - - constructor(options: RuntimeMcpRegistryOptions) { - if (typeof options.artifactEpochId !== 'function' || typeof options.connector.connect !== 'function' || - typeof options.emit !== 'function' || typeof options.executor !== 'function') { - throw new TypeError('Runtime MCP registry requires connector, executor, artifact, and event functions.'); - } - nonempty(options.providerSessionId, 'Runtime MCP provider session id'); - nonempty(options.stateStoreId, 'Runtime MCP state store id'); - this.#options = Object.freeze({ - ...options, - createOperationId: options.createOperationId ?? (() => crypto.randomUUID()), - createSessionId: options.createSessionId ?? (() => crypto.randomUUID()), - }); - if (options.initialRegistry !== undefined) { - const input = freezeInput(options.initialRegistry); - this.#state = Object.freeze({ descriptors: descriptorMap(input), input, registryRevision: 1 }); - } - } - - snapshot(): DevRuntimeMcpRegistrySnapshot | undefined { - const state = this.#state; - if (state === undefined) return undefined; - return Object.freeze({ - definitionDigest: state.input.definitionDigest, - providerSessionId: this.#options.providerSessionId, - registryRevision: state.registryRevision, - runtimeGenerationId: state.input.runtimeGenerationId, - servers: state.input.servers, - transportDigest: state.input.transportDigest, - }); - } - - async open(request: DevRuntimeMcpSessionRequest): Promise { - const release = this.#reserve('public'); - const controller = new AbortController(); - let settled!: () => void; - const settling = new Promise((resolve) => { settled = resolve; }); - this.#opening.set(controller, settling); - try { - const state = this.#requireState(); - if (request.expectedRegistryRevision !== undefined && request.expectedRegistryRevision !== state.registryRevision) { - throw registryConflict('Expected runtime MCP registry revision does not match the current registry revision.'); - } - const descriptor = state.descriptors.get(descriptorKey(request.serverName, request.target)); - if (descriptor === undefined) throw registryNotFound('Unknown runtime MCP server or target.'); - const id = this.#options.createSessionId(); - nonempty(id, 'Runtime MCP session id'); - if (this.#sessions.has(id)) throw registryConflict(`Runtime MCP session ${JSON.stringify(id)} already exists.`); - const connected = await this.#connectAndRelist(descriptor, id, controller.signal); - const record: SessionRecord = { - abort: new AbortController(), - binding: this.#binding(id, 1, state, descriptor), - closed: false, - connection: connected.connection, - connectionState: connected.state, - descriptor, - id, - operations: new Set(), - state: 'ready', - watchers: new Set(), - }; - this.#sessions.set(id, record); - return this.#ownedSession(record); - } finally { - this.#opening.delete(controller); - settled(); - release(); - } - } - - session(sessionId: string): DevRuntimeMcpSessionView | undefined { - const record = this.#sessions.get(sessionId); - return record === undefined ? undefined : this.#view(record); - } - - async reconcile(input: DevRuntimeMcpRegistryReconcileInput): Promise { - const frozen = freezeInput(input); - const release = this.#reserve('public'); - const settle = this.#trackPublicMutation(); - try { - const current = this.#state; - if (current === undefined) { - this.#state = Object.freeze({ descriptors: descriptorMap(frozen), input: frozen, registryRevision: 1 }); - const result = this.#result('implementation-updated', [], [], this.#state); - this.#publishResult(result); - return result; - } - if (!requiresRestart(current.input, frozen)) { - this.#installImplementation(current, frozen); - const result = this.#result('implementation-updated', [], [], this.#requireState()); - this.#publishResult(result); - return result; - } - - const invalidatedBindings = this.#startVisibleRestart(current, frozen, this.#sessions.values()); - const restartedSessionIds: string[] = []; - let failure: unknown; - for (const record of this.#sessions.values()) { - try { - await this.#replaceVisibleConnection(record); - restartedSessionIds.push(record.id); - this.#emit('runtime.mcp.ready', record); - } catch (error) { - failure ??= error; - record.state = 'failed'; - record.connection = undefined; - this.#emit('runtime.mcp.failed', record); - } - } - const result = this.#result( - failure === undefined ? 'sessions-restarted' : 'restart-failed', - invalidatedBindings, - restartedSessionIds, - this.#requireState(), - ); - this.#publishResult(result); - return result; - } finally { - release(); - settle(); - } - } - - async restart(request: DevRuntimeMcpSessionControlRequest): Promise { - const release = this.#reserve('public'); - const settle = this.#trackPublicMutation(); - try { - const current = this.#requireState(); - const record = this.#sessionForControl(request); - const previous = bindingCopy(record.binding); - record.binding = this.#binding(record.id, record.binding.sessionRevision + 1, current, record.descriptor); - record.state = 'restarting'; - this.#emit('runtime.mcp.restarting', record); - try { - await this.#replaceVisibleConnection(record); - this.#emit('runtime.mcp.ready', record); - const result = this.#result('sessions-restarted', [previous], [record.id], current); - this.#publishResult(result); - return result; - } catch { - record.state = 'failed'; - record.connection = undefined; - this.#emit('runtime.mcp.failed', record); - const result = this.#result('restart-failed', [previous], [], current); - this.#publishResult(result); - return result; - } - } finally { - release(); - settle(); - } - } - - async closeSession(request: DevRuntimeMcpSessionControlRequest): Promise { - const release = this.#reserve('public'); - const settle = this.#trackPublicMutation(); - try { - const record = this.#sessionForControl(request); - await this.#closeRecord(record, new Error('Runtime MCP session was closed.')); - this.#sessions.delete(record.id); - } finally { - release(); - settle(); - } - } - - subscribe( - options: Readonly<{ readonly afterSequence?: number }>, - listener: DevRuntimeMcpRegistryListener, - ): DevRuntimeMcpRegistrySubscription { - if (typeof listener !== 'function') throw new TypeError('A runtime MCP registry listener is required.'); - const afterSequence = options.afterSequence ?? 0; - if (!Number.isSafeInteger(afterSequence) || afterSequence < 0 || afterSequence > this.#sequence) { - throw registryConflict('afterSequence must be a current runtime MCP registry cursor.'); - } - const subscription: Subscription = { - closed: false, - lastDeliveredSequence: afterSequence, - listener, - pending: [], - replaying: true, - }; - this.#subscriptions.add(subscription); - const earliest = this.#history[0]?.sequence; - if (earliest !== undefined && afterSequence < earliest - 1) { - this.#deliver(subscription, Object.freeze({ - earliestAvailableSequence: earliest, - latestDroppedSequence: earliest - 1, - requestedAfterSequence: afterSequence, - type: 'replay.gap' as const, - })); - } - const boundary = this.#sequence; - for (const result of this.#history) { - if (result.sequence > afterSequence && result.sequence <= boundary) this.#deliver(subscription, result); - } - while (!subscription.closed && subscription.pending.length > 0) { - const message = subscription.pending.shift(); - if (message !== undefined) this.#deliver(subscription, message); - } - subscription.replaying = false; - return Object.freeze({ unsubscribe: () => this.#unsubscribe(subscription) }); - } - - async prepareActivationReconcile(input: DevRuntimeMcpRegistryReconcileInput): Promise { - const frozen = freezeInput(input); - const release = this.#reserve('activation'); - const current = this.#state; - const reservationRevision = ++this.#reservationRevision; - const prepared = Object.freeze({ input: frozen, reservationRevision }); - const stagedAbort = new AbortController(); - let markPreparedSettled!: () => void; - const preparedSettled = new Promise((resolve) => { markPreparedSettled = resolve; }); - const preparation = Object.freeze({ abort: stagedAbort, settled: preparedSettled }); - this.#preparingActivation = preparation; - const replacements = new Map(); - try { - const restart = current !== undefined && requiresRestart(current.input, frozen); - const nextDescriptors = descriptorMap(frozen); - const invalidatedBindings: DevRuntimeMcpInvalidatedBinding[] = []; - if (restart) { - for (const record of this.#sessions.values()) { - const next = nextDescriptors.get(descriptorKey(record.binding.serverName, record.binding.target)); - if (next === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - invalidatedBindings.push(bindingCopy(record.binding)); - replacements.set(record.id, await this.#connectAndRelist(next, record.id, stagedAbort.signal)); - } - } - this.#activation.set(prepared, Object.freeze({ - current, - input: frozen, - invalidatedBindings: Object.freeze(invalidatedBindings), - nextDescriptors, - replacements, - requiresRestart: restart, - reservationRevision, - stagedAbort, - })); - this.#preparedActivations.add(prepared); - if (this.#preparingActivation === preparation) this.#preparingActivation = undefined; - markPreparedSettled(); - return prepared; - } catch (error) { - stagedAbort.abort(error); - const cleanupFailures = await this.#closeConnectionsAndRetain( - [...replacements.values()].map(({ connection }) => connection), - ); - release(); - if (this.#preparingActivation === preparation) this.#preparingActivation = undefined; - markPreparedSettled(); - if (cleanupFailures.length > 0) { - throw new AggregateError( - [error, ...cleanupFailures], - 'Runtime MCP activation staging and cleanup both failed.', - { cause: error }, - ); - } - throw error; - } - } - - commitActivationReconcile(prepared: RuntimeMcpPreparedActivationReconcile): RuntimeMcpCommittedActivationReconcile { - if (this.#closed) throw registryClosed(); - const record = this.#activation.get(prepared); - if (record === undefined || record.reservationRevision !== prepared.reservationRevision || this.#mutation !== 'activation') { - throw registryConflict('Runtime MCP activation reconciliation is no longer reserved.'); - } - const previousState = record.current; - const nextState = Object.freeze({ - descriptors: record.nextDescriptors, - input: record.input, - registryRevision: previousState === undefined ? 1 : previousState.registryRevision + (record.requiresRestart ? 1 : 0), - }); - const transitions: Array> = []; - if (record.requiresRestart) { - for (const session of this.#sessions.values()) { - const descriptor = record.nextDescriptors.get(descriptorKey(session.binding.serverName, session.binding.target)); - const replacement = record.replacements.get(session.id); - if (descriptor === undefined || replacement === undefined) { - throw registryConflict('Runtime MCP activation reconciliation is incomplete.'); - } - transitions.push(Object.freeze({ descriptor, replacement, session })); - } - } else { - for (const session of this.#sessions.values()) { - const descriptor = record.nextDescriptors.get(descriptorKey(session.binding.serverName, session.binding.target)); - if (descriptor === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - transitions.push(Object.freeze({ descriptor, replacement: undefined, session })); - } - } - - this.#activation.delete(prepared); - this.#preparedActivations.delete(prepared); - const retired: RetiredConnectionBatch['entries'][number][] = []; - if (record.requiresRestart) { - for (const { descriptor, replacement, session } of transitions) { - retired.push(Object.freeze({ - abort: session.abort, - connection: session.connection, - operations: Object.freeze([...session.operations]), - })); - session.abort = new AbortController(); - session.connection = replacement!.connection; - session.connectionState = replacement!.state; - session.descriptor = descriptor; - session.binding = this.#binding(session.id, session.binding.sessionRevision + 1, nextState, descriptor); - session.state = 'restarting'; - } - } else { - for (const { descriptor, session } of transitions) { - session.descriptor = descriptor; - session.binding = this.#binding(session.id, session.binding.sessionRevision, nextState, descriptor); - } - } - this.#state = nextState; - if (record.requiresRestart) { - // Keep the replacement fenced until its revocation publication is visible. - for (const session of this.#sessions.values()) this.#emit('runtime.mcp.restarting', session); - } - const result = this.#result( - record.requiresRestart ? 'sessions-restarted' : 'implementation-updated', - record.invalidatedBindings, - record.requiresRestart ? [...this.#sessions.keys()] : [], - nextState, - ); - this.#mutation = 'none'; - const retirement: RetiredConnectionBatch | undefined = retired.length === 0 - ? undefined - : { entries: Object.freeze(retired), finalization: undefined }; - if (retirement !== undefined) this.#retirements.add(retirement); - let published = false; - return Object.freeze({ - finalize: () => { - return retirement === undefined ? Promise.resolve() : this.#finalizeRetirement(retirement); - }, - publish: () => { - if (published || this.#closed) return; - published = true; - this.#publishResult(result); - if (!record.requiresRestart || this.#closed) return; - for (const { session } of transitions) { - if (this.#closed) return; - if (this.#sessions.get(session.id) !== session || session.closed || session.state !== 'restarting') continue; - session.state = 'ready'; - this.#emit('runtime.mcp.ready', session); - } - }, - result, - }); - } - - async abortActivationReconcile(prepared: RuntimeMcpPreparedActivationReconcile): Promise { - const record = this.#activation.get(prepared); - if (record === undefined) return; - this.#activation.delete(prepared); - this.#preparedActivations.delete(prepared); - record.stagedAbort.abort(new Error('Runtime MCP activation reconciliation was aborted.')); - const entries = [...record.replacements.entries()]; - const results = await Promise.allSettled(entries.map(async ([id, replacement]) => { - await this.#closeOrRetain(replacement.connection); - return id; - })); - this.#mutation = 'none'; - const failures = results.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `staged:${entries[index]![0]}` })] - : []); - if (failures.length > 0) throw new RuntimeMcpRegistryCloseError(failures); - } - - close(): Promise { - if (this.#closePromise !== undefined) return this.#closePromise; - this.#closed = true; - this.#closeAbort.abort(new Error('Runtime MCP registry was closed.')); - this.#preparingActivation?.abort.abort(new Error('Runtime MCP registry was closed.')); - for (const subscription of this.#subscriptions) subscription.closed = true; - this.#subscriptions.clear(); - for (const controller of this.#opening.keys()) controller.abort(new Error('Runtime MCP registry was closed.')); - this.#closePromise = this.#closeAll(); - return this.#closePromise; - } - - async #closeAll(): Promise { - await Promise.all([...this.#publicMutations]); - const preparing = this.#preparingActivation?.settled; - if (preparing !== undefined) await preparing; - const prepared = [...this.#preparedActivations]; - const activationResults = await Promise.allSettled(prepared.map((activation) => this.abortActivationReconcile(activation))); - await Promise.all([...this.#opening.values()]); - const retirements = [...this.#retirements]; - const retirementResults = await Promise.allSettled(retirements.map((retirement) => this.#finalizeRetirement(retirement))); - const records = [...this.#sessions.values()]; - this.#sessions.clear(); - const results = await Promise.allSettled(records.map((record) => this.#closeRecord(record, new Error('Runtime MCP registry was closed.')))); - const orphans = [...this.#orphanedConnections.values()]; - const orphanResults = await Promise.allSettled(orphans.map((orphan) => this.#finalizeOrphan(orphan))); - const failures = [ - ...activationResults.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `activation:${prepared[index]!.reservationRevision}` })] - : []), - ...retirementResults.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `retirement:${index}` })] - : []), - ...results.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `session:${records[index]!.id}` })] - : []), - ...orphanResults.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `orphan:${index}` })] - : []), - ]; - if (failures.length > 0) throw new RuntimeMcpRegistryCloseError(failures); - } - - #ownedSession(record: SessionRecord): DevRuntimeMcpSession { - return Object.freeze({ - close: async () => { - if (record.closed) return; - await this.#closeOwned(record); - }, - ...this.#view(record), - }); - } - - #view(record: SessionRecord): DevRuntimeMcpSessionView { - return Object.freeze({ - execute: async (request: DevRuntimeMcpOperationRequest, options: DevRuntimeMcpSessionExecuteOptions | undefined) => this.#execute(record, request, options?.signal), - snapshot: () => this.#sessionSnapshot(record), - watchClosed: (listener: (reason?: unknown) => Promise | void) => this.#watchClosed(record, listener), - }); - } - - async #closeOwned(record: SessionRecord): Promise { - const release = this.#reserve('public'); - const settle = this.#trackPublicMutation(); - try { - if (record.closed) return; - await this.#closeRecord(record, new Error('Runtime MCP session was closed.')); - this.#sessions.delete(record.id); - } finally { - release(); - settle(); - } - } - - #sessionSnapshot(record: SessionRecord): DevRuntimeMcpSessionSnapshot { - return Object.freeze({ binding: record.binding, connection: record.connectionState, state: record.state }); - } - - #watchClosed(record: SessionRecord, listener: (reason?: unknown) => Promise | void): DevRuntimeMcpSessionCloseObservation { - if (typeof listener !== 'function') throw new TypeError('A runtime MCP session close listener is required.'); - if (record.closed) return Object.freeze({ closed: true, unsubscribe: () => undefined }); - record.watchers.add(listener); - if (record.closed) { - record.watchers.delete(listener); - return Object.freeze({ closed: true, unsubscribe: () => undefined }); - } - let subscribed = true; - return Object.freeze({ - closed: false, - unsubscribe: () => { - if (!subscribed) return; - subscribed = false; - record.watchers.delete(listener); - }, - }); - } - - async #execute(record: SessionRecord, request: DevRuntimeMcpOperationRequest, operationSignal?: AbortSignal): Promise { - this.#assertSessionReady(record, request.expectedSessionRevision); - const binding = record.binding; - const descriptor = record.descriptor; - const sessionAbort = record.abort; - const state = this.#requireState(); - const generationId = state.input.runtimeGenerationId; - const lease = await this.#options.generationStore.lease(generationId); - const cancellation = sessionAbort.signal.aborted - ? sessionAbort.signal - : operationSignal?.aborted - ? operationSignal - : undefined; - if (cancellation !== undefined) { - await lease.release(); - throw cancellation.reason ?? registryConflict('Runtime MCP session is restarting.'); - } - const controller = new AbortController(); - const signal = AbortSignal.any([ - this.#closeAbort.signal, - sessionAbort.signal, - controller.signal, - ...(operationSignal === undefined ? [] : [operationSignal]), - ]); - let resolveDone!: () => void; - const done = new Promise((resolve) => { resolveDone = resolve; }); - const operation: OperationRecord = Object.freeze({ controller, done, sessionAbort }); - record.operations.add(operation); - try { - const value = staticValue(descriptor, request); - const execution = value === undefined - ? await this.#executeDynamic(descriptor, binding.sessionId, request, lease.generation, signal) - : Object.freeze({ stateVersion: 0, value }); - const vector: RuntimeVector = Object.freeze({ - ...(this.#options.artifactEpochId() === undefined ? {} : { artifactEpochId: this.#options.artifactEpochId() }), - providerSessionId: this.#options.providerSessionId, - runtimeGenerationId: lease.generation.id, - sourceRevision: lease.generation.sourceRevision, - stateStoreId: this.#options.stateStoreId, - stateVersion: execution.stateVersion, - }); - return Object.freeze({ - operationId: this.#options.createOperationId(), - sessionId: binding.sessionId, - sessionRevision: binding.sessionRevision, - value: finiteJson(execution.value), - vector, - }); - } finally { - record.operations.delete(operation); - resolveDone(); - await lease.release(); - } - } - - async #executeDynamic( - descriptor: DevRuntimeMcpServerDescriptor, - sessionId: string, - request: DevRuntimeMcpOperationRequest, - generation: RuntimeGeneration, - signal: AbortSignal, - ): Promise { - if (request.kind === 'call-tool' && !declaredTool(descriptor, request.name)) { - throw registryInvalid(`Runtime MCP tool ${JSON.stringify(request.name)} is not declared.`); - } - if (request.kind === 'read-resource' && !declaredResource(descriptor, request.uri)) { - throw registryInvalid(`Runtime MCP resource ${JSON.stringify(request.uri)} is not declared.`); - } - const value = await this.#options.executor(Object.freeze({ - descriptor, - generation, - request, - sessionId, - signal, - })); - if (!Number.isSafeInteger(value.stateVersion) || value.stateVersion < 0) { - throw registryInvalid('Runtime MCP executor returned an invalid state version.'); - } - return Object.freeze({ stateVersion: value.stateVersion, value: finiteJson(value.value) }); - } - - #startVisibleRestart( - current: RegistryState, - input: DevRuntimeMcpRegistryReconcileInput, - sessions: Iterable, - ): readonly DevRuntimeMcpInvalidatedBinding[] { - const next = Object.freeze({ descriptors: descriptorMap(input), input, registryRevision: current.registryRevision + 1 }); - const invalidated: DevRuntimeMcpInvalidatedBinding[] = []; - const affected = [...sessions]; - for (const record of affected) { - const descriptor = next.descriptors.get(descriptorKey(record.binding.serverName, record.binding.target)); - if (descriptor === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - } - for (const record of affected) { - const descriptor = next.descriptors.get(descriptorKey(record.binding.serverName, record.binding.target)); - if (descriptor === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - invalidated.push(bindingCopy(record.binding)); - record.descriptor = descriptor; - record.binding = this.#binding(record.id, record.binding.sessionRevision + 1, next, descriptor); - record.state = 'restarting'; - record.abort.abort(new Error('Runtime MCP session is restarting.')); - this.#emit('runtime.mcp.restarting', record); - } - this.#state = next; - return Object.freeze(invalidated); - } - - #installImplementation(current: RegistryState, input: DevRuntimeMcpRegistryReconcileInput): void { - const next = Object.freeze({ descriptors: descriptorMap(input), input, registryRevision: current.registryRevision }); - for (const record of this.#sessions.values()) { - const descriptor = next.descriptors.get(descriptorKey(record.binding.serverName, record.binding.target)); - if (descriptor === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - } - for (const record of this.#sessions.values()) { - const descriptor = next.descriptors.get(descriptorKey(record.binding.serverName, record.binding.target)); - if (descriptor === undefined) throw registryInvalid('A registered runtime MCP session no longer has a static descriptor.'); - record.descriptor = descriptor; - record.binding = this.#binding(record.id, record.binding.sessionRevision, next, descriptor); - } - this.#state = next; - } - - async #replaceVisibleConnection(record: SessionRecord): Promise { - const oldConnection = record.connection; - const oldOperations = [...record.operations]; - await this.#cancelAndDrain(oldOperations, record.abort); - if (oldConnection !== undefined) await this.#closeOrRetain(oldConnection); - const nextAbort = new AbortController(); - record.abort = nextAbort; - const connected = await this.#connectAndRelist( - record.descriptor, - record.id, - AbortSignal.any([this.#closeAbort.signal, nextAbort.signal]), - ); - record.connection = connected.connection; - record.connectionState = connected.state; - record.state = 'ready'; - } - - #finalizeRetirement(retirement: RetiredConnectionBatch): Promise { - retirement.finalization ??= this.#finalizeRetirementInternal(retirement).then( - () => { this.#retirements.delete(retirement); }, - (error: unknown) => { - retirement.finalization = undefined; - throw error; - }, - ); - return retirement.finalization; - } - - async #finalizeRetirementInternal(retirement: RetiredConnectionBatch): Promise { - const results = await Promise.allSettled(retirement.entries.flatMap((entry) => [ - this.#cancelAndDrain(entry.operations, entry.abort), - ...(entry.connection === undefined ? [] : [this.#closeOrRetain(entry.connection)]), - ])); - const failures = results.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `retired:${index}` })] - : []); - if (failures.length > 0) throw new RuntimeMcpRegistryCloseError(failures); - } - - async #closeRecord(record: SessionRecord, reason: unknown): Promise { - if (record.closed) return; - record.closed = true; - record.state = 'closed'; - record.abort.abort(reason); - this.#notifyClosed(record, reason); - const operations = [...record.operations]; - const connection = record.connection; - record.connection = undefined; - const results = await Promise.allSettled([ - this.#cancelAndDrain(operations, record.abort), - ...(connection === undefined ? [] : [this.#closeOrRetain(connection)]), - ]); - const failures = results.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: `${record.id}:${index}` })] - : []); - if (failures.length > 0) throw new RuntimeMcpRegistryCloseError(failures); - } - - #retainOrphan(connection: RuntimeMcpConnection): void { - if (this.#orphanedConnections.has(connection)) return; - this.#orphanedConnections.set(connection, { connection, finalization: undefined }); - } - - async #closeOrRetain(connection: RuntimeMcpConnection): Promise { - try { - await connection.close(); - } catch (error) { - this.#retainOrphan(connection); - throw error; - } - } - - #finalizeOrphan(orphan: OrphanedConnection): Promise { - orphan.finalization ??= this.#closeOrRetain(orphan.connection).then( - () => { this.#orphanedConnections.delete(orphan.connection); }, - (error: unknown) => { - orphan.finalization = undefined; - throw error; - }, - ); - return orphan.finalization; - } - - async #closeConnectionsAndRetain(connections: readonly RuntimeMcpConnection[]): Promise { - const results = await Promise.allSettled(connections.map((connection) => this.#closeOrRetain(connection))); - return Object.freeze(results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [])); - } - - async #cancelAndDrain(operations: readonly OperationRecord[], abort: AbortController): Promise { - abort.abort(new Error('Runtime MCP session is restarting.')); - for (const operation of operations) operation.controller.abort(new Error('Runtime MCP operation was cancelled.')); - if (operations.length === 0) return; - await Promise.race([ - Promise.allSettled(operations.map((operation) => operation.done)).then(() => undefined), - timeout(restartDrainTimeoutMs), - ]); - } - - #notifyClosed(record: SessionRecord, reason: unknown): void { - const listeners = [...record.watchers]; - record.watchers.clear(); - for (const listener of listeners) { - try { - void Promise.resolve(listener(reason)).catch(() => undefined); - } catch { - // Close observers cannot stop owned resource cleanup. - } - } - } - - #binding(id: string, revision: number, state: RegistryState, descriptor: DevRuntimeMcpServerDescriptor): DevRuntimeMcpSessionBinding { - return Object.freeze({ - definitionDigest: state.input.definitionDigest, - providerSessionId: this.#options.providerSessionId, - registryRevision: state.registryRevision, - serverDigest: descriptor.serverDigest, - serverName: descriptor.name, - sessionId: id, - sessionRevision: revision, - stateStoreId: this.#options.stateStoreId, - target: descriptor.target, - transportDigest: state.input.transportDigest, - }); - } - - #result( - action: DevRuntimeMcpRegistryReconcileResult['action'], - invalidatedBindings: readonly DevRuntimeMcpInvalidatedBinding[], - restartedSessionIds: readonly string[], - state: RegistryState, - ): DevRuntimeMcpRegistryReconcileResult { - return Object.freeze({ - action, - invalidatedBindings: Object.freeze([...invalidatedBindings]), - registryRevision: state.registryRevision, - restartedSessionIds: Object.freeze([...restartedSessionIds]), - runtimeGenerationId: state.input.runtimeGenerationId, - sequence: ++this.#sequence, - }); - } - - #publishResult(result: DevRuntimeMcpRegistryReconcileResult): void { - this.#pendingPublications.set(result.sequence, result); - if (this.#publishing) return; - this.#publishing = true; - try { - while (true) { - const next = this.#pendingPublications.get(this.#nextPublicationSequence); - if (next === undefined) return; - this.#pendingPublications.delete(next.sequence); - this.#nextPublicationSequence += 1; - this.#history.push(next); - if (this.#history.length > maxRetainedResults) this.#history.splice(0, this.#history.length - maxRetainedResults); - const subscribers = [...this.#subscriptions]; - for (const subscription of subscribers) { - if (subscription.closed) continue; - if (subscription.replaying) subscription.pending.push(next); - else this.#deliver(subscription, next); - } - } - } finally { - this.#publishing = false; - } - } - - #deliver(subscription: Subscription, message: RuntimeMcpRegistryMessage): void { - if (subscription.closed) return; - if ('sequence' in message) { - if (message.sequence <= subscription.lastDeliveredSequence) return; - subscription.lastDeliveredSequence = message.sequence; - } - try { - subscription.listener(message); - } catch { - this.#unsubscribe(subscription); - } - } - - #unsubscribe(subscription: Subscription): void { - if (subscription.closed) return; - subscription.closed = true; - subscription.pending.length = 0; - this.#subscriptions.delete(subscription); - } - - #emit(type: DevRuntimeEventInput['type'], record: SessionRecord): void { - this.#options.emit(Object.freeze({ - mcpRegistryRevision: record.binding.registryRevision, - mcpSessionId: record.id, - mcpSessionRevision: record.binding.sessionRevision, - runtimeGenerationId: this.#state?.input.runtimeGenerationId, - type, - })); - } - - #sessionForControl(request: DevRuntimeMcpSessionControlRequest): SessionRecord { - const record = this.#sessions.get(request.sessionId); - if (record === undefined || record.closed) throw registryNotFound('Runtime MCP session was not found.'); - if (request.expectedSessionRevision !== record.binding.sessionRevision) { - throw registryConflict('Expected runtime MCP session revision does not match the current session revision.'); - } - return record; - } - - #assertSessionReady(record: SessionRecord, expectedRevision: number): void { - if (this.#closed || record.closed) throw registryClosed(); - if (expectedRevision !== record.binding.sessionRevision) { - throw registryConflict('Expected runtime MCP session revision does not match the current session revision.'); - } - if (record.state === 'restarting') throw registryConflict('Runtime MCP session is restarting.'); - if (record.state !== 'ready' || record.connection === undefined) throw registryConflict('Runtime MCP session is not ready.'); - } - - #requireState(): RegistryState { - if (this.#closed) throw registryClosed(); - if (this.#state === undefined) throw registryConflict('Runtime MCP registry has not been reconciled.'); - return this.#state; - } - - #reserve(lane: Exclude): () => void { - if (this.#closed) throw registryClosed(); - if (this.#mutation !== 'none') throw registryConflict('Runtime MCP registry mutation is reserved.'); - this.#mutation = lane; - let released = false; - return () => { - if (released) return; - released = true; - if (this.#mutation === lane) this.#mutation = 'none'; - }; - } - - #trackPublicMutation(): () => void { - let resolve!: () => void; - const settled = new Promise((resolvePromise) => { resolve = resolvePromise; }); - this.#publicMutations.add(settled); - let active = true; - return () => { - if (!active) return; - active = false; - this.#publicMutations.delete(settled); - resolve(); - }; - } - - async #connect(descriptor: DevRuntimeMcpServerDescriptor, sessionId: string, signal: AbortSignal): Promise { - if (signal.aborted) throw signal.reason ?? registryClosed(); - const connection = await this.#options.connector.connect(Object.freeze({ descriptor, sessionId, signal })); - if (signal.aborted) { - await this.#closeAfterFailedSetup(connection, signal.reason ?? registryClosed()); - } - return connection; - } - - async #connectAndRelist( - descriptor: DevRuntimeMcpServerDescriptor, - sessionId: string, - signal: AbortSignal, - ): Promise { - const connection = await this.#connect(descriptor, sessionId, signal); - try { - const state = finiteConnectionState(await connection.relist()); - if (signal.aborted) throw signal.reason ?? registryClosed(); - return Object.freeze({ connection, state }); - } catch (error) { - return this.#closeAfterFailedSetup(connection, error); - } - } - - async #closeAfterFailedSetup(connection: RuntimeMcpConnection, error: unknown): Promise { - const cleanupFailures = await this.#closeConnectionsAndRetain([connection]); - if (cleanupFailures.length > 0) { - throw new AggregateError([error, ...cleanupFailures], 'Runtime MCP connection setup and cleanup both failed.'); - } - throw error; - } -} - -const requiresRestart = ( - current: DevRuntimeMcpRegistryReconcileInput, - input: DevRuntimeMcpRegistryReconcileInput, -): boolean => current.definitionDigest !== input.definitionDigest || current.transportDigest !== input.transportDigest; - -const bindingCopy = (binding: DevRuntimeMcpSessionBinding): DevRuntimeMcpInvalidatedBinding => Object.freeze({ - sessionId: binding.sessionId, - sessionRevision: binding.sessionRevision, -}); - -const finiteConnectionState = (input: DevRuntimeMcpConnectionState): DevRuntimeMcpConnectionState => Object.freeze({ - capabilities: input.capabilities === undefined ? undefined : jsonObject(input.capabilities, 'Runtime MCP connection capabilities'), - protocolEra: input.protocolEra, - protocolVersion: input.protocolVersion, - server: input.server === undefined ? undefined : Object.freeze({ - name: nonempty(input.server.name, 'Runtime MCP connection server name'), - version: nonempty(input.server.version, 'Runtime MCP connection server version'), - }), -}); diff --git a/packages/agent-bundle/src/dev/runtime-protocol.ts b/packages/agent-bundle/src/dev/runtime-protocol.ts index 43f9188f6..a819550e2 100644 --- a/packages/agent-bundle/src/dev/runtime-protocol.ts +++ b/packages/agent-bundle/src/dev/runtime-protocol.ts @@ -78,12 +78,6 @@ export interface DevRuntimeTraceSpan { export interface DevRuntimeInspectionEnvelope { readonly agentVisible?: JsonValue; - readonly app?: Readonly<{ - readonly mcpBinding: DevRuntimeMcpAppRunBinding; - readonly resourceUri: string; - /** Server-only client/HMR endpoint locator; it may differ from the invoked run surface. */ - readonly surfaceId: string; - }>; readonly flight?: Readonly<{ readonly bytes: number; readonly downloadPath?: string; @@ -172,120 +166,6 @@ export interface DevRuntimeAsset { readonly contentType: string; } -/** Server-only compiler endpoint. It is never returned by status/surfaces JSON. */ -export interface DevRuntimeMcpSessionRequest { - readonly expectedRegistryRevision?: number; - readonly serverName: string; - readonly target: string; -} - -export interface DevRuntimeMcpSessionControlRequest { - readonly expectedSessionRevision: number; - readonly sessionId: string; -} - -export interface DevRuntimeMcpSessionBinding { - readonly definitionDigest: string; - readonly providerSessionId: string; - readonly registryRevision: number; - readonly serverDigest: string; - readonly serverName: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly stateStoreId: string; - readonly target: string; - readonly transportDigest: string; -} - -export type DevRuntimeMcpAppRunBinding = Omit< - DevRuntimeMcpSessionBinding, - 'providerSessionId' | 'stateStoreId' ->; - -export interface DevRuntimeMcpServerDescriptor { - readonly definitionDigest: string; - readonly name: string; - readonly resources: readonly JsonObject[]; - readonly serverDigest: string; - readonly target: string; - readonly tools: readonly JsonObject[]; - readonly transportDigest: string; -} - -export interface DevRuntimeMcpRegistrySnapshot { - readonly definitionDigest: string; - readonly providerSessionId: string; - readonly registryRevision: number; - readonly runtimeGenerationId: string; - readonly servers: readonly DevRuntimeMcpServerDescriptor[]; - readonly transportDigest: string; -} - -export interface DevRuntimeMcpConnectionState { - readonly capabilities: JsonObject | undefined; - readonly protocolEra: 'legacy' | 'modern' | undefined; - readonly protocolVersion: string | undefined; - readonly server: Readonly<{ readonly name: string; readonly version: string }> | undefined; -} - -interface DevRuntimeMcpOperationBase { - readonly expectedSessionRevision: number; -} - -export type DevRuntimeMcpOperationRequest = DevRuntimeMcpOperationBase & ( - | Readonly<{ readonly kind: 'list-tools' }> - | Readonly<{ - readonly arguments: JsonObject; - readonly kind: 'call-tool'; - readonly name: string; - readonly requestId?: string; - }> - | Readonly<{ readonly kind: 'list-resources' }> - | Readonly<{ readonly kind: 'read-resource'; readonly uri: string }> -); - -export interface DevRuntimeMcpOperationResult { - readonly operationId: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly value: JsonValue; - readonly vector: RuntimeVector; -} - -export interface DevRuntimeMcpSessionSnapshot { - readonly binding: DevRuntimeMcpSessionBinding; - readonly connection: DevRuntimeMcpConnectionState; - readonly state: 'connecting' | 'ready' | 'restarting' | 'failed' | 'closed'; -} - -export interface DevRuntimeMcpRegistryReconcileInput { - readonly definitionDigest: string; - readonly runtimeGenerationId: string; - readonly servers: readonly DevRuntimeMcpServerDescriptor[]; - readonly transportDigest: string; -} - -export interface DevRuntimeMcpInvalidatedBinding { - readonly sessionId: string; - readonly sessionRevision: number; -} - -export interface DevRuntimeMcpRegistryReconcileResult { - readonly action: 'implementation-updated' | 'sessions-restarted' | 'restart-failed'; - readonly invalidatedBindings: readonly DevRuntimeMcpInvalidatedBinding[]; - readonly registryRevision: number; - readonly restartedSessionIds: readonly string[]; - readonly runtimeGenerationId: string; - readonly sequence: number; -} - -export interface DevRuntimeMcpRegistryReplayGap { - readonly earliestAvailableSequence: number; - readonly latestDroppedSequence: number; - readonly requestedAfterSequence: number; - readonly type: 'replay.gap'; -} - export interface DevRuntimeStatusResponse { readonly status: DevRuntimeStatus | null; } diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index 03dfc190e..6e3c23ffd 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -4,15 +4,6 @@ import type { DevRuntimeAssetRequest, DevRuntimeDescriptor, DevRuntimeInvocationRequest, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpOperationResult, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpRegistryReplayGap, - DevRuntimeMcpRegistrySnapshot, - DevRuntimeMcpSessionControlRequest, - DevRuntimeMcpSessionRequest, - DevRuntimeMcpSessionSnapshot, DevRuntimeReplayRequest, DevRuntimeRun, DevRuntimeStateIdentity, @@ -21,20 +12,6 @@ import type { DevRuntimeSurface, } from './runtime-protocol.ts'; -/** Trusted-process-only compiler endpoint; never serialize it into runtime JSON. */ -export interface DevRuntimeClientSurfaceEndpoint { - readonly entryPath: string; - readonly httpOrigin: string; - readonly httpPathPrefixes: readonly string[]; - /** - * Provider-owned Runtime App reload authority. The provider invokes every - * subscribed listener after a successful, changed App environment compile; - * the returned function detaches that listener. - */ - readonly subscribeReload: (listener: () => void) => () => void; - readonly surfaceId: string; -} - /** Trusted normalized input from ProjectService; never serialize to the browser. */ export interface DevRuntimePreparedMcpServer { readonly args?: readonly string[]; @@ -72,9 +49,6 @@ export interface DevRuntimePreparedProject { export interface DevRuntimeEventInput { readonly correlationId?: string; readonly details?: JsonObject; - readonly mcpRegistryRevision?: number; - readonly mcpSessionId?: string; - readonly mcpSessionRevision?: number; readonly runId?: string; readonly runtimeGenerationId?: string; readonly type: @@ -84,10 +58,7 @@ export interface DevRuntimeEventInput { | 'runtime.generation.failed' | 'runtime.run.started' | 'runtime.run.completed' - | 'runtime.run.failed' - | 'runtime.mcp.restarting' - | 'runtime.mcp.ready' - | 'runtime.mcp.failed'; + | 'runtime.run.failed'; } export interface DevRuntimeStartContext { @@ -101,54 +72,9 @@ export interface DevRuntimeStartContext { readonly storageRoot: string; } -export type DevRuntimeMcpRegistryMessage = - | DevRuntimeMcpRegistryReconcileResult - | DevRuntimeMcpRegistryReplayGap; - -export type DevRuntimeMcpRegistryListener = (message: DevRuntimeMcpRegistryMessage) => void; - -export interface DevRuntimeMcpRegistrySubscription { - unsubscribe(): void; -} - -export interface DevRuntimeMcpSessionCloseObservation { - readonly closed: boolean; - unsubscribe(): void; -} - -export interface DevRuntimeMcpSessionExecuteOptions { - readonly signal?: AbortSignal; -} - -export interface DevRuntimeMcpSessionView { - execute(request: DevRuntimeMcpOperationRequest, options?: DevRuntimeMcpSessionExecuteOptions): Promise; - snapshot(): DevRuntimeMcpSessionSnapshot; - watchClosed(listener: (reason?: unknown) => Promise | void): DevRuntimeMcpSessionCloseObservation; -} - -export interface DevRuntimeMcpSession extends DevRuntimeMcpSessionView { - close(): Promise; -} - -export interface DevRuntimeMcpRegistry { - closeSession(request: DevRuntimeMcpSessionControlRequest): Promise; - close(): Promise; - open(request: DevRuntimeMcpSessionRequest): Promise; - reconcile(input: DevRuntimeMcpRegistryReconcileInput): Promise; - restart(request: DevRuntimeMcpSessionControlRequest): Promise; - session(sessionId: string): DevRuntimeMcpSessionView | undefined; - snapshot(): DevRuntimeMcpRegistrySnapshot | undefined; - subscribe( - options: Readonly<{ readonly afterSequence?: number }>, - listener: DevRuntimeMcpRegistryListener, - ): DevRuntimeMcpRegistrySubscription; -} - export interface DevRuntimeSession { - readonly mcpRegistry: DevRuntimeMcpRegistry; /** Server-only controller identity; it does not depend on an active generation. */ readonly providerSessionId: string; - clientSurface(surfaceId: string): DevRuntimeClientSurfaceEndpoint | undefined; close(): Promise; invoke(request: DevRuntimeInvocationRequest): Promise; readAsset(request: DevRuntimeAssetRequest): Promise; diff --git a/packages/agent-bundle/src/dev/runtime-store-contracts.ts b/packages/agent-bundle/src/dev/runtime-store-contracts.ts index 52fa961dd..e41e26dd4 100644 --- a/packages/agent-bundle/src/dev/runtime-store-contracts.ts +++ b/packages/agent-bundle/src/dev/runtime-store-contracts.ts @@ -1,24 +1,12 @@ -import type { - DevRuntimeEventInput, - DevRuntimeMcpRegistry, -} from './runtime-provider.ts'; -import type { - DevRuntimeMcpConnectionState, - DevRuntimeMcpOperationRequest, - DevRuntimeMcpRegistryReconcileInput, - DevRuntimeMcpRegistryReconcileResult, - DevRuntimeMcpServerDescriptor, -} from './runtime-protocol.ts'; import type { JsonValue } from './types.ts'; /** - * The generation store and MCP registry contracts a `dev.runtime.provider` - * drives, spelled without their implementations (#485). The classes in - * `runtime-generation-store.ts` and `runtime-mcp-registry.ts` implement these - * interfaces and throw `YieldableFrameworkError`s, which reach `effect`; a - * public `.d.ts` graph must not (`docs/effect-conventions.md`, boundary - * modules), so `agent-bundle/api` exports this module and the factories in - * `runtime-store-factories.ts`, never the classes. + * The generation store contract a `dev.runtime.provider` drives, spelled + * without its implementation (#485). The class in `runtime-generation-store.ts` + * implements these interfaces and throws `YieldableFrameworkError`s, which + * reach `effect`; a public `.d.ts` graph must not (`docs/effect-conventions.md`, + * boundary modules), so `agent-bundle/api` exports this module and the factory + * in `runtime-store-factories.ts`, never the class. */ export interface RuntimeGenerationAsset { @@ -134,78 +122,3 @@ export interface DevRuntimeGenerationStore { options?: RuntimeGenerationPrepareOptions, ): Promise>; } - -export interface RuntimeMcpConnection { - readonly state: DevRuntimeMcpConnectionState; - close(): Promise; - relist(): Promise; -} - -export interface RuntimeMcpConnector { - connect(input: Readonly<{ - readonly descriptor: DevRuntimeMcpServerDescriptor; - readonly sessionId: string; - readonly signal: AbortSignal; - }>): Promise; -} - -export interface RuntimeMcpExecutionContext { - readonly descriptor: DevRuntimeMcpServerDescriptor; - readonly generation: RuntimeGeneration; - readonly request: DevRuntimeMcpOperationRequest; - readonly sessionId: string; - readonly signal: AbortSignal; -} - -export interface RuntimeMcpExecutionValue { - readonly stateVersion: number; - readonly value: JsonValue; -} - -export interface RuntimeMcpRegistryOptions { - readonly artifactEpochId: () => string | undefined; - readonly connector: RuntimeMcpConnector; - readonly createOperationId?: () => string; - readonly createSessionId?: () => string; - readonly emit: (event: DevRuntimeEventInput) => void; - readonly executor: (context: RuntimeMcpExecutionContext) => Promise; - readonly generationStore: DevRuntimeGenerationStore; - readonly initialRegistry?: DevRuntimeMcpRegistryReconcileInput; - readonly providerSessionId: string; - readonly stateStoreId: string; -} - -export interface RuntimeMcpPreparedActivationReconcile { - readonly input: DevRuntimeMcpRegistryReconcileInput; - readonly reservationRevision: number; -} - -export interface RuntimeMcpCommittedActivationReconcile { - readonly result: DevRuntimeMcpRegistryReconcileResult; - finalize(): Promise; - publish(): void; -} - -export interface RuntimeMcpRegistryCloseFailure { - readonly error: unknown; - readonly resource: string; -} - -/** The `code` of an error the MCP registry throws (`name: 'RuntimeMcpRegistryError'`). */ -export type RuntimeMcpRegistryErrorCode = - | 'RUNTIME_MCP_REGISTRY_CLOSED' - | 'RUNTIME_MCP_REGISTRY_CONFLICT' - | 'RUNTIME_MCP_REGISTRY_INVALID' - | 'RUNTIME_MCP_REGISTRY_NOT_FOUND'; - -/** - * The MCP registry as the provider that owns it sees it: the session-facing - * {@link DevRuntimeMcpRegistry} plus the activation reconcile the provider - * drives while it activates a generation. Created with - * `createRuntimeMcpRegistry` from `agent-bundle/api`. - */ -export interface DevRuntimeProviderMcpRegistry extends DevRuntimeMcpRegistry { - abortActivationReconcile(prepared: RuntimeMcpPreparedActivationReconcile): Promise; - commitActivationReconcile(prepared: RuntimeMcpPreparedActivationReconcile): RuntimeMcpCommittedActivationReconcile; - prepareActivationReconcile(input: DevRuntimeMcpRegistryReconcileInput): Promise; -} diff --git a/packages/agent-bundle/src/dev/runtime-store-factories.ts b/packages/agent-bundle/src/dev/runtime-store-factories.ts index 432ecb977..6a65b4fcd 100644 --- a/packages/agent-bundle/src/dev/runtime-store-factories.ts +++ b/packages/agent-bundle/src/dev/runtime-store-factories.ts @@ -1,23 +1,16 @@ import { RuntimeGenerationStore } from './runtime-generation-store.ts'; -import { RuntimeMcpRegistry } from './runtime-mcp-registry.ts'; import type { DevRuntimeGenerationStore, - DevRuntimeProviderMcpRegistry, RuntimeGenerationStoreOptions, - RuntimeMcpRegistryOptions, } from './runtime-store-contracts.ts'; /** - * The public constructors of the generation store and MCP registry a - * `dev.runtime.provider` session drives (#485). They return the effect-free - * contracts rather than the classes, so `agent-bundle/api`'s declaration - * graph never reaches the `YieldableFrameworkError` hierarchy behind them. - * Errors the store and registry throw are recognised by `name` and `code` - * (`RuntimeGenerationStoreErrorCode`, `RuntimeMcpRegistryErrorCode`). + * The public constructor of the generation store a `dev.runtime.provider` + * session drives (#485). It returns the effect-free contract rather than the + * class, so `agent-bundle/api`'s declaration graph never reaches the + * `YieldableFrameworkError` hierarchy behind it. Errors the store throws are + * recognised by `name` and `code` (`RuntimeGenerationStoreErrorCode`). */ export const createRuntimeGenerationStore = ( options: RuntimeGenerationStoreOptions, ): DevRuntimeGenerationStore => new RuntimeGenerationStore(options); - -export const createRuntimeMcpRegistry = (options: RuntimeMcpRegistryOptions): DevRuntimeProviderMcpRegistry => - new RuntimeMcpRegistry(options); diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index f5e65c597..266d09d41 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -306,9 +306,6 @@ export interface Invalidation { export interface RuntimeEvent { readonly correlationId?: string; readonly details?: JsonObject; - readonly mcpRegistryRevision?: number; - readonly mcpSessionId?: string; - readonly mcpSessionRevision?: number; readonly providerSessionId: string; readonly runId?: string; readonly runtimeGenerationId?: string; diff --git a/packages/agent-bundle/tests/dev-events.test.ts b/packages/agent-bundle/tests/dev-events.test.ts index e324436fd..712d42d84 100644 --- a/packages/agent-bundle/tests/dev-events.test.ts +++ b/packages/agent-bundle/tests/dev-events.test.ts @@ -86,7 +86,7 @@ it('assigns monotonic sequence IDs and freezes published event payloads', () => type: 'source.changed', }); const second = hub.publish({ - payload: { providerSessionId: 'provider-a', type: 'runtime.mcp.ready' }, + payload: { providerSessionId: 'provider-a', type: 'runtime.generation.compiling' }, type: 'runtime.event', }); @@ -126,7 +126,7 @@ it('queues live events published during replay until retained events are deliver received.push(event.sequence); if (event.sequence === 1) { hub.publish({ - payload: { providerSessionId: 'provider-a', type: 'runtime.mcp.ready' }, + payload: { providerSessionId: 'provider-a', type: 'runtime.generation.compiling' }, type: 'runtime.event', }); } diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 7b7e538bc..2435089cd 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -661,8 +661,6 @@ it('does not reconcile a valid preparation released after foreground close begin enteredPrepare, preparationReleased, reconciles: 0, - subscribes: 0, - unsubscribes: 0, }; const runtimeGlobal = globalThis as typeof globalThis & Record; let server: Awaited> | undefined; @@ -703,23 +701,11 @@ it('does not reconcile a valid preparation released after foreground close begin writeFile(join(project.root, 'src', 'dev', 'provider.ts'), [ `const state = globalThis[${JSON.stringify(stateKey)}];`, "if (state === undefined) throw new Error('Missing prepared Runtime Apps close state.');", - 'const registry = {', - ' close: async () => undefined,', - ' closeSession: async () => undefined,', - ' open: async () => { throw new Error(\'unused\'); },', - ' reconcile: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' restart: async () => ({ invalidatedBindings: [], registryRevision: 0 }),', - ' session: () => undefined,', - ' snapshot: () => undefined,', - " subscribe: () => { state.calls.push('subscribe'); state.subscribes += 1; return { unsubscribe: () => { state.calls.push('unsubscribe'); state.unsubscribes += 1; } }; },", - '};', 'export const createDevRuntimeProvider = () => ({', " descriptor: { environmentVariables: [], id: 'prepared-runtime-close', label: 'Prepared Runtime Close', schemaVersion: 1 },", ' start: async () => ({', - ' clientSurface: () => undefined,', " close: async () => { state.calls.push('close'); state.closes += 1; },", ' invoke: async () => { throw new Error(\'unused\'); },', - ' mcpRegistry: registry,', " providerSessionId: 'provider-prepared-runtime-close',", ' readAsset: async () => undefined,', ' readRunFlight: async () => undefined,', @@ -882,9 +868,7 @@ it('prepares the optional runtime once with the development config context befor ' storageRoot: context.storageRoot,', ' }));', ' return {', - " clientSurface: (surfaceId) => surfaceId === 'timeline' ? { entryPath: '/', httpOrigin: 'http://127.0.0.1:41111', httpPathPrefixes: ['/'], subscribeReload: () => () => undefined, surfaceId } : undefined,", ' close: async () => undefined,', - ' mcpRegistry: {},', ' providerSessionId: context.providerSessionId,', ' status: () => ({ descriptor: { environmentVariables: [], id: \'fixture-runtime\', label: \'Fixture runtime\', schemaVersion: 1 }, diagnostics: [], hmrReady: false, state: \'active\' }),', ' surfaces: () => [],', diff --git a/packages/agent-bundle/tests/effect-errors.test.ts b/packages/agent-bundle/tests/effect-errors.test.ts index 1626abb9c..120a505b1 100644 --- a/packages/agent-bundle/tests/effect-errors.test.ts +++ b/packages/agent-bundle/tests/effect-errors.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from '@rstest/core'; import { stableJson } from '../src/core/digest.ts'; import { CodedError } from '../src/core/errors.ts'; import { DevCoordinatorCloseError } from '../src/dev/coordinator.ts'; -import { RuntimeMcpRegistryError } from '../src/dev/runtime-mcp-registry.ts'; +import { RuntimeGenerationStoreError } from '../src/dev/runtime-generation-store.ts'; import { ScriptPlaygroundFailure } from '../src/dev/playground/script-playground-service.ts'; import { isTypedDevError, runPromise } from '../src/effect/boundary.ts'; import { YieldableCodedError, YieldableFrameworkError } from '../src/effect/errors.ts'; @@ -51,8 +51,8 @@ describe('yieldable framework error bases (src/effect/errors.ts)', () => { }); it('types the yielded error into the fail channel', () => { - const program: Effect.Effect = Effect.gen(function* () { - return yield* new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CLOSED', 'Runtime MCP registry is closed.'); + const program: Effect.Effect = Effect.gen(function* () { + return yield* new RuntimeGenerationStoreError('RUNTIME_GENERATION_CLOSED', 'Runtime generation store is closed.'); }); expect(program).toBeDefined(); }); diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index ce1a8fb11..abf08bf53 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -22,18 +22,15 @@ import { TargetRegistry, createDefaultRegistry, createRuntimeGenerationStore, - createRuntimeMcpRegistry, } from '../src/api.ts'; import type { DevRuntimeDescriptor, DevRuntimeInspectionEnvelope, - DevRuntimeMcpServerDescriptor, DevRuntimeProvider, DevRuntimeSession, DevRuntimeStartContext, RuntimeGenerationCandidate, RuntimeGenerationStoreOptions, - RuntimeMcpRegistryOptions, TargetAdapter, TargetHookContract, TargetMcpRuntimeContract, @@ -164,20 +161,14 @@ it('exposes the dev.runtime.provider protocol from the advanced API (#485)', () throw new DevRuntimeUnavailableError(`no runtime for ${context.projectRoot}`); }, } satisfies DevRuntimeProvider; - const server: DevRuntimeMcpServerDescriptor = { - definitionDigest: 'd', name: 'fixture', resources: [], serverDigest: 's', target: 'portable', tools: [], transportDigest: 't', - }; const envelope: Pick = { trace: [] }; const candidate: Pick = { id: 'gen-1' }; const storeOptions: Pick, 'storageRoot'> = { storageRoot: '/tmp/never-opened' }; - const registryOptions: Pick = { stateStoreId: 'state' }; expect(provider.descriptor.id).toBe('synthetic'); - expect(server.name).toBe('fixture'); expect(envelope.trace).toEqual([]); expect(candidate.id).toBe('gen-1'); expect(storeOptions.storageRoot).toBe('/tmp/never-opened'); - expect(registryOptions.stateStoreId).toBe('state'); // The two errors a provider throws for the documented Workbench behaviour carry their codes. expect(new DevRuntimeUnavailableError().code).toBe('AB8201'); expect(new DevRuntimeGenerationConflictError('gen-2', 'gen-1')).toMatchObject({ @@ -185,10 +176,9 @@ it('exposes the dev.runtime.provider protocol from the advanced API (#485)', () code: 'AB8204', expectedGenerationId: 'gen-2', }); - // The store and registry ship as effect-free contracts plus constructors; - // their classes throw YieldableFrameworkErrors and stay behind the boundary. + // The store ships as an effect-free contract plus constructor; its class + // throws YieldableFrameworkErrors and stays behind the boundary. expect(typeof createRuntimeGenerationStore).toBe('function'); - expect(typeof createRuntimeMcpRegistry).toBe('function'); }); it('loads every public subpath and reports the package version', async () => { diff --git a/packages/agent-bundle/tests/runtime-generation-store.test.ts b/packages/agent-bundle/tests/runtime-generation-store.test.ts index 04c7801de..7cc6e47fb 100644 --- a/packages/agent-bundle/tests/runtime-generation-store.test.ts +++ b/packages/agent-bundle/tests/runtime-generation-store.test.ts @@ -17,7 +17,6 @@ import { expect, it } from '@rstest/core'; import { digest, stableJson } from '../src/core/digest.ts'; import { RuntimeGenerationStore, - type DevRuntimeMcpServerDescriptor, type JsonObject, type JsonValue, type RuntimeGenerationActivationGuard, @@ -179,7 +178,7 @@ const validateCapturedMetadata = ( }; const nestedDescriptorCodec: RuntimeGenerationMetadataCodec[]; }>> = { decode: () => Object.freeze({ servers: Object.freeze([]) }), encode: () => Object.freeze({ servers: Object.freeze([]) }), diff --git a/packages/agent-bundle/tests/runtime-mcp-registry.test.ts b/packages/agent-bundle/tests/runtime-mcp-registry.test.ts deleted file mode 100644 index 28d0668de..000000000 --- a/packages/agent-bundle/tests/runtime-mcp-registry.test.ts +++ /dev/null @@ -1,1398 +0,0 @@ -import { mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { expect, it } from '@rstest/core'; -import { deepFreeze } from '../src/core/freeze.ts'; - -import { - RuntimeGenerationStore, - RuntimeMcpRegistry, - RuntimeMcpRegistryCloseError, - type DevRuntimeEventInput, - type DevRuntimeMcpConnectionState, - type DevRuntimeMcpOperationRequest, - type DevRuntimeMcpRegistryReconcileInput, - type DevRuntimeMcpServerDescriptor, - type JsonValue, - type RuntimeGeneration, - type RuntimeMcpConnection, - type RuntimeMcpConnector, - type RuntimeMcpExecutionContext, - type RuntimeMcpExecutionValue, -} from '../src/dev/index.ts'; -import { removeTree } from './support/remove-tree.ts'; - -const deferred = (): Readonly<{ - readonly promise: Promise; - readonly reject: (reason?: unknown) => void; - readonly resolve: (value: T) => void; -}> => { - let reject!: (reason?: unknown) => void; - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return Object.freeze({ promise, reject, resolve }); -}; - -const connectionState: DevRuntimeMcpConnectionState = Object.freeze({ - capabilities: Object.freeze({ tools: Object.freeze({}) }), - protocolEra: 'modern', - protocolVersion: '2025-06-18', - server: Object.freeze({ name: 'fixture', version: '1.0.0' }), -}); - -const descriptor = (input: Readonly<{ - readonly definitionDigest?: string; - readonly serverDigest?: string; - readonly target?: string; - readonly transportDigest?: string; -}> = {}): DevRuntimeMcpServerDescriptor => Object.freeze({ - definitionDigest: input.definitionDigest ?? 'definition-1', - name: 'timeline', - resources: Object.freeze([Object.freeze({ uri: 'timeline://current' })]), - serverDigest: input.serverDigest ?? 'server-g1', - target: input.target ?? 'portable', - tools: Object.freeze([Object.freeze({ name: 'render_timeline' })]), - transportDigest: input.transportDigest ?? 'transport-1', -}); - -const registryInput = (input: Readonly<{ - readonly definitionDigest?: string; - readonly runtimeGenerationId?: string; - readonly serverDigest?: string; - readonly transportDigest?: string; -}> = {}): DevRuntimeMcpRegistryReconcileInput => Object.freeze({ - definitionDigest: input.definitionDigest ?? 'definition-1', - runtimeGenerationId: input.runtimeGenerationId ?? 'g1', - servers: Object.freeze([descriptor({ - definitionDigest: input.definitionDigest, - serverDigest: input.serverDigest, - transportDigest: input.transportDigest, - })]), - transportDigest: input.transportDigest ?? 'transport-1', -}); - -class TestConnection implements RuntimeMcpConnection { - readonly state = connectionState; - closed = false; - relisted = false; - readonly #close: () => Promise; - readonly #relist: () => Promise; - - constructor(input: Readonly<{ - readonly close?: () => Promise; - readonly relist?: () => Promise; - }> = {}) { - this.#close = input.close ?? (async () => undefined); - this.#relist = input.relist ?? (async () => connectionState); - } - - async close(): Promise { - this.closed = true; - await this.#close(); - } - - async relist(): Promise { - this.relisted = true; - return this.#relist(); - } -} - -interface ConnectorHarness { - readonly connections: TestConnection[]; - readonly connector: RuntimeMcpConnector; - readonly pending: readonly Readonly<{ - readonly input: Readonly<{ - readonly descriptor: DevRuntimeMcpServerDescriptor; - readonly sessionId: string; - readonly signal: AbortSignal; - }>; - readonly result: ReturnType>; - }>[]; -} - -const connectorHarness = (input: Readonly<{ - readonly deferAfter?: number; - readonly failAfter?: number; - readonly throwOnClose?: boolean; -}> = {}): ConnectorHarness => { - const connections: TestConnection[] = []; - const pending: Array; - readonly result: ReturnType>; - }>> = []; - let calls = 0; - const connector: RuntimeMcpConnector = Object.freeze({ - connect: async (connectionInput: Parameters[0]) => { - calls += 1; - if (input.failAfter !== undefined && calls >= input.failAfter) { - throw new Error('fixture connector failed'); - } - if (input.deferAfter !== undefined && calls >= input.deferAfter) { - const result = deferred(); - pending.push(Object.freeze({ input: connectionInput, result })); - return result.promise; - } - const connection = new TestConnection({ - close: input.throwOnClose ? async () => { throw new Error('fixture close failed'); } : undefined, - }); - connections.push(connection); - return connection; - }, - }); - return Object.freeze({ connections, connector, pending }); -}; - -const scriptedConnector = (...steps: readonly (Error | RuntimeMcpConnection)[]): RuntimeMcpConnector => { - let next = 0; - return Object.freeze({ - connect: async () => { - const step = steps[next++]; - if (step === undefined) throw new Error('Unexpected fixture connector call.'); - if (step instanceof Error) throw step; - return step; - }, - }); -}; - -const trackAbortSignalListeners = (): Readonly<{ - readonly residual: () => readonly number[]; - readonly restore: () => void; -}> => { - type AddListener = ( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions, - ) => void; - type RemoveListener = ( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | EventListenerOptions, - ) => void; - const prototype = Object.getPrototypeOf(new AbortController().signal) as AbortSignal; - const ownAdd = Object.getOwnPropertyDescriptor(prototype, 'addEventListener'); - const ownRemove = Object.getOwnPropertyDescriptor(prototype, 'removeEventListener'); - const add = prototype.addEventListener as AddListener; - const remove = prototype.removeEventListener as RemoveListener; - const counts = new Map(); - Object.defineProperty(prototype, 'addEventListener', { - configurable: true, - value: function addListener( - this: AbortSignal, - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions, - ): void { - if (type === 'abort') counts.set(this, (counts.get(this) ?? 0) + 1); - add.call(this, type, listener, options); - }, - }); - Object.defineProperty(prototype, 'removeEventListener', { - configurable: true, - value: function removeListener( - this: AbortSignal, - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | EventListenerOptions, - ): void { - if (type === 'abort') counts.set(this, (counts.get(this) ?? 0) - 1); - remove.call(this, type, listener, options); - }, - }); - return Object.freeze({ - residual: () => Object.freeze([...counts.values()].filter((count) => count !== 0)), - restore: () => { - if (ownAdd === undefined) delete (prototype as { addEventListener?: unknown }).addEventListener; - else Object.defineProperty(prototype, 'addEventListener', ownAdd); - if (ownRemove === undefined) delete (prototype as { removeEventListener?: unknown }).removeEventListener; - else Object.defineProperty(prototype, 'removeEventListener', ownRemove); - }, - }); -}; - -const createGenerationStore = async (retainInactive?: number): Promise Promise; - readonly commit: (id: string) => Promise; - readonly root: string; - readonly store: RuntimeGenerationStore; -}>> => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-runtime-mcp-registry-')); - const store = new RuntimeGenerationStore({ - metadataCodec: { - decode: (value): unknown => value, - encode: (value): JsonValue => value as JsonValue, - }, - ...(retainInactive === undefined ? {} : { retainInactive }), - storageRoot: root, - validateMetadata: ({ metadata }) => metadata, - }); - const fixture: Readonly<{ - readonly close: () => Promise; - readonly commit: (id: string) => Promise; - readonly root: string; - readonly store: RuntimeGenerationStore; - }> = { - close: async () => { - await store.close().catch(() => undefined); - await removeTree(root); - }, - commit: async (id) => { - const candidate = await store.begin({ id, sourceRevision: `source-${id}` }); - const prepared = await store.prepare(candidate, { assets: [], metadata: Object.freeze({ id }) }); - return store.commit(prepared); - }, - root, - store, - }; - return Object.freeze(fixture); -}; - -const createRegistry = (input: Readonly<{ - readonly connector?: RuntimeMcpConnector; - readonly events?: DevRuntimeEventInput[]; - readonly executor?: (context: RuntimeMcpExecutionContext) => Promise; - readonly initialRegistry?: DevRuntimeMcpRegistryReconcileInput; - readonly store: RuntimeGenerationStore; -}>): RuntimeMcpRegistry => new RuntimeMcpRegistry({ - artifactEpochId: () => 'artifact-1', - connector: input.connector ?? connectorHarness().connector, - createOperationId: (() => { - let next = 0; - return () => `operation-${++next}`; - })(), - createSessionId: (() => { - let next = 0; - return () => `session-${++next}`; - })(), - emit: (event) => { input.events?.push(event); }, - executor: input.executor ?? (async (context) => deepFreeze({ - stateVersion: 7, - value: { kind: context.request.kind, generation: context.generation.id }, - })), - generationStore: input.store, - initialRegistry: input.initialRegistry ?? registryInput(), - providerSessionId: 'provider-1', - stateStoreId: 'state-1', -}); - -const request = (kind: DevRuntimeMcpOperationRequest['kind'], revision: number): DevRuntimeMcpOperationRequest => { - if (kind === 'call-tool') { - return Object.freeze({ - arguments: Object.freeze({ limit: 10 }), - expectedSessionRevision: revision, - kind, - name: 'render_timeline', - }); - } - if (kind === 'read-resource') { - return Object.freeze({ expectedSessionRevision: revision, kind, uri: 'timeline://current' }); - } - return Object.freeze({ expectedSessionRevision: revision, kind }); -}; - -it('opens only static registered descriptors and rejects stale server, target, and revision selectors', async () => { - const fixture = await createGenerationStore(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - - const session = await registry.open({ - expectedRegistryRevision: 1, - serverName: 'timeline', - target: 'portable', - }); - expect(session.snapshot().binding).toMatchObject({ - registryRevision: 1, - serverName: 'timeline', - sessionRevision: 1, - target: 'portable', - }); - await expect(registry.open({ serverName: 'missing', target: 'portable' })).rejects.toThrow('Unknown runtime MCP server'); - await expect(registry.open({ serverName: 'timeline', target: 'claude' })).rejects.toThrow('Unknown runtime MCP server'); - await expect(registry.open({ expectedRegistryRevision: 2, serverName: 'timeline', target: 'portable' })) - .rejects.toThrow('registry revision'); - expect(() => new RuntimeMcpRegistry({ - artifactEpochId: () => undefined, - connector: connectorHarness().connector, - emit: () => undefined, - executor: async () => ({ stateVersion: 0, value: null }), - generationStore: fixture.store, - initialRegistry: Object.freeze({ ...registryInput(), servers: Object.freeze([descriptor(), descriptor()]) }), - providerSessionId: 'provider-1', - stateStoreId: 'state-1', - })).toThrow('duplicate'); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('leases a generation per blocked operation while implementation updates preserve the ready connection and bindings', async () => { - const fixture = await createGenerationStore(0); - const connector = connectorHarness(); - const entered = deferred(); - const release = deferred(); - const registry = createRegistry({ - connector: connector.connector, - executor: async (context) => { - if (context.request.kind === 'call-tool') { - entered.resolve(); - await release.promise; - } - return deepFreeze({ stateVersion: 3, value: { generation: context.generation.id } }); - }, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const first = session.execute(request('call-tool', before.binding.sessionRevision)); - await entered.promise; - await fixture.commit('g2'); - - const updated = await registry.reconcile(registryInput({ runtimeGenerationId: 'g2', serverDigest: 'server-g2' })); - expect(updated.action).toBe('implementation-updated'); - expect(session.snapshot().binding).toMatchObject({ - registryRevision: before.binding.registryRevision, - serverDigest: 'server-g2', - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - }); - expect(connector.connections).toHaveLength(1); - - release.resolve(); - expect((await first).vector).toMatchObject({ runtimeGenerationId: 'g1', sourceRevision: 'source-g1' }); - expect((await session.execute(request('list-tools', before.binding.sessionRevision))).vector.runtimeGenerationId).toBe('g2'); - await expect(fixture.store.lease('g1')).rejects.toMatchObject({ code: 'RUNTIME_GENERATION_NOT_FOUND' }); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('returns static lists and complete leased vectors for every MCP operation', async () => { - const fixture = await createGenerationStore(); - const observed: RuntimeMcpExecutionContext[] = []; - const registry = createRegistry({ - executor: async (context) => { - observed.push(context); - return deepFreeze({ stateVersion: 42, value: { result: context.request.kind } }); - }, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const revision = session.snapshot().binding.sessionRevision; - const results = await Promise.all([ - session.execute(request('list-tools', revision)), - session.execute(request('list-resources', revision)), - session.execute(request('read-resource', revision)), - session.execute(request('call-tool', revision)), - ]); - expect(results[0]!.value).toEqual([Object.freeze({ name: 'render_timeline' })]); - expect(results[1]!.value).toEqual([Object.freeze({ uri: 'timeline://current' })]); - expect(results[2]!.value).toEqual({ result: 'read-resource' }); - expect(results[3]!.value).toEqual({ result: 'call-tool' }); - for (const result of results) { - expect(result).toMatchObject({ - operationId: expect.any(String), - sessionId: session.snapshot().binding.sessionId, - sessionRevision: revision, - vector: { - artifactEpochId: 'artifact-1', - providerSessionId: 'provider-1', - runtimeGenerationId: 'g1', - sourceRevision: 'source-g1', - stateStoreId: 'state-1', - stateVersion: expect.any(Number), - }, - }); - } - expect(observed.map((entry) => entry.request.kind).sort()).toEqual(['call-tool', 'read-resource']); - await expect(session.execute(Object.freeze({ - arguments: Object.freeze({}), - expectedSessionRevision: revision, - kind: 'call-tool', - name: 'undeclared', - }))).rejects.toThrow('not declared'); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('makes public definition restarts visible before awaiting replacement connection and invalidates old bindings', async () => { - const fixture = await createGenerationStore(); - const events: DevRuntimeEventInput[] = []; - const connector = connectorHarness({ deferAfter: 2 }); - const registry = createRegistry({ connector: connector.connector, events, store: fixture.store }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const reconciling = registry.reconcile(registryInput({ definitionDigest: 'definition-2' })); - expect(session.snapshot()).toMatchObject({ - binding: { registryRevision: 2, sessionRevision: 2 }, - state: 'restarting', - }); - expect(events.map((event) => event.type)).toContain('runtime.mcp.restarting'); - await expect(session.execute(request('list-tools', 2))).rejects.toThrow('restarting'); - expect(connector.pending).toHaveLength(1); - const replacement = new TestConnection(); - connector.pending[0]!.result.resolve(replacement); - const result = await reconciling; - expect(result).toMatchObject({ - action: 'sessions-restarted', - invalidatedBindings: [{ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - }], - registryRevision: 2, - restartedSessionIds: [before.binding.sessionId], - }); - expect(session.snapshot().state).toBe('ready'); - expect(replacement.relisted).toBe(true); - expect(events.map((event) => event.type)).toEqual(expect.arrayContaining(['runtime.mcp.restarting', 'runtime.mcp.ready'])); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('keeps a failed controlled restart on its new binding revision and emits a failed result', async () => { - const fixture = await createGenerationStore(); - const events: DevRuntimeEventInput[] = []; - const connector = connectorHarness({ failAfter: 2 }); - const registry = createRegistry({ connector: connector.connector, events, store: fixture.store }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const result = await registry.reconcile(registryInput({ transportDigest: 'transport-2' })); - expect(result).toMatchObject({ - action: 'restart-failed', - invalidatedBindings: [{ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - }], - registryRevision: 2, - }); - expect(session.snapshot()).toMatchObject({ - binding: { registryRevision: 2, sessionRevision: 2 }, - state: 'failed', - }); - await expect(session.execute(request('list-tools', 1))).rejects.toThrow('revision'); - expect(events.map((event) => event.type)).toContain('runtime.mcp.failed'); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('closes every connection whose relist validation fails across open, visible restart, and private activation', async () => { - const fixture = await createGenerationStore(); - const openFailure = new TestConnection({ - close: async () => { throw new Error('open cleanup failed'); }, - relist: async () => { throw new Error('open relist failed'); }, - }); - const visibleOld = new TestConnection(); - const visibleFailure = new TestConnection({ relist: async () => { throw new Error('visible relist failed'); } }); - const privateOld = new TestConnection(); - const privateFailure = new TestConnection({ relist: async () => { throw new Error('private relist failed'); } }); - const openRegistry = createRegistry({ connector: scriptedConnector(openFailure), store: fixture.store }); - const visibleRegistry = createRegistry({ connector: scriptedConnector(visibleOld, visibleFailure), store: fixture.store }); - const privateRegistry = createRegistry({ connector: scriptedConnector(privateOld, privateFailure), store: fixture.store }); - try { - await fixture.commit('g1'); - await expect(openRegistry.open({ serverName: 'timeline', target: 'portable' })).rejects.toBeInstanceOf(AggregateError); - expect(openFailure.closed).toBe(true); - - await visibleRegistry.open({ serverName: 'timeline', target: 'portable' }); - expect((await visibleRegistry.reconcile(registryInput({ definitionDigest: 'definition-2' }))).action).toBe('restart-failed'); - expect(visibleFailure.closed).toBe(true); - - await privateRegistry.open({ serverName: 'timeline', target: 'portable' }); - await expect(privateRegistry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-2' }))) - .rejects.toThrow('private relist failed'); - expect(privateFailure.closed).toBe(true); - } finally { - await Promise.all([ - openRegistry.close().catch(() => undefined), - visibleRegistry.close().catch(() => undefined), - privateRegistry.close().catch(() => undefined), - ]); - await fixture.close(); - } -}); - -it('retains public and manual relist cleanup failures for registry-close retry and aggregation', async () => { - const fixture = await createGenerationStore(); - const publicOld = new TestConnection(); - let publicCloseCalls = 0; - const publicFailure = new TestConnection({ - close: async () => { - publicCloseCalls += 1; - throw new Error('public cleanup failed'); - }, - relist: async () => { throw new Error('public relist failed'); }, - }); - const manualOld = new TestConnection(); - let manualCloseCalls = 0; - const manualFailure = new TestConnection({ - close: async () => { - manualCloseCalls += 1; - throw new Error('manual cleanup failed'); - }, - relist: async () => { throw new Error('manual relist failed'); }, - }); - const publicRegistry = createRegistry({ connector: scriptedConnector(publicOld, publicFailure), store: fixture.store }); - const manualRegistry = createRegistry({ connector: scriptedConnector(manualOld, manualFailure), store: fixture.store }); - try { - await fixture.commit('g1'); - await publicRegistry.open({ serverName: 'timeline', target: 'portable' }); - expect((await publicRegistry.reconcile(registryInput({ definitionDigest: 'definition-2' }))).action).toBe('restart-failed'); - await expect(publicRegistry.close()).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(publicFailure.closed).toBe(true); - expect(publicCloseCalls).toBe(2); - - const session = await manualRegistry.open({ serverName: 'timeline', target: 'portable' }); - expect((await manualRegistry.restart({ - expectedSessionRevision: session.snapshot().binding.sessionRevision, - sessionId: session.snapshot().binding.sessionId, - })).action).toBe('restart-failed'); - await expect(manualRegistry.close()).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(manualFailure.closed).toBe(true); - expect(manualCloseCalls).toBe(2); - } finally { - await Promise.all([ - publicRegistry.close().catch(() => undefined), - manualRegistry.close().catch(() => undefined), - ]); - await fixture.close(); - } -}); - -it('retains every failed staged cleanup when a later private replacement setup fails', async () => { - const fixture = await createGenerationStore(); - const firstOld = new TestConnection(); - const secondOld = new TestConnection(); - let firstStagedCloseCalls = 0; - const firstStaged = new TestConnection({ - close: async () => { - firstStagedCloseCalls += 1; - throw new Error('first staged cleanup failed'); - }, - }); - let secondStagedCloseCalls = 0; - const secondStaged = new TestConnection({ - close: async () => { - secondStagedCloseCalls += 1; - throw new Error('second staged cleanup failed'); - }, - relist: async () => { throw new Error('second staged relist failed'); }, - }); - let session = 0; - const registry = createRegistry({ - connector: Object.freeze({ - connect: async () => { - session += 1; - if (session === 1) return firstOld; - if (session === 2) return secondOld; - if (session === 3) return firstStaged; - return secondStaged; - }, - }), - store: fixture.store, - }); - try { - await fixture.commit('g1'); - await registry.open({ serverName: 'timeline', target: 'portable' }); - await registry.open({ serverName: 'timeline', target: 'portable' }); - await expect(registry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-2' }))) - .rejects.toBeInstanceOf(AggregateError); - await expect(registry.close()).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(firstStaged.closed).toBe(true); - expect(secondStaged.closed).toBe(true); - expect(firstStagedCloseCalls).toBe(2); - expect(secondStagedCloseCalls).toBe(2); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('repairs a failed visible session through private activation without consuming an invalid prepared transaction', async () => { - const fixture = await createGenerationStore(); - const first = new TestConnection(); - const recovered = new TestConnection(); - const registry = createRegistry({ - connector: scriptedConnector(first, new Error('visible restart failed'), recovered), - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - expect((await registry.reconcile(registryInput({ transportDigest: 'transport-2' }))).action).toBe('restart-failed'); - expect(session.snapshot().state).toBe('failed'); - - const prepared = await registry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-3' })); - const committed = registry.commitActivationReconcile(prepared); - expect(session.snapshot()).toMatchObject({ - binding: { registryRevision: 3, sessionRevision: 3 }, - state: 'restarting', - }); - await expect(session.execute(request('list-tools', 3))).rejects.toThrow('restarting'); - expect(recovered.relisted).toBe(true); - committed.publish(); - expect(session.snapshot().state).toBe('ready'); - await committed.finalize(); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('closes retired private activation connections even when callers drop or observe a failing finalizer', async () => { - const fixture = await createGenerationStore(); - const retired = new TestConnection(); - const replacement = new TestConnection(); - const registry = createRegistry({ connector: scriptedConnector(retired, replacement), store: fixture.store }); - try { - await fixture.commit('g1'); - await registry.open({ serverName: 'timeline', target: 'portable' }); - const committed = registry.commitActivationReconcile( - await registry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-2' })), - ); - committed.publish(); - await registry.close(); - expect(retired.closed).toBe(true); - expect(replacement.closed).toBe(true); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } - - const failingFixture = await createGenerationStore(); - const failingRetired = new TestConnection({ close: async () => { throw new Error('retired cleanup failed'); } }); - const failingReplacement = new TestConnection(); - const failingRegistry = createRegistry({ connector: scriptedConnector(failingRetired, failingReplacement), store: failingFixture.store }); - try { - await failingFixture.commit('g1'); - await failingRegistry.open({ serverName: 'timeline', target: 'portable' }); - const committed = failingRegistry.commitActivationReconcile( - await failingRegistry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-2' })), - ); - await expect(committed.finalize()).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - await expect(failingRegistry.close()).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(failingRetired.closed).toBe(true); - expect(failingReplacement.closed).toBe(true); - } finally { - await failingRegistry.close().catch(() => undefined); - await failingFixture.close(); - } -}); - -it('delivers public and delayed-private results in sequence order under reentrant listeners', async () => { - const fixture = await createGenerationStore(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - const received: number[] = []; - registry.subscribe({ afterSequence: 0 }, (message) => { - if ('sequence' in message && message.sequence === 1) { - void registry.reconcile(registryInput({ serverDigest: 'reentrant' })); - } - }); - registry.subscribe({ afterSequence: 0 }, (message) => { - if ('sequence' in message) received.push(message.sequence); - }); - const first = registry.commitActivationReconcile( - await registry.prepareActivationReconcile(registryInput({ runtimeGenerationId: 'g1', serverDigest: 'first' })), - ); - first.publish(); - expect(received).toEqual([1, 2]); - - const committed = registry.commitActivationReconcile( - await registry.prepareActivationReconcile(registryInput({ runtimeGenerationId: 'g1', serverDigest: 'private' })), - ); - await registry.reconcile(registryInput({ runtimeGenerationId: 'g1', serverDigest: 'public' })); - expect(received).toEqual([1, 2]); - committed.publish(); - expect(received).toEqual([1, 2, 3, 4]); - - const replay: number[] = []; - registry.subscribe({ afterSequence: 0 }, (message) => { - if ('sequence' in message) replay.push(message.sequence); - }); - expect(replay).toEqual([1, 2, 3, 4]); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('honors afterSequence and snapshots subscribers across delayed and reentrant delivery', async () => { - const fixture = await createGenerationStore(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - await registry.reconcile(registryInput({ serverDigest: 'one' })); - await registry.reconcile(registryInput({ serverDigest: 'two' })); - const delayed = registry.commitActivationReconcile( - await registry.prepareActivationReconcile(registryInput({ runtimeGenerationId: 'g1', serverDigest: 'three' })), - ); - await registry.reconcile(registryInput({ serverDigest: 'four' })); - const afterFour: number[] = []; - registry.subscribe({ afterSequence: 4 }, (message) => { - if ('sequence' in message) afterFour.push(message.sequence); - }); - delayed.publish(); - expect(afterFour).toEqual([]); - - const nested: number[] = []; - registry.subscribe({ afterSequence: 4 }, (message) => { - if ('sequence' in message && message.sequence === 5) { - registry.subscribe({ afterSequence: 0 }, (inner) => { - if ('sequence' in inner) nested.push(inner.sequence); - }); - } - }); - await registry.reconcile(registryInput({ serverDigest: 'five' })); - await registry.reconcile(registryInput({ serverDigest: 'six' })); - expect(nested).toEqual([1, 2, 3, 4, 5, 6]); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('replays the last 64 sequenced results before ordered live delivery and reports cursor gaps', async () => { - const fixture = await createGenerationStore(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - for (let index = 0; index < 66; index += 1) { - await registry.reconcile(registryInput({ serverDigest: `server-${index}` })); - } - const replay: Array = []; - const gapSubscription = registry.subscribe({ afterSequence: 0 }, (message) => { replay.push(message); }); - expect(replay[0]).toMatchObject({ - earliestAvailableSequence: 3, - latestDroppedSequence: 2, - requestedAfterSequence: 0, - type: 'replay.gap', - }); - expect(replay).toHaveLength(65); - gapSubscription.unsubscribe(); - - const live: Array<{ readonly sequence: number }> = []; - const subscription = registry.subscribe({ afterSequence: 66 }, (message) => { - if ('sequence' in message) live.push(message); - }); - await registry.reconcile(registryInput({ serverDigest: 'server-live' })); - subscription.unsubscribe(); - expect(live).toEqual([expect.objectContaining({ sequence: 67 })]); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('manually restarts and closes a revision-checked owned session while session views only observe closure', async () => { - const fixture = await createGenerationStore(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const view = registry.session(before.binding.sessionId); - if (view === undefined) throw new Error('Expected a non-owning session view.'); - let watched = 0; - const observation = view.watchClosed(() => { watched += 1; }); - expect(observation.closed).toBe(false); - const restarted = await registry.restart({ - expectedSessionRevision: before.binding.sessionRevision, - sessionId: before.binding.sessionId, - }); - expect(restarted).toMatchObject({ action: 'sessions-restarted', registryRevision: before.binding.registryRevision }); - expect(session.snapshot().binding.sessionRevision).toBe(before.binding.sessionRevision + 1); - await expect(registry.closeSession({ - expectedSessionRevision: before.binding.sessionRevision, - sessionId: before.binding.sessionId, - })).rejects.toThrow('revision'); - await registry.closeSession({ - expectedSessionRevision: before.binding.sessionRevision + 1, - sessionId: before.binding.sessionId, - }); - expect(watched).toBe(1); - expect(view.watchClosed(() => undefined).closed).toBe(true); - observation.unsubscribe(); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('closes idempotently, rejects new work, and aggregates connector cleanup failures', async () => { - const fixture = await createGenerationStore(); - const connector = connectorHarness({ throwOnClose: true }); - const registry = createRegistry({ connector: connector.connector, store: fixture.store }); - try { - await fixture.commit('g1'); - await registry.open({ serverName: 'timeline', target: 'portable' }); - const first = registry.close(); - const second = registry.close(); - expect(second).toBe(first); - await expect(first).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - await expect(registry.open({ serverName: 'timeline', target: 'portable' })).rejects.toThrow('closed'); - } finally { - await fixture.close(); - } -}); - -it('settles an aborted pending open before registry close resolves', async () => { - const fixture = await createGenerationStore(); - const connector = connectorHarness({ deferAfter: 1 }); - const registry = createRegistry({ connector: connector.connector, store: fixture.store }); - try { - await fixture.commit('g1'); - const opening = registry.open({ serverName: 'timeline', target: 'portable' }); - await Promise.resolve(); - expect(connector.pending).toHaveLength(1); - let closed = false; - const closing = registry.close().then(() => { closed = true; }); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(closed).toBe(false); - connector.pending[0]!.result.resolve(new TestConnection()); - await expect(opening).rejects.toThrow('closed'); - await closing; - expect(closed).toBe(true); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('settles an aborted private activation preparation before registry close resolves', async () => { - const fixture = await createGenerationStore(); - const connector = connectorHarness({ deferAfter: 2 }); - const registry = createRegistry({ connector: connector.connector, store: fixture.store }); - try { - await fixture.commit('g1'); - await registry.open({ serverName: 'timeline', target: 'portable' }); - const staging = registry.prepareActivationReconcile(registryInput({ definitionDigest: 'definition-2' })); - await Promise.resolve(); - expect(connector.pending).toHaveLength(1); - let closed = false; - const closing = registry.close().then(() => { closed = true; }); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(closed).toBe(false); - connector.pending[0]!.result.resolve(new TestConnection()); - await expect(staging).rejects.toThrow('closed'); - await closing; - expect(closed).toBe(true); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('waits deferred public reconcile and restart replacement negotiation before closing late failed setup ownership', async () => { - const run = async (kind: 'reconcile' | 'restart'): Promise => { - const fixture = await createGenerationStore(); - const old = new TestConnection(); - const replacement = deferred(); - let replacementCloseCalls = 0; - const failedReplacement = new TestConnection({ - close: async () => { - replacementCloseCalls += 1; - throw new Error(`${kind} replacement cleanup failed`); - }, - relist: async () => { throw new Error(`${kind} replacement relist failed`); }, - }); - let connects = 0; - const registry = createRegistry({ - connector: Object.freeze({ - connect: async () => { - connects += 1; - return connects === 1 ? old : replacement.promise; - }, - }), - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const changing = kind === 'reconcile' - ? registry.reconcile(registryInput({ definitionDigest: 'definition-2' })) - : registry.restart({ - expectedSessionRevision: session.snapshot().binding.sessionRevision, - sessionId: session.snapshot().binding.sessionId, - }); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(connects).toBe(2); - let closeSettled = false; - const closing = registry.close(); - void closing.then( - () => { closeSettled = true; }, - () => { closeSettled = true; }, - ); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(closeSettled).toBe(false); - - replacement.resolve(failedReplacement); - if (kind === 'reconcile') await expect(changing).rejects.toThrow('closed'); - else await expect(changing).resolves.toMatchObject({ action: 'restart-failed' }); - await expect(closing).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(replacementCloseCalls).toBe(2); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } - }; - - await run('reconcile'); - await run('restart'); -}); - -it('waits public closeSession and owned session close before closing their late orphan cleanup', async () => { - const run = async (kind: 'close-session' | 'owned-close'): Promise => { - const fixture = await createGenerationStore(); - const deferredClose = deferred(); - let closeCalls = 0; - const connection = new TestConnection({ - close: async () => { - closeCalls += 1; - return deferredClose.promise; - }, - }); - const registry = createRegistry({ connector: scriptedConnector(connection), store: fixture.store }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const closingSession = kind === 'close-session' - ? registry.closeSession({ - expectedSessionRevision: session.snapshot().binding.sessionRevision, - sessionId: session.snapshot().binding.sessionId, - }) - : session.close(); - await Promise.resolve(); - expect(closeCalls).toBe(1); - let closeSettled = false; - const closing = registry.close(); - void closing.then( - () => { closeSettled = true; }, - () => { closeSettled = true; }, - ); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(closeSettled).toBe(false); - - deferredClose.reject(new Error(`${kind} cleanup failed`)); - await expect(closingSession).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - await expect(closing).rejects.toBeInstanceOf(RuntimeMcpRegistryCloseError); - expect(closeCalls).toBe(2); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } - }; - - await run('close-session'); - await run('owned-close'); -}); - -it('synchronously aborts deferred public reconcile and restart replacement setup before joining them on close', async () => { - const run = async (kind: 'reconcile' | 'restart'): Promise => { - const fixture = await createGenerationStore(); - const old = new TestConnection(); - const enteredSetup = deferred(); - const cleanupAbort = new AbortController(); - let connects = 0; - let setupAborted = false; - const registry = createRegistry({ - connector: Object.freeze({ - connect: async (input: Parameters[0]) => { - const { signal } = input; - connects += 1; - if (connects === 1) return old; - enteredSetup.resolve(); - return new Promise((_resolve, reject) => { - const rejectForAbort = (): void => { reject(signal.reason ?? new Error('setup aborted')); }; - signal.addEventListener('abort', () => { - setupAborted = true; - rejectForAbort(); - }, { once: true }); - cleanupAbort.signal.addEventListener('abort', rejectForAbort, { once: true }); - }); - }, - }), - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const changing = kind === 'reconcile' - ? registry.reconcile(registryInput({ definitionDigest: 'definition-2' })) - : registry.restart({ - expectedSessionRevision: session.snapshot().binding.sessionRevision, - sessionId: session.snapshot().binding.sessionId, - }); - await enteredSetup.promise; - const closing = registry.close(); - const abortedSynchronously = setupAborted; - if (!abortedSynchronously) cleanupAbort.abort(new Error('test cleanup after missed close abort')); - if (kind === 'reconcile') await expect(changing).rejects.toThrow('closed'); - else await expect(changing).resolves.toMatchObject({ action: 'restart-failed' }); - await expect(closing).resolves.toBeUndefined(); - expect(abortedSynchronously).toBe(true); - } finally { - cleanupAbort.abort(new Error('test cleanup')); - await registry.close().catch(() => undefined); - await fixture.close(); - } - }; - - await run('reconcile'); - await run('restart'); -}); - -it('disposes successful composite signal listeners and propagates close abort once', async () => { - const fixture = await createGenerationStore(); - const listeners = trackAbortSignalListeners(); - const registry = createRegistry({ store: fixture.store }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - for (let index = 0; index < 12; index += 1) { - const revision = session.snapshot().binding.sessionRevision; - await session.execute(request('list-tools', revision)); - await registry.restart({ expectedSessionRevision: revision, sessionId: session.snapshot().binding.sessionId }); - } - expect(listeners.residual()).toEqual([]); - } finally { - listeners.restore(); - await registry.close().catch(() => undefined); - await fixture.close(); - } - - const abortFixture = await createGenerationStore(); - const entered = deferred(); - let aborts = 0; - const abortRegistry = createRegistry({ - executor: async (context) => { - entered.resolve(); - return new Promise((_resolve, reject) => { - context.signal.addEventListener('abort', () => { - aborts += 1; - reject(context.signal.reason); - }, { once: true }); - }); - }, - store: abortFixture.store, - }); - try { - await abortFixture.commit('g1'); - const session = await abortRegistry.open({ serverName: 'timeline', target: 'portable' }); - const running = session.execute(request('call-tool', session.snapshot().binding.sessionRevision)); - await entered.promise; - const firstClose = abortRegistry.close(); - expect(abortRegistry.close()).toBe(firstClose); - await expect(running).rejects.toThrow('closed'); - await expect(firstClose).resolves.toBeUndefined(); - expect(aborts).toBe(1); - } finally { - await abortRegistry.close().catch(() => undefined); - await abortFixture.close(); - } -}); - -it('forwards a caller abort signal to an active Runtime MCP executor exactly once', async () => { - const fixture = await createGenerationStore(); - const entered = deferred(); - let aborts = 0; - const registry = createRegistry({ - executor: async (context) => { - entered.resolve(); - return new Promise((_resolve, reject) => { - context.signal.addEventListener('abort', () => { - aborts += 1; - reject(context.signal.reason); - }, { once: true }); - }); - }, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const caller = new AbortController(); - const running = session.execute(request('call-tool', session.snapshot().binding.sessionRevision), Object.freeze({ signal: caller.signal })); - await entered.promise; - const reason = new DOMException('Caller cancelled the Runtime MCP operation.', 'AbortError'); - caller.abort(reason); - await expect(running).rejects.toBe(reason); - expect(aborts).toBe(1); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('does not execute an already cancelled Runtime MCP operation after leasing its generation', async () => { - const fixture = await createGenerationStore(); - let executions = 0; - const registry = createRegistry({ - executor: async () => { - executions += 1; - return deepFreeze({ stateVersion: 1, value: { unexpected: true } }); - }, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const caller = new AbortController(); - const reason = new DOMException('Caller cancelled the Runtime MCP operation.', 'AbortError'); - caller.abort(reason); - - await expect(session.execute( - request('call-tool', session.snapshot().binding.sessionRevision), - Object.freeze({ signal: caller.signal }), - )).rejects.toBe(reason); - expect(executions).toBe(0); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('prepares private activation without public visibility, commits synchronously with buffered publish, and aborts without changes', async () => { - const fixture = await createGenerationStore(); - const connector = connectorHarness({ deferAfter: 2 }); - const events: DevRuntimeEventInput[] = []; - const entered = deferred(); - const release = deferred(); - const registry = createRegistry({ - connector: connector.connector, - events, - executor: async (context) => { - if (context.request.kind === 'call-tool') { - entered.resolve(); - await release.promise; - } - return deepFreeze({ stateVersion: 9, value: { generation: context.generation.id } }); - }, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - await fixture.commit('g2'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const running = session.execute(request('call-tool', before.binding.sessionRevision)); - await entered.promise; - const staged = registry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g2', - })); - expect(session.snapshot()).toEqual(before); - expect(events).toEqual([]); - expect((await session.execute(request('list-tools', before.binding.sessionRevision))).vector.runtimeGenerationId).toBe('g1'); - expect(connector.pending).toHaveLength(1); - connector.pending[0]!.result.resolve(new TestConnection()); - const prepared = await staged; - expect(session.snapshot()).toEqual(before); - await expect(registry.open({ serverName: 'timeline', target: 'portable' })).rejects.toThrow('reserved'); - const committed = registry.commitActivationReconcile(prepared); - expect(session.snapshot()).toMatchObject({ - binding: { registryRevision: 2, sessionRevision: 2 }, - state: 'restarting', - }); - await expect(session.execute(request('list-tools', 2))).rejects.toThrow('restarting'); - expect(events).toEqual([{ - mcpRegistryRevision: before.binding.registryRevision + 1, - mcpSessionId: before.binding.sessionId, - mcpSessionRevision: before.binding.sessionRevision + 1, - runtimeGenerationId: 'g2', - type: 'runtime.mcp.restarting', - }]); - committed.publish(); - expect(session.snapshot().state).toBe('ready'); - expect(events.map((event) => event.type)).toEqual(['runtime.mcp.restarting', 'runtime.mcp.ready']); - release.resolve(); - expect(await running).toMatchObject({ - sessionRevision: before.binding.sessionRevision, - vector: { runtimeGenerationId: 'g1' }, - }); - await committed.finalize(); - - const abort = await registry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g1', - serverDigest: 'server-g1', - })); - await registry.abortActivationReconcile(abort); - expect(session.snapshot().binding.serverDigest).toBe('server-g1'); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('emits an activation restart with the replacement binding before publishing its revocation', async () => { - const fixture = await createGenerationStore(); - const events: DevRuntimeEventInput[] = []; - const registry = createRegistry({ events, store: fixture.store }); - try { - await fixture.commit('g1'); - await fixture.commit('g2'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const before = session.snapshot(); - const publications: string[] = []; - let reentrantExecution: Promise | undefined; - registry.subscribe({ afterSequence: 0 }, (message) => { - if (!('sequence' in message)) return; - publications.push(message.action); - if (message.action === 'sessions-restarted') { - reentrantExecution = session.execute(request('list-tools', session.snapshot().binding.sessionRevision)); - } - }); - - const prepared = await registry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g2', - })); - expect(events).toEqual([]); - const committed = registry.commitActivationReconcile(prepared); - - expect(events).toEqual([{ - mcpRegistryRevision: before.binding.registryRevision + 1, - mcpSessionId: before.binding.sessionId, - mcpSessionRevision: before.binding.sessionRevision + 1, - runtimeGenerationId: 'g2', - type: 'runtime.mcp.restarting', - }]); - expect(publications).toEqual([]); - committed.publish(); - committed.publish(); - expect(publications).toEqual(['sessions-restarted']); - await expect(reentrantExecution).rejects.toThrow('restarting'); - expect(session.snapshot().state).toBe('ready'); - expect(events.map((event) => event.type)).toEqual(['runtime.mcp.restarting', 'runtime.mcp.ready']); - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('does not resurrect a prepared replacement when close precedes or interrupts publication', async () => { - const fixture = await createGenerationStore(); - const events: DevRuntimeEventInput[] = []; - const registry = createRegistry({ events, store: fixture.store }); - try { - await fixture.commit('g1'); - await fixture.commit('g2'); - const session = await registry.open({ serverName: 'timeline', target: 'portable' }); - const beforeClose = registry.commitActivationReconcile(await registry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g2', - }))); - expect(session.snapshot().state).toBe('restarting'); - const closing = registry.close(); - beforeClose.publish(); - beforeClose.publish(); - await closing; - expect(session.snapshot().state).toBe('closed'); - expect(events.map((event) => event.type)).toEqual(['runtime.mcp.restarting']); - - const duringFixture = await createGenerationStore(); - const duringEvents: DevRuntimeEventInput[] = []; - const duringRegistry = createRegistry({ events: duringEvents, store: duringFixture.store }); - try { - await duringFixture.commit('g1'); - await duringFixture.commit('g2'); - const duringSession = await duringRegistry.open({ serverName: 'timeline', target: 'portable' }); - const publications: string[] = []; - let interruptedClose: Promise | undefined; - duringRegistry.subscribe({ afterSequence: 0 }, (message) => { - if (!('sequence' in message)) return; - publications.push(message.action); - if (message.action === 'sessions-restarted') interruptedClose ??= duringRegistry.close(); - }); - const duringClose = duringRegistry.commitActivationReconcile(await duringRegistry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g2', - }))); - duringClose.publish(); - duringClose.publish(); - await interruptedClose; - expect(publications).toEqual(['sessions-restarted']); - expect(duringSession.snapshot().state).toBe('closed'); - expect(duringEvents.map((event) => event.type)).toEqual(['runtime.mcp.restarting']); - } finally { - await duringRegistry.close().catch(() => undefined); - await duringFixture.close(); - } - } finally { - await registry.close().catch(() => undefined); - await fixture.close(); - } -}); - -it('does not emit activation restarts for opening, implementation-only commits, failed staging, or a close race', async () => { - const fixture = await createGenerationStore(); - const events: DevRuntimeEventInput[] = []; - const registry = createRegistry({ events, store: fixture.store }); - const failingEvents: DevRuntimeEventInput[] = []; - const failing = createRegistry({ - connector: connectorHarness({ failAfter: 2 }).connector, - events: failingEvents, - store: fixture.store, - }); - try { - await fixture.commit('g1'); - await fixture.commit('g2'); - await fixture.commit('g3'); - await registry.open({ serverName: 'timeline', target: 'portable' }); - expect(events).toEqual([]); - - const implementation = registry.commitActivationReconcile(await registry.prepareActivationReconcile(registryInput({ - runtimeGenerationId: 'g2', - serverDigest: 'implementation-only', - }))); - expect(events).toEqual([]); - implementation.publish(); - expect(events).toEqual([]); - - await failing.open({ serverName: 'timeline', target: 'portable' }); - await expect(failing.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g2', - }))).rejects.toThrow('fixture connector failed'); - expect(failingEvents).toEqual([]); - - const prepared = await registry.prepareActivationReconcile(registryInput({ - definitionDigest: 'definition-2', - runtimeGenerationId: 'g3', - })); - const closing = registry.close(); - expect(() => registry.commitActivationReconcile(prepared)).toThrow('closed'); - await closing; - expect(events).toEqual([]); - } finally { - await Promise.all([ - registry.close().catch(() => undefined), - failing.close().catch(() => undefined), - ]); - await fixture.close(); - } -}); diff --git a/packages/agent-bundle/tests/runtime-provider.test.ts b/packages/agent-bundle/tests/runtime-provider.test.ts index 8bef653b8..890382013 100644 --- a/packages/agent-bundle/tests/runtime-provider.test.ts +++ b/packages/agent-bundle/tests/runtime-provider.test.ts @@ -7,14 +7,11 @@ import { expect, it } from '@rstest/core'; import { DevRuntimeController, DevRuntimeGenerationConflictError, - RuntimeMcpRegistry, DevRuntimeUnavailableError, type DevRuntimeControllerOptions, type DevRuntimeProvider, type DevRuntimePreparedProject, type DevRuntimeSession, - type DevRuntimeMcpSessionBinding, - type DevRuntimeMcpRegistry, type DevRuntimeRun, type DevRuntimeSurface, } from '../src/dev/index.ts'; @@ -71,19 +68,6 @@ const surface = { targets: ['claude', 'codex'], } satisfies DevRuntimeSurface; -const binding = { - definitionDigest: 'definition-a', - providerSessionId: 'provider-a', - registryRevision: 3, - serverDigest: 'server-a', - serverName: 'timeline', - sessionId: 'mcp-a', - sessionRevision: 2, - stateStoreId: 'fixture-a', - target: 'portable', - transportDigest: 'transport-a', -} satisfies DevRuntimeMcpSessionBinding; - const run = { completedAt: '2026-08-15T00:00:01.000Z', id: 'run-a', @@ -129,23 +113,9 @@ const targetlessSurface = { // @ts-expect-error Every browser surface must explicitly declare its supported targets. const targetfulSurface: DevRuntimeSurface = targetlessSurface; -const incompleteBinding = { - providerSessionId: 'provider-a', - serverName: 'timeline', - sessionId: 'mcp-a', - stateStoreId: 'fixture-a', - target: 'portable', -} satisfies Pick< - DevRuntimeMcpSessionBinding, - 'providerSessionId' | 'serverName' | 'sessionId' | 'stateStoreId' | 'target' ->; - -// @ts-expect-error Stable MCP bindings include registry/session revisions and all three digests. -const completeBinding: DevRuntimeMcpSessionBinding = incompleteBinding; - // The satisfies/@ts-expect-error declarations above are the contract checks; // they need no runtime test to compile. -void [binding, jsonOnlyRun, targetfulSurface, completeBinding]; +void [jsonOnlyRun, targetfulSurface]; it('uses stable errors for unavailable and stale runtime generations', () => { const unavailable = new DevRuntimeUnavailableError(); @@ -168,7 +138,6 @@ it('starts one provider from the trusted prepared snapshot with only declared en const events: unknown[] = []; const session = { close: async () => undefined, - mcpRegistry: {}, providerSessionId: 'upstream-provider-session', status: () => ({ descriptor: { environmentVariables: ['RUNTIME_TOKEN'], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 }, @@ -219,17 +188,8 @@ it('refreshes controller endpoint snapshots before publishing a later runtime ac let activated = false; let emit: Parameters[0]['emit'] | undefined; const observedEvents: Array> = []; - const endpoint = { - entryPath: '/', - httpOrigin: 'http://127.0.0.1:43111', - httpPathPrefixes: ['/'], - subscribeReload: () => () => undefined, - surfaceId: surface.id, - }; const session = { - clientSurface: () => activated ? endpoint : undefined, close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => activated ? { activeVector: vector, descriptor, diagnostics: [], hmrReady: true, lastGoodVector: vector, state: 'active' as const } @@ -258,7 +218,6 @@ it('refreshes controller endpoint snapshots before publishing a later runtime ac expect(controller.status()).toMatchObject({ hmrReady: false, state: 'compiling' }); expect(controller.status()).not.toHaveProperty('activeVector'); expect(controller.surfaces()).toEqual([]); - expect(controller.clientSurface(surface.id)).toBeUndefined(); activated = true; emit?.({ runtimeGenerationId: vector.runtimeGenerationId, type: 'runtime.generation.activated' }); @@ -272,7 +231,6 @@ it('refreshes controller endpoint snapshots before publishing a later runtime ac expect(controller.surfaces()).toEqual([surface]); expect(Object.isFrozen(controller.status())).toBe(true); expect(Object.isFrozen(controller.surfaces())).toBe(true); - expect(controller.clientSurface(surface.id)).toEqual(endpoint); expect(observedEvents).toEqual([{ state: 'active', surfaceCount: 1, type: 'runtime.generation.activated' }]); await controller.close(); @@ -298,7 +256,6 @@ it('refreshes authoritative failed and status snapshots before forwarding their }>> = []; const session = { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => malformed ? { activeVector: { runtimeGenerationId: 7 }, descriptor, diagnostics: [], hmrReady: true, state: 'active' as const } @@ -371,7 +328,6 @@ it('refreshes terminal run snapshots before completed or failed events without r const observed: Array> = []; const session = { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => { const current = Object.freeze({ ...vector, stateVersion }); @@ -439,7 +395,6 @@ it('does not overwrite a controller-owned lifecycle failure while publishing its descriptor, start: async () => ({ close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => { throw new Error('Reconcile failed.'); }, status: () => ({ activeVector: vector, descriptor, diagnostics: [], hmrReady: true, lastGoodVector: vector, state: 'active' as const }), surfaces: () => [surface], @@ -500,7 +455,6 @@ it('detaches and freezes complete activation status and surface snapshots', asyn emit = context.emit; return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => activated ? { @@ -596,7 +550,6 @@ it('degrades instead of publishing malformed activation snapshots', async () => emit = context.emit; return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => activated ? invalid.status @@ -640,7 +593,6 @@ it('accepts acyclic shared JSON fragments in activation snapshots', async () => emit = context.emit; return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => activated ? { activeVector: vector, descriptor, diagnostics: [], hmrReady: true, state: 'active' as const } @@ -707,7 +659,6 @@ it('buffers synchronous startup failure and status until controller snapshots in context.emit(Object.freeze({ details: Object.freeze({ sequence: 'latest' }), type: 'runtime.status' })); return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => ({ descriptor, diagnostics: [sourceBuildDiagnostic], hmrReady: true, state: 'degraded' as const }), surfaces: () => [surface], @@ -750,7 +701,6 @@ it('buffers synchronous startup activation until controller snapshots install', context.emit({ runtimeGenerationId: vector.runtimeGenerationId, type: 'runtime.generation.activated' }); return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => ({ activeVector: vector, descriptor, diagnostics: [], hmrReady: true, lastGoodVector: vector, state: 'active' as const }), surfaces: () => [surface], @@ -801,7 +751,6 @@ it('drops buffered startup lifecycle events after close or topology failure', as : controller.reconcileDeclaration(undefined); resolveSession?.({ close: async () => { closeCalls += 1; }, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => ({ activeVector: vector, descriptor, diagnostics: [], hmrReady: true, state: 'active' }), surfaces: () => [surface], @@ -834,7 +783,6 @@ it('sanitizes a failed activation refresh without recursively publishing runtime emit = context.emit; return { close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => ({ descriptor, diagnostics: [], hmrReady: activated, state: activated ? 'active' as const : 'compiling' as const }), surfaces: () => { @@ -958,7 +906,6 @@ it('reconciles the newest revision exactly once after a deferred provider start }); resolveSession?.({ close: async () => undefined, - mcpRegistry: {}, reconcilePreparedRuntime: async (prepared: DevRuntimePreparedProject) => { reconciled.push(prepared.sourceRevision); }, status: () => ({ descriptor: { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: 'active' }), surfaces: () => [], @@ -969,23 +916,8 @@ it('reconciles the newest revision exactly once after a deferred provider start await controller.close(); }); -it('latches declaration topology failures and revokes registry capabilities captured while active', async () => { - let executions = 0; - const view = { - execute: async () => { executions += 1; return {}; }, - snapshot: () => ({}), - watchClosed: () => ({ closed: false, unsubscribe: () => undefined }), - }; - const registry = { - close: async () => undefined, - closeSession: async () => undefined, - open: async () => ({ ...view, close: async () => undefined }), - reconcile: async () => ({}), - restart: async () => ({}), - session: () => view, - snapshot: () => undefined, - subscribe: () => ({ unsubscribe: () => undefined }), - }; +it('latches a provider path change on an active session and revokes its run capabilities', async () => { + const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; const controller = new DevRuntimeController({ artifactStatus: () => ({ state: 'missing' }), emit: () => undefined, @@ -993,23 +925,18 @@ it('latches declaration topology failures and revokes registry capabilities capt preparedRuntime: { apps: [], provider: './src/dev/provider.ts', servers: [], sourceRevision: 'source-1' }, projectRoot: '/workspace/project', provider: { - descriptor: { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 }, + descriptor, start: async () => ({ close: async () => undefined, - mcpRegistry: registry, - status: () => ({ descriptor: { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 }, diagnostics: [], hmrReady: true, state: 'active' }), + runs: () => [], + status: () => ({ descriptor, diagnostics: [], hmrReady: true, state: 'active' }), surfaces: () => [], }) as unknown as DevRuntimeSession, }, storageRoot: '/workspace/project/.agent-bundle/runtime', }); await controller.start(); - const capturedRegistry = controller.mcpRegistry; - const capturedView = capturedRegistry.session('mcp-a'); - if (capturedView === undefined) throw new Error('Expected a captured runtime MCP view.'); - const capturedSession = await capturedRegistry.open({ serverName: 'timeline', target: 'portable' }); - await capturedView.execute({ expectedSessionRevision: 1, kind: 'list-tools' }); - expect(executions).toBe(1); + expect(controller.runs(1)).toEqual([]); await controller.reconcileDeclaration({ apps: [], @@ -1019,129 +946,7 @@ it('latches declaration topology failures and revokes registry capabilities capt }); expect(controller.status()).toMatchObject({ state: 'failed' }); - await expect(capturedRegistry.open({ serverName: 'timeline', target: 'portable' })).rejects.toMatchObject({ code: 'AB8201' }); - await expect(capturedView.execute({ expectedSessionRevision: 1, kind: 'list-tools' })).rejects.toMatchObject({ code: 'AB8201' }); - await expect(capturedSession.close()).rejects.toMatchObject({ code: 'AB8201' }); - expect(() => capturedView.snapshot()).toThrow(DevRuntimeUnavailableError); - await controller.close(); -}); - -it('preserves private registry and session receivers through the stable MCP facade', async () => { - const descriptor = { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 } as const; - const prepared = { apps: [], provider: './src/dev/provider.ts', servers: [], sourceRevision: 'source-1' } as const; - const status = () => ({ descriptor, diagnostics: [], hmrReady: true, state: 'active' as const }); - const controllerFor = (mcpRegistry: DevRuntimeMcpRegistry): DevRuntimeController => new DevRuntimeController({ - artifactStatus: () => ({ state: 'missing' }), - emit: () => undefined, - environment: {}, - preparedRuntime: prepared, - projectRoot: '/workspace/project', - provider: { - descriptor, - start: async () => ({ - close: async () => undefined, - mcpRegistry, - status, - surfaces: () => [], - }) as unknown as DevRuntimeSession, - }, - storageRoot: '/workspace/project/.agent-bundle/runtime', - }); - const actualRegistry = new RuntimeMcpRegistry({ - artifactEpochId: () => undefined, - connector: { connect: async () => { throw new Error('Connection is not needed by this receiver test.'); } } as never, - emit: () => undefined, - executor: async () => ({}) as never, - generationStore: {} as never, - initialRegistry: { definitionDigest: 'definition-1', runtimeGenerationId: 'generation-1', servers: [], transportDigest: 'transport-1' }, - providerSessionId: 'provider-1', - stateStoreId: 'state-1', - }); - const actualController = controllerFor(actualRegistry); - await actualController.start(); - expect(actualController.mcpRegistry.snapshot()).toMatchObject({ - providerSessionId: 'provider-1', - registryRevision: 1, - }); - await actualController.close(); - - class PrivateSession { - #closed = false; - #executions = 0; - - async close(): Promise { - this.#closed = true; - } - - async execute(): Promise { - if (this.#closed) throw new Error('Private MCP session is closed.'); - this.#executions += 1; - return { executions: this.#executions }; - } - - snapshot(): unknown { - return { closed: this.#closed, executions: this.#executions }; - } - - watchClosed(): unknown { - return { closed: this.#closed, unsubscribe: () => undefined }; - } - } - - class PrivateRegistry { - #calls: string[] = []; - #session = new PrivateSession(); - - get calls(): readonly string[] { - return this.#calls; - } - - async close(): Promise { this.#calls.push('close'); } - async closeSession(): Promise { this.#calls.push('closeSession'); } - async open(): Promise { this.#calls.push('open'); return this.#session; } - async reconcile(): Promise { this.#calls.push('reconcile'); return {}; } - async restart(): Promise { this.#calls.push('restart'); return {}; } - session(): PrivateSession { this.#calls.push('session'); return this.#session; } - snapshot(): unknown { this.#calls.push('snapshot'); return { registry: 'private' }; } - subscribe(): unknown { this.#calls.push('subscribe'); return { unsubscribe: () => undefined }; } - } - - const privateRegistry = new PrivateRegistry(); - const controller = controllerFor(privateRegistry as unknown as DevRuntimeMcpRegistry); - await controller.start(); - const facade = controller.mcpRegistry; - expect(facade.snapshot()).toEqual({ registry: 'private' }); - expect(facade.subscribe({}, () => undefined)).toEqual({ unsubscribe: expect.any(Function) }); - const view = facade.session('class-session'); - if (view === undefined) throw new Error('Expected private MCP session view.'); - await expect(view.execute({ expectedSessionRevision: 1, kind: 'list-tools' })).resolves.toEqual({ executions: 1 }); - expect(view.snapshot()).toEqual({ closed: false, executions: 1 }); - expect(view.watchClosed(() => undefined)).toEqual({ closed: false, unsubscribe: expect.any(Function) }); - const opened = await facade.open({ serverName: 'timeline', target: 'portable' }); - await expect(opened.execute({ expectedSessionRevision: 1, kind: 'list-tools' })).resolves.toEqual({ executions: 2 }); - await expect(opened.close()).resolves.toBeUndefined(); - await expect(facade.closeSession({ expectedSessionRevision: 1, sessionId: 'class-session' })).resolves.toBeUndefined(); - await expect(facade.reconcile({ definitionDigest: 'definition-2', runtimeGenerationId: 'generation-2', servers: [], transportDigest: 'transport-2' })).resolves.toEqual({}); - await expect(facade.restart({ expectedSessionRevision: 1, sessionId: 'class-session' })).resolves.toEqual({}); - await expect(facade.close()).resolves.toBeUndefined(); - expect(privateRegistry.calls).toEqual([ - 'snapshot', - 'subscribe', - 'session', - 'session', - 'session', - 'session', - 'open', - 'closeSession', - 'reconcile', - 'restart', - 'close', - ]); - - await controller.reconcileDeclaration({ ...prepared, provider: './src/dev/replaced-provider.ts', sourceRevision: 'source-2' }); - await expect(facade.open({ serverName: 'timeline', target: 'portable' })).rejects.toMatchObject({ code: 'AB8201' }); - await expect(view.execute({ expectedSessionRevision: 1, kind: 'list-tools' })).rejects.toMatchObject({ code: 'AB8201' }); - await expect(opened.close()).rejects.toMatchObject({ code: 'AB8201' }); + expect(() => controller.runs(1)).toThrow(DevRuntimeUnavailableError); await controller.close(); }); @@ -1176,7 +981,6 @@ it('latches every topology failure across a pending runtime start', async () => await controller.reconcilePreparedRuntime({ ...prepared, sourceRevision: 'source-3' }); resolveSession?.({ close: async () => { closeCalls += 1; }, - mcpRegistry: {}, reconcilePreparedRuntime: async () => undefined, status: () => ({ descriptor, diagnostics: [], hmrReady: true, state: 'active' }), surfaces: () => [], @@ -1184,7 +988,7 @@ it('latches every topology failure across a pending runtime start', async () => await starting; expect(controller.status(), topology.name).toMatchObject({ state: 'failed' }); - await expect(controller.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }), topology.name).rejects.toMatchObject({ code: 'AB8201' }); + expect(() => controller.runs(1), topology.name).toThrow(DevRuntimeUnavailableError); await controller.close(); expect(closeCalls, topology.name).toBe(1); } @@ -1209,7 +1013,6 @@ it('retains a topology failure when it races an accepted runtime reconcile', asy emit = context.emit; return { close: async () => { closeCalls += 1; }, - mcpRegistry: {}, reconcilePreparedRuntime: async () => reconcileGate, status: () => ({ descriptor, diagnostics: [], hmrReady: true, state: 'active' }), surfaces: () => [], @@ -1219,7 +1022,6 @@ it('retains a topology failure when it races an accepted runtime reconcile', asy storageRoot: '/workspace/project/.agent-bundle/runtime', }); await controller.start(); - const capturedRegistry = controller.mcpRegistry; const reconciling = controller.reconcilePreparedRuntime({ ...prepared, sourceRevision: 'source-2' }); await new Promise((resolvePromise) => setImmediate(resolvePromise)); await controller.reconcileDeclaration(undefined); @@ -1228,7 +1030,7 @@ it('retains a topology failure when it races an accepted runtime reconcile', asy await reconciling; expect(controller.status()).toMatchObject({ state: 'failed' }); - await expect(capturedRegistry.open({ serverName: 'timeline', target: 'portable' })).rejects.toMatchObject({ code: 'AB8201' }); + expect(() => controller.runs(1)).toThrow(DevRuntimeUnavailableError); await controller.close(); expect(closeCalls).toBe(1); }); diff --git a/packages/agent-bundle/tests/runtime-routes.test.ts b/packages/agent-bundle/tests/runtime-routes.test.ts index 3f135e17e..e1a5610e6 100644 --- a/packages/agent-bundle/tests/runtime-routes.test.ts +++ b/packages/agent-bundle/tests/runtime-routes.test.ts @@ -73,7 +73,6 @@ const runtimeStatus = Object.freeze({ } satisfies DevRuntimeStatus); class MemoryRuntime implements DevRuntimeSession { - readonly mcpRegistry = {} as DevRuntimeSession['mcpRegistry']; readonly invocations: unknown[] = []; readonly providerSessionId: string = 'provider-a'; readonly #flight: Uint8Array; @@ -83,7 +82,6 @@ class MemoryRuntime implements DevRuntimeSession { this.#flight = flight; } - clientSurface(): undefined { return undefined; } async close(): Promise {} async invoke(request: Parameters[0]): Promise { this.invocations.push(request); diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index 5530894bb..10ef5dc1f 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -248,36 +248,10 @@ const traceSpan = (value: unknown): DevRuntimeTraceSpan => { const inspection = (value: unknown, runId: string): DevRuntimeInspectionEnvelope => { const response = record(value); - if (!hasOnly(response, ['agentVisible', 'app', 'flight', 'modelVisible', 'native', 'protocol', 'state', 'trace', 'tree']) || + if (!hasOnly(response, ['agentVisible', 'flight', 'modelVisible', 'native', 'protocol', 'state', 'trace', 'tree']) || !isRecord(response.state) || !hasOnly(response.state, ['identity', 'snapshot']) || !Array.isArray(response.trace) || !Array.isArray(response.tree)) { throw invalid('Runtime route returned an invalid inspection envelope.'); } - const app = response.app === undefined ? undefined : record(response.app, 'Runtime route returned an invalid App inspection.'); - let appSnapshot: DevRuntimeInspectionEnvelope['app']; - if (app !== undefined) { - const binding = record(app.mcpBinding, 'Runtime route returned an invalid App inspection.'); - if (!hasOnly(app, ['mcpBinding', 'resourceUri', 'surfaceId']) || - !hasOnly(binding, ['definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest']) || - !nonemptyString(app.resourceUri) || !nonemptyString(app.surfaceId) || !nonemptyString(binding.definitionDigest) || - !nonnegativeInteger(binding.registryRevision) || !nonemptyString(binding.serverDigest) || !nonemptyString(binding.serverName) || - !nonemptyString(binding.sessionId) || !nonnegativeInteger(binding.sessionRevision) || !nonemptyString(binding.target) || !nonemptyString(binding.transportDigest)) { - throw invalid('Runtime route returned an invalid App inspection.'); - } - appSnapshot = Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: binding.definitionDigest, - registryRevision: binding.registryRevision, - serverDigest: binding.serverDigest, - serverName: binding.serverName, - sessionId: binding.sessionId, - sessionRevision: binding.sessionRevision, - target: binding.target, - transportDigest: binding.transportDigest, - }), - resourceUri: app.resourceUri, - surfaceId: app.surfaceId, - }); - } const flight = response.flight === undefined ? undefined : record(response.flight, 'Runtime route returned an invalid Flight inspection.'); if (flight !== undefined && (!hasOnly(flight, ['bytes', 'downloadPath', 'preview', 'truncated']) || !nonnegativeInteger(flight.bytes) || (flight.downloadPath !== undefined && !nonemptyString(flight.downloadPath)) || !nonemptyString(flight.preview) || typeof flight.truncated !== 'boolean')) { @@ -289,7 +263,6 @@ const inspection = (value: unknown, runId: string): DevRuntimeInspectionEnvelope } return Object.freeze({ ...(response.agentVisible === undefined ? {} : { agentVisible: jsonValue(response.agentVisible) }), - ...(appSnapshot === undefined ? {} : { app: appSnapshot }), ...(flight === undefined ? {} : { flight: Object.freeze({ bytes: flight.bytes as number, downloadPath: flightDownloadPath, diff --git a/packages/workbench/tests/lifecycles.e2e.test.ts b/packages/workbench/tests/lifecycles.e2e.test.ts index 8afce65c7..eb0abdd83 100644 --- a/packages/workbench/tests/lifecycles.e2e.test.ts +++ b/packages/workbench/tests/lifecycles.e2e.test.ts @@ -98,6 +98,39 @@ e2e( } return (body as { readonly token: string }).token; }); + // The example's MCP server is a prebuilt entry, so the Application tree + // has no tool leaf for it; the runtime run route is its host boundary. + const runtimeRun = async (request: Readonly>) => page.evaluate(async ({ body, token }) => { + const response = await fetch('/api/runtime/runs', { + body: JSON.stringify(body), + credentials: 'same-origin', + headers: { 'content-type': 'application/json', 'x-agent-bundle-session': token }, + method: 'POST', + }); + if (!response.ok) throw new Error(`Runtime run failed with ${String(response.status)}.`); + return (await response.json() as { readonly run: { readonly result?: Record; readonly status: string } }).run; + }, { body: request, token: sessionToken }); + const hookRun = await runtimeRun({ + input: { + cwd: '/tmp', + hook_event_name: 'PostToolUse', + session_id: 'lifecycle-runtime-kernel', + tool_input: { file_path: 'runtime-kernel.txt' }, + tool_name: 'Write', + tool_use_id: 'lifecycle-runtime-kernel-write', + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + expect(hookRun.status).toBe('succeeded'); + const timeline = await runtimeRun({ input: {}, surfaceId: 'mcp.render_edit_timeline', target: 'portable' }); + expect(timeline.status).toBe('succeeded'); + expect(Object.keys(timeline.result ?? {}).sort()).toEqual(['flight', 'modelVisible', 'protocol', 'state', 'trace', 'tree']); + expect(timeline.result?.['protocol']).toMatchObject({ + structuredContent: { + edits: expect.arrayContaining([expect.objectContaining({ host: 'claude', path: '/tmp/runtime-kernel.txt' })]), + }, + }); const manifestDigest = async (): Promise => page.evaluate(async (token) => { const response = await fetch('/api/lifecycles', { credentials: 'same-origin', diff --git a/packages/workbench/tests/runtime-contract-compile.test.ts b/packages/workbench/tests/runtime-contract-compile.test.ts index 689ffbcda..de29b91bd 100644 --- a/packages/workbench/tests/runtime-contract-compile.test.ts +++ b/packages/workbench/tests/runtime-contract-compile.test.ts @@ -83,20 +83,6 @@ const state = { stateStoreId: 'state-a', stateVersion: 1 } satisfies DevRuntimeS const inspection = { agentVisible: { summary: 'Sunny' }, - app: { - mcpBinding: { - definitionDigest: 'definition-a', - registryRevision: 1, - serverDigest: 'server-a', - serverName: 'weather', - sessionId: 'session-a', - sessionRevision: 1, - target: 'portable', - transportDigest: 'transport-a', - }, - resourceUri: 'ui://weather/app.html', - surfaceId: 'app/weather', - }, modelVisible: { summary: 'Sunny' }, native: { status: 200 }, protocol: { jsonrpc: '2.0' }, diff --git a/packages/workbench/tests/runtime-model.test.ts b/packages/workbench/tests/runtime-model.test.ts index 9f2e02ed4..ba8e5c10f 100644 --- a/packages/workbench/tests/runtime-model.test.ts +++ b/packages/workbench/tests/runtime-model.test.ts @@ -484,7 +484,7 @@ it('rejects a stale provider run injected into a fresh-provider bootstrap', () = it('handles foreign and run lifecycle events through browser-only effects', () => { const foreign = reduce(model(), { event: event(1, 'runtime.status', undefined, 'provider-b', 'generation-b'), type: 'event.received' }); const started = reduce(model(), { event: event(1, 'runtime.run.started', undefined, 'provider-a', 'generation-a'), type: 'event.received' }); - const generic = reduce(model(), { event: event(1, 'runtime.mcp.ready'), type: 'event.received' }); + const generic = reduce(model(), { event: event(1, 'runtime.generation.compiling'), type: 'event.received' }); expect(effectFor(foreign)).toMatchObject({ kind: 'bootstrap', triggerSequence: 1 }); expect(effectFor(started)).toMatchObject({ kind: 'bootstrap', triggerSequence: 1 }); @@ -576,25 +576,11 @@ it('covers runtime reducer invalid controls, ordered read effects, and settled l expect(() => reduce(model(), { input: Number.NaN as unknown as JsonValue, raw: 'NaN', type: 'draft.replace' })).toThrow(/finite/i); }); -it('parses optional App inspection evidence and rejects every provider envelope layer through public routes', async () => { +it('parses optional Flight inspection evidence and rejects every provider envelope layer through public routes', async () => { const original = run('app-evidence'); if (original.status !== 'succeeded') throw new Error('Expected succeeded fixture.'); const appResult = { ...original.result, - app: { - mcpBinding: { - definitionDigest: 'definition-a', - registryRevision: 1, - serverDigest: 'server-a', - serverName: 'weather', - sessionId: 'session-a', - sessionRevision: 1, - target: 'portable', - transportDigest: 'transport-a', - }, - resourceUri: 'ui://weather/app.html', - surfaceId: 'weather', - }, flight: { bytes: 8, downloadPath: '/api/runtime/runs/app-evidence/flight', preview: 'flight', truncated: false }, }; const withApp = { @@ -609,7 +595,7 @@ it('parses optional App inspection evidence and rejects every provider envelope }); await appClient.bootstrap(); await expect(appClient.createRun({ input: {}, surfaceId: 'weather', target: 'portable' })) - .resolves.toMatchObject({ result: { app: { resourceUri: 'ui://weather/app.html' } } }); + .resolves.toMatchObject({ result: { flight: { downloadPath: '/api/runtime/runs/app-evidence/flight' } } }); const invalidBootstrap = async (statusBody: unknown, surfacesBody: unknown = { surfaces: [surface()] }, runsBody: unknown = { providerSessionId: 'provider-a', runs: [run('base')] }): Promise => { await expect(clientFor({ @@ -637,7 +623,7 @@ it('parses optional App inspection evidence and rejects every provider envelope await invalidRun({ ...run('bad-run'), status: 'unknown' }); await invalidRun({ ...withApp, result: { ...appResult, tree: [{ children: [], id: '', kind: 'component', label: 'bad' }] } }); await invalidRun({ ...withApp, result: { ...appResult, trace: [{ id: 'trace', phase: 'rsc', startedAt: 'no-date', status: 'succeeded' }] } }); - await invalidRun({ ...withApp, result: { ...appResult, app: { ...appResult.app, resourceUri: '' } } }); + await invalidRun({ ...withApp, result: { ...appResult, app: { resourceUri: 'ui://weather/app.html' } } }); await invalidRun({ ...withApp, result: { ...appResult, flight: { bytes: -1, preview: 'flight', truncated: false } } }); const invalidStateClient = clientFor({ '/api/runtime/runs?limit=50': { providerSessionId: 'provider-a', runs: [run('initial')] }, @@ -792,11 +778,11 @@ it('never applies runtime events at or below the replay-gap recovery watermark', const gap = { earliestAvailableSequence: 9, latestDroppedSequence: 8, requestedAfterSequence: 1, type: 'replay.gap' } as const; const recovered = reduce(model(), { event: gap, type: 'event.received' }); const stale = reduce(recovered, { - event: event(7, 'runtime.mcp.ready'), + event: event(7, 'runtime.generation.compiling'), type: 'event.received', }); const next = reduce(stale, { - event: event(9, 'runtime.mcp.ready'), + event: event(9, 'runtime.generation.compiling'), type: 'event.received', }); diff --git a/scripts/rsc-runtime-topology.mjs b/scripts/rsc-runtime-topology.mjs index b5858ed73..d5bc7532d 100644 --- a/scripts/rsc-runtime-topology.mjs +++ b/scripts/rsc-runtime-topology.mjs @@ -69,7 +69,6 @@ const agentBundleDev = new Set([ 'packages/agent-bundle/src/dev/runtime-app-message-limits.ts', 'packages/agent-bundle/src/dev/runtime-controller.ts', 'packages/agent-bundle/src/dev/runtime-generation-store.ts', - 'packages/agent-bundle/src/dev/runtime-mcp-registry.ts', 'packages/agent-bundle/src/dev/runtime-provider-loader.ts', 'packages/agent-bundle/src/dev/runtime-provider.ts', 'packages/agent-bundle/src/dev/runtime-routes.ts', diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 7bbaacff5..5f7732f50 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -248,7 +248,7 @@ Development-only settings that never become part of a built artifact. | `dev.agentApi` | Exposes the authenticated, loopback-only Agent API from `agent-bundle dev`. The `--agent-api` / `--no-agent-api` flags override it. | | `dev.contracts.fixtures` | **Required when `dev.contracts` is set.** Project-relative module whose default export maps route ids to contract fixtures. Declaring `dev.contracts` switches `agent-bundle dev` from adopting every epoch directly to gating host-facing adoption on the development contract matrix: an epoch whose checks fail still publishes to the Workbench route workspace, but live host connections and development installs keep the last passing epoch (`AB7211`). A malformed block, a fixtures module that escapes the project root, cannot load, or exports the wrong shape is `AB7210`. | | `dev.contracts.server` | The MCP server the matrix checks. Optional only when the project compiles exactly one server. | -| `dev.runtime.provider` | The development runtime provider module: it exports `createDevRuntimeProvider` ([`CreateDevRuntimeProvider`](../api/api.md#createdevruntimeprovider)), returning a [`DevRuntimeProvider`](../api/api.md#devruntimeprovider) whose `start` takes a [`DevRuntimeStartContext`](../api/api.md#devruntimestartcontext) and returns a [`DevRuntimeSession`](../api/api.md#devruntimesession). The whole protocol a provider implements ships from `agent-bundle/api`, the inspection envelope ([`DevRuntimeInspectionEnvelope`](../api/api.md#devruntimeinspectionenvelope)), the MCP server descriptor ([`DevRuntimeMcpServerDescriptor`](../api/api.md#devruntimemcpserverdescriptor)), the errors it throws ([`DevRuntimeUnavailableError`](../api/api.md#devruntimeunavailableerror), [`DevRuntimeGenerationConflictError`](../api/api.md#devruntimegenerationconflicterror)), and the generation store and MCP registry a session drives, as contracts ([`DevRuntimeGenerationStore`](../api/api.md#devruntimegenerationstore), [`DevRuntimeProviderMcpRegistry`](../api/api.md#devruntimeprovidermcpregistry)) with their constructors ([`createRuntimeGenerationStore`](../api/api.md#createruntimegenerationstore), [`createRuntimeMcpRegistry`](../api/api.md#createruntimemcpregistry)). | +| `dev.runtime.provider` | The development runtime provider module: it exports `createDevRuntimeProvider` ([`CreateDevRuntimeProvider`](../api/api.md#createdevruntimeprovider)), returning a [`DevRuntimeProvider`](../api/api.md#devruntimeprovider) whose `start` takes a [`DevRuntimeStartContext`](../api/api.md#devruntimestartcontext) and returns a [`DevRuntimeSession`](../api/api.md#devruntimesession). The whole protocol a provider implements ships from `agent-bundle/api`, the inspection envelope ([`DevRuntimeInspectionEnvelope`](../api/api.md#devruntimeinspectionenvelope)), the errors it throws ([`DevRuntimeUnavailableError`](../api/api.md#devruntimeunavailableerror), [`DevRuntimeGenerationConflictError`](../api/api.md#devruntimegenerationconflicterror)), and the generation store a session drives, as a contract ([`DevRuntimeGenerationStore`](../api/api.md#devruntimegenerationstore)) with its constructor ([`createRuntimeGenerationStore`](../api/api.md#createruntimegenerationstore)). | ## web diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 1bd39c94c..681765688 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -218,7 +218,7 @@ export default defineConfig({ | `dev.agentApi` | 从 `agent-bundle dev` 暴露那个经过认证、仅 loopback 的 Agent API。`--agent-api` / `--no-agent-api` 标志会覆盖它。 | | `dev.contracts.fixtures` | **设置了 `dev.contracts` 时必填。** 项目相对路径的模块,其默认导出把路由 id 映射到契约夹具。声明 `dev.contracts` 会让 `agent-bundle dev` 从直接采用每个 epoch,改为以开发契约矩阵门控面向宿主的采用:检查失败的 epoch 仍会发布到 Workbench 路由工作区,但活跃的宿主连接与开发安装会保留最后一个通过的 epoch(`AB7211`)。块本身格式错误、夹具模块逃出项目根、无法加载或导出了错误的形状,则是 `AB7210`。 | | `dev.contracts.server` | 矩阵要检查的 MCP 服务器。仅当项目恰好编译一个服务器时可省略。 | -| `dev.runtime.provider` | 开发期运行时 provider 模块:它导出 `createDevRuntimeProvider`([`CreateDevRuntimeProvider`](../api/api.md#createdevruntimeprovider)),返回一个 [`DevRuntimeProvider`](../api/api.md#devruntimeprovider),其 `start` 接收 [`DevRuntimeStartContext`](../api/api.md#devruntimestartcontext) 并返回 [`DevRuntimeSession`](../api/api.md#devruntimesession)。provider 需要实现的整个协议都从 `agent-bundle/api` 发布,检视信封([`DevRuntimeInspectionEnvelope`](../api/api.md#devruntimeinspectionenvelope))、MCP 服务器描述符([`DevRuntimeMcpServerDescriptor`](../api/api.md#devruntimemcpserverdescriptor))、它要抛出的错误([`DevRuntimeUnavailableError`](../api/api.md#devruntimeunavailableerror)、[`DevRuntimeGenerationConflictError`](../api/api.md#devruntimegenerationconflicterror)),以及会话所驱动的 generation store 与 MCP registry,以契约([`DevRuntimeGenerationStore`](../api/api.md#devruntimegenerationstore)、[`DevRuntimeProviderMcpRegistry`](../api/api.md#devruntimeprovidermcpregistry))及其构造函数([`createRuntimeGenerationStore`](../api/api.md#createruntimegenerationstore)、[`createRuntimeMcpRegistry`](../api/api.md#createruntimemcpregistry))的形式提供。 | +| `dev.runtime.provider` | 开发期运行时 provider 模块:它导出 `createDevRuntimeProvider`([`CreateDevRuntimeProvider`](../api/api.md#createdevruntimeprovider)),返回一个 [`DevRuntimeProvider`](../api/api.md#devruntimeprovider),其 `start` 接收 [`DevRuntimeStartContext`](../api/api.md#devruntimestartcontext) 并返回 [`DevRuntimeSession`](../api/api.md#devruntimesession)。provider 需要实现的整个协议都从 `agent-bundle/api` 发布,检视信封([`DevRuntimeInspectionEnvelope`](../api/api.md#devruntimeinspectionenvelope))、它要抛出的错误([`DevRuntimeUnavailableError`](../api/api.md#devruntimeunavailableerror)、[`DevRuntimeGenerationConflictError`](../api/api.md#devruntimegenerationconflicterror)),以及会话所驱动的 generation store,以契约([`DevRuntimeGenerationStore`](../api/api.md#devruntimegenerationstore))及其构造函数([`createRuntimeGenerationStore`](../api/api.md#createruntimegenerationstore))的形式提供。 | ## web