From 5d342e6a5824766f2ae9867fd7fcf42d41b4357a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 05:00:36 +0000 Subject: [PATCH 1/3] refactor(dev)!: delete the runtime provider MCP contract Nothing in the host consumes a provider's MCP registry, clientSurface(), or run results' app binding since the runtime App path was removed (#852). Delete them from agent-bundle: the RuntimeMcpRegistry implementation and createRuntimeMcpRegistry, DevRuntimeSession.mcpRegistry and clientSurface(), DevRuntimeInspectionEnvelope.app, the runtime.mcp.* events and their fields, and every DevRuntimeMcp*/RuntimeMcp* type. The Workbench runtime client now rejects an app field in run inspections. Rework examples/rsc-agent-runtime so its dev provider no longer hosts an in-process MCP registry or App client surface: activation commits only the generation store, generation metadata drops the registry descriptors and digests, and MCP tool runs no longer return an App binding. Hooks and MCP tools still share the durable kernel, which a new lifecycles e2e step checks end to end. --- .changeset/remove-runtime-provider-mcp.md | 5 + agent-patterns/effect-errors.md | 4 +- docs/architecture/rsc-runtime-workbench.md | 12 +- docs/effect-conventions.md | 3 +- examples/rsc-agent-runtime/README.md | 14 +- examples/rsc-agent-runtime/rsbuild.config.ts | 93 +- .../src/dev/generation-materializer.ts | 158 +- .../src/dev/rsbuild-runtime-session.ts | 625 +------- .../src/runtime/contracts.ts | 24 +- .../tests/dev-invocation.integration.test.ts | 127 +- .../tests/dev-provider.integration.test.ts | 718 ++------- .../tests/generation-materializer.test.ts | 97 +- packages/agent-bundle/src/api.ts | 43 +- packages/agent-bundle/src/dev/index.ts | 36 - .../src/dev/runtime-controller.ts | 94 -- .../src/dev/runtime-mcp-registry.ts | 1163 -------------- .../agent-bundle/src/dev/runtime-protocol.ts | 120 -- .../agent-bundle/src/dev/runtime-provider.ts | 76 +- .../src/dev/runtime-store-contracts.ts | 99 +- .../src/dev/runtime-store-factories.ts | 17 +- packages/agent-bundle/src/dev/types.ts | 3 - .../agent-bundle/tests/dev-events.test.ts | 4 +- .../agent-bundle/tests/dev-workbench.test.ts | 16 - .../agent-bundle/tests/effect-errors.test.ts | 6 +- .../agent-bundle/tests/public-api.test.ts | 14 +- .../tests/runtime-generation-store.test.ts | 3 +- .../tests/runtime-mcp-registry.test.ts | 1398 ----------------- .../tests/runtime-provider.test.ts | 238 +-- .../agent-bundle/tests/runtime-routes.test.ts | 2 - packages/workbench/src/runtime-client.ts | 29 +- .../workbench/tests/lifecycles.e2e.test.ts | 33 + .../tests/runtime-contract-compile.test.ts | 14 - .../workbench/tests/runtime-model.test.ts | 26 +- scripts/rsc-runtime-topology.mjs | 1 - website/docs/en/reference/configuration.mdx | 2 +- website/docs/zh/reference/configuration.mdx | 2 +- 36 files changed, 315 insertions(+), 5004 deletions(-) create mode 100644 .changeset/remove-runtime-provider-mcp.md delete mode 100644 packages/agent-bundle/src/dev/runtime-mcp-registry.ts delete mode 100644 packages/agent-bundle/tests/runtime-mcp-registry.test.ts diff --git a/.changeset/remove-runtime-provider-mcp.md b/.changeset/remove-runtime-provider-mcp.md new file mode 100644 index 000000000..b6069ccef --- /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` drops 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. 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..4a80df0a9 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -80,15 +80,17 @@ 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 and exposes the hook and MCP +tool surfaces that Workbench routes invoke; it serves no browser client. 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..fcf6f4fff 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; @@ -699,7 +597,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 +604,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 +691,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 +706,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 +714,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 +737,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 +874,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 +1015,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 +1179,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 +1190,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 +1205,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 +1319,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 +1336,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 +1347,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 +1834,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 +2112,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 +2131,37 @@ 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) => { + // ponytail: a throwing test seam leaks this prepared activation; the public provider never sets it. + await this.#testing.afterActivationPrepare?.(Object.freeze({ session: this })); + return next; }), - (prepared) => this.#generationStore.abort(prepared), + (late) => this.#generationStore.abort(late), ); - 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), - ); - 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.#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 +2172,20 @@ 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'); } catch (error) { 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 +2196,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 +2222,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..3d1e30370 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,37 @@ 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({ - ...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; + 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' }); + 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'] }), + ])); + 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 +458,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 +926,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 +994,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 +1033,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 +1044,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 +1067,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 +1145,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 +1154,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 +1176,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 +1374,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 +1439,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 +1473,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 +1493,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 +1502,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 +1556,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 +1638,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 +1673,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 +1922,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..d05f82305 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,182 +916,6 @@ 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 }), - }; - const controller = new DevRuntimeController({ - artifactStatus: () => ({ state: 'missing' }), - emit: () => undefined, - environment: {}, - 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 }, - start: async () => ({ - close: async () => undefined, - mcpRegistry: registry, - status: () => ({ descriptor: { environmentVariables: [], id: 'fixture-runtime', label: 'Fixture runtime', schemaVersion: 1 }, 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); - - await controller.reconcileDeclaration({ - apps: [], - provider: './src/dev/replaced-provider.ts', - servers: [], - sourceRevision: 'source-2', - }); - - 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' }); - await controller.close(); -}); - it('latches every topology failure across a pending runtime start', 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; @@ -1176,7 +947,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 +954,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 +979,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 +988,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 +996,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 From 4fc4ebaf180364c7ab4f56a0ec9b45f1128529e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 05:31:30 +0000 Subject: [PATCH 2/3] fix(example): recover from a failed prepared-runtime reconcile Return the runtime to active once a reconcile succeeds after a failed one, abort a prepared activation when the test seam throws, cover a provider-path latch on an active session, and describe what the dev session still serves. --- .changeset/remove-runtime-provider-mcp.md | 2 +- examples/rsc-agent-runtime/README.md | 7 ++-- .../src/dev/rsbuild-runtime-session.ts | 15 ++++++-- .../tests/dev-provider.integration.test.ts | 10 ++++++ .../tests/runtime-provider.test.ts | 34 +++++++++++++++++++ 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.changeset/remove-runtime-provider-mcp.md b/.changeset/remove-runtime-provider-mcp.md index b6069ccef..81e2e0497 100644 --- a/.changeset/remove-runtime-provider-mcp.md +++ b/.changeset/remove-runtime-provider-mcp.md @@ -2,4 +2,4 @@ "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` drops 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. +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. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 4a80df0a9..9952fb004 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -82,9 +82,10 @@ 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 session only when an `agent-bundle dev` project opts into `dev.runtime.provider`. That session -compiles each change into a runtime generation and exposes the hook and MCP -tool surfaces that Workbench routes invoke; it serves no browser client. The -session uses development entries and output roots while compiling every +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 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 fcf6f4fff..599c95747 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -585,6 +585,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { #generationSequence = 0; #failureTail: Promise = Promise.resolve(); #hmrReady = false; + #reconcileDegraded = false; #latestPreparedRuntime: DevRuntimePreparedProject; #latestRscCohortRevision = 0; #invocationReservations = 0; @@ -2140,8 +2141,12 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { stateStoreId, store: this.#generationStore, }).then(async (next) => { - // ponytail: a throwing test seam leaks this prepared activation; the public provider never sets it. - await this.#testing.afterActivationPrepare?.(Object.freeze({ session: this })); + try { + await this.#testing.afterActivationPrepare?.(Object.freeze({ session: this })); + } catch (error) { + await this.#generationStore.abort(next).catch(() => undefined); + throw error; + } return next; }), (late) => this.#generationStore.abort(late), @@ -2157,6 +2162,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { this.#active = generation; this.#updateSurfaces(snapshot, snapshot.preparedRuntime); this.#updateSurfaceAssetApps(snapshot.preparedRuntime); + this.#reconcileDegraded = false; this.#setStatus('active'); this.#emit(Object.freeze({ runtimeGenerationId: generation.id, type: 'runtime.generation.activated' })); return 'activated'; @@ -2176,7 +2182,12 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const definition = JSON.parse(await readFile(join(active.root, 'rsc', 'runtime-definition.json'), 'utf8')) as SerializedRuntimeDefinition; this.#updateSurfaces({ definition }, prepared); this.#updateSurfaceAssetApps(prepared); + if (this.#reconcileDegraded) { + this.#reconcileDegraded = false; + this.#setStatus('active'); + } } catch (error) { + this.#reconcileDegraded = true; this.#setStatus('degraded', [lifecycleDiagnostic(error)]); throw error; } 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 3d1e30370..4a3a11f5d 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -426,6 +426,15 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil ])); 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}-unreadable-definition`, + })).rejects.toThrow(); + expect(session.status()).toMatchObject({ state: 'degraded' }); + await rename(`${definitionPath}.hidden`, definitionPath); + await session.reconcilePreparedRuntime({ ...prepared.devRuntime!, servers: prepared.devRuntime!.servers.map((server) => ({ ...server, targets: ['portable'] })), @@ -435,6 +444,7 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil 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: {}, diff --git a/packages/agent-bundle/tests/runtime-provider.test.ts b/packages/agent-bundle/tests/runtime-provider.test.ts index d05f82305..890382013 100644 --- a/packages/agent-bundle/tests/runtime-provider.test.ts +++ b/packages/agent-bundle/tests/runtime-provider.test.ts @@ -916,6 +916,40 @@ it('reconciles the newest revision exactly once after a deferred provider start await controller.close(); }); +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, + environment: {}, + preparedRuntime: { apps: [], provider: './src/dev/provider.ts', servers: [], sourceRevision: 'source-1' }, + projectRoot: '/workspace/project', + provider: { + descriptor, + start: async () => ({ + close: async () => undefined, + runs: () => [], + status: () => ({ descriptor, diagnostics: [], hmrReady: true, state: 'active' }), + surfaces: () => [], + }) as unknown as DevRuntimeSession, + }, + storageRoot: '/workspace/project/.agent-bundle/runtime', + }); + await controller.start(); + expect(controller.runs(1)).toEqual([]); + + await controller.reconcileDeclaration({ + apps: [], + provider: './src/dev/replaced-provider.ts', + servers: [], + sourceRevision: 'source-2', + }); + + expect(controller.status()).toMatchObject({ state: 'failed' }); + expect(() => controller.runs(1)).toThrow(DevRuntimeUnavailableError); + await controller.close(); +}); + it('latches every topology failure across a pending runtime start', 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; From 0474847cd92051c62399887161b2d3cc9d918178 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 05:40:21 +0000 Subject: [PATCH 3/3] docs(changeset): reference #855 --- .changeset/remove-runtime-provider-mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/remove-runtime-provider-mcp.md b/.changeset/remove-runtime-provider-mcp.md index 81e2e0497..a9326f94a 100644 --- a/.changeset/remove-runtime-provider-mcp.md +++ b/.changeset/remove-runtime-provider-mcp.md @@ -2,4 +2,4 @@ "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. +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)