From 6ba963a2ce32370584d63edebd2a399542b6c4b3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:11:37 +0000 Subject: [PATCH 1/3] feat(agent-bundle): gate dev epochs on contract matrix Run opt-in project fixtures against each generated epoch before host-facing connections and installs adopt it, preserving the last approved epoch on contract failure. --- .changeset/dev-epoch-contract-matrix.md | 5 + packages/agent-bundle/README.md | 30 +++ packages/agent-bundle/src/api.ts | 1 + .../agent-bundle/src/config/dev-contracts.ts | 228 ++++++++++++++++ packages/agent-bundle/src/config/index.ts | 1 + packages/agent-bundle/src/core/types.ts | 9 + .../src/dev/dev-contract-runner.ts | 188 +++++++++++++ .../src/dev/epoch-adoption-policy.ts | 178 ++++++++++++ packages/agent-bundle/src/dev/events.ts | 5 +- .../src/dev/host-install-manager.ts | 16 +- .../agent-bundle/src/dev/host-mcp-routes.ts | 26 +- .../src/dev/logs/dev-log-kinds.ts | 6 +- .../src/dev/logs/dev-log-producers.ts | 7 +- .../agent-bundle/src/dev/project-service.ts | 12 + packages/agent-bundle/src/dev/types.ts | 16 +- .../agent-bundle/src/dev/workbench-server.ts | 47 ++-- packages/agent-bundle/src/index.ts | 1 + packages/agent-bundle/src/test/contract.ts | 113 ++++++-- packages/agent-bundle/src/test/index.ts | 11 +- packages/agent-bundle/src/test/manifest.ts | 7 + .../tests/dev-contract-adoption.test.ts | 255 ++++++++++++++++++ .../tests/dev-contract-config.test.ts | 79 ++++++ .../tests/epoch-adoption-policy.test.ts | 132 +++++++++ packages/workbench/src/logs/log-client.ts | 6 +- packages/workbench/tests/log-client.test.ts | 32 +++ rstest.integration-tests.ts | 1 + 26 files changed, 1356 insertions(+), 56 deletions(-) create mode 100644 .changeset/dev-epoch-contract-matrix.md create mode 100644 packages/agent-bundle/src/config/dev-contracts.ts create mode 100644 packages/agent-bundle/src/dev/dev-contract-runner.ts create mode 100644 packages/agent-bundle/src/dev/epoch-adoption-policy.ts create mode 100644 packages/agent-bundle/tests/dev-contract-adoption.test.ts create mode 100644 packages/agent-bundle/tests/dev-contract-config.test.ts create mode 100644 packages/agent-bundle/tests/epoch-adoption-policy.test.ts diff --git a/.changeset/dev-epoch-contract-matrix.md b/.changeset/dev-epoch-contract-matrix.md new file mode 100644 index 000000000..5f4d8d55e --- /dev/null +++ b/.changeset/dev-epoch-contract-matrix.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Gate live host and development-install epoch adoption on an opt-in, project-declared contract matrix. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 935b9aa74..6d28b2654 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -218,6 +218,36 @@ epoch automatically. Use **Restart MCP session** to respawn that generated serve epoch; open a new session to use a newly published epoch. Compatible MCP Apps preview through the same bound session. +### Development contract matrix + +Projects can opt host-facing rebuild adoption into the generated contract matrix by pointing +`dev.contracts.fixtures` at a project-local module: + +```ts +// agent-bundle.config.ts +export default { + dev: { + contracts: { + fixtures: './contract-fixtures.ts', + server: 'tools', // optional when the project has exactly one MCP server + }, + }, +}; +``` + +The module default-exports the same `Record` consumed by +`runContractMatrix`. Agent Bundle reloads and validates it for every prepared epoch. An invalid +module does not fail compilation: it fails that epoch's contract run with a diagnostic instead. +Omitting `dev.contracts` leaves the matrix off and preserves direct `artifact.available` adoption. + +For an enabled project, each published epoch is exercised through an already-open, epoch-pinned +generated stdio session. Passing epochs atomically replace the server behind existing live host MCP +connections and refresh opted-in development host installs. Failing or timed-out epochs remain +inactive on those host-facing surfaces, leaving the last passing epoch connected and installed. +The Workbench project stream emits `dev.contract.status`; the Logs page includes its diagnostics and +the exact failed check names grouped by route. A later passing rebuild is adopted normally. +Workbench playground sessions remain independently epoch-pinned and are not gated by this matrix. + ### Live host MCP proxy During development, a host can keep one stdio MCP process connected while `agent-bundle dev` diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 18bf05234..f6dd20004 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -222,6 +222,7 @@ export { startDevServer } from './dev/workbench-server.ts'; export type { DevServerSession, StartDevServerOptions } from './dev/workbench-server.ts'; export type { AgentBundleDevConfig, + AgentBundleDevContractsConfig, AgentBundleDevRuntimeConfig, } from './core/types.ts'; export type { diff --git a/packages/agent-bundle/src/config/dev-contracts.ts b/packages/agent-bundle/src/config/dev-contracts.ts new file mode 100644 index 000000000..323d30e16 --- /dev/null +++ b/packages/agent-bundle/src/config/dev-contracts.ts @@ -0,0 +1,228 @@ +import { realpath } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { createJiti } from 'jiti'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { isInsideOrEqual } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { + AgentBundleConfig, + AgentBundleDevContractsConfig, +} from '../core/types.ts'; +import type { ContractRouteFixture } from '../test/contract.ts'; + +export interface PreparedDevContractMatrix { + readonly diagnostics: readonly Diagnostic[]; + readonly fixtures?: Readonly>; + readonly modulePath: string; + readonly server?: string; +} + +const diagnostic = (sourcePath: string, message: string): Diagnostic => Object.freeze({ + code: 'AB7005', + message, + recovery: 'Correct dev.contracts and its fixture module, then rebuild; contract failures do not invalidate the artifact.', + severity: 'error', + sourcePath, +}); + +const invalid = (reason: string): never => { + throw new TypeError(reason); +}; + +const optionalArray = (value: unknown, name: string): void => { + if (value !== undefined && !Array.isArray(value)) invalid(`${name} must be an array when provided.`); +}; + +const fields = (value: Readonly>, allowed: readonly string[], name: string): void => { + const allowedFields = new Set(allowed); + const unknown = Object.keys(value).filter((key) => !allowedFields.has(key)); + if (unknown.length > 0) invalid(`${name} has unknown field(s): ${unknown.sort().join(', ')}.`); +}; + +const requiredString = (value: unknown, name: string): void => { + if (typeof value !== 'string' || value.length === 0) invalid(`${name} must be a nonempty string.`); +}; + +const requiredStringArray = (value: unknown, name: string): void => { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + invalid(`${name} must be an array of strings.`); + } +}; + +const lifecyclePhase = (value: unknown, name: string): void => { + if (value !== 'setup' && value !== 'active' && value !== 'terminal') { + invalid(`${name} must be "setup", "active", or "terminal".`); + } +}; + +const lifecycleStateEntry = ( + value: unknown, + name: string, + allowed: readonly string[], +): Readonly> => { + if (!isRecord(value)) return invalid(`${name} must be an object.`); + fields(value, allowed, name); + return value; +}; + +const validateLifecycleState = (value: Readonly>, routeId: string): void => { + const name = `Fixture ${JSON.stringify(routeId)} lifecycle.state`; + fields(value, ['budget', 'durability', 'idempotency', 'journal', 'notice'], name); + if (value.budget !== undefined) { + const entry = lifecycleStateEntry( + value.budget, + `${name}.budget`, + ['codePath', 'expectedCode', 'input', 'revisionPath'], + ); + requiredStringArray(entry.codePath, `${name}.budget.codePath`); + requiredString(entry.expectedCode, `${name}.budget.expectedCode`); + requiredStringArray(entry.revisionPath, `${name}.budget.revisionPath`); + if (!Object.hasOwn(entry, 'input')) invalid(`${name}.budget.input is required.`); + } + if (value.durability !== undefined) { + const entry = lifecycleStateEntry( + value.durability, + `${name}.durability`, + ['expectedStructuredContent', 'input'], + ); + if (!Object.hasOwn(entry, 'expectedStructuredContent') || !Object.hasOwn(entry, 'input')) { + invalid(`${name}.durability requires expectedStructuredContent and input.`); + } + } + if (value.idempotency !== undefined) { + const entry = lifecycleStateEntry( + value.idempotency, + `${name}.idempotency`, + ['phase', 'replayedPath', 'revisionPath'], + ); + lifecyclePhase(entry.phase, `${name}.idempotency.phase`); + requiredStringArray(entry.replayedPath, `${name}.idempotency.replayedPath`); + requiredStringArray(entry.revisionPath, `${name}.idempotency.revisionPath`); + } + if (value.journal !== undefined) { + const entry = lifecycleStateEntry(value.journal, `${name}.journal`, ['expected', 'path']); + if (!Object.hasOwn(entry, 'expected')) invalid(`${name}.journal.expected is required.`); + requiredStringArray(entry.path, `${name}.journal.path`); + } + if (value.notice !== undefined) { + const entry = lifecycleStateEntry(value.notice, `${name}.notice`, ['expected', 'path', 'phase']); + if (!Object.hasOwn(entry, 'expected')) invalid(`${name}.notice.expected is required.`); + requiredStringArray(entry.path, `${name}.notice.path`); + lifecyclePhase(entry.phase, `${name}.notice.phase`); + } +}; + +const validateLifecycle = (value: unknown, routeId: string): void => { + if (value === undefined) return; + if (!isRecord(value)) return invalid(`Fixture ${JSON.stringify(routeId)} lifecycle must be an object.`); + fields(value, ['state', 'transitionDriver'], `Fixture ${JSON.stringify(routeId)} lifecycle`); + if (typeof value.transitionDriver !== 'function') { + return invalid(`Fixture ${JSON.stringify(routeId)} lifecycle must provide a transitionDriver function.`); + } + if (value.state !== undefined && !isRecord(value.state)) { + return invalid(`Fixture ${JSON.stringify(routeId)} lifecycle.state must be an object when provided.`); + } + if (value.state !== undefined) validateLifecycleState(value.state, routeId); +}; + +const validateFixture = (value: unknown, routeId: string): ContractRouteFixture => { + if (!isRecord(value)) return invalid(`Fixture ${JSON.stringify(routeId)} must be an object.`); + fields( + value, + ['cancellation', 'input', 'inputs', 'lifecycle', 'previousResults', 'resultCompat'], + `Fixture ${JSON.stringify(routeId)}`, + ); + if (value.resultCompat !== undefined && value.resultCompat !== 'additive' && value.resultCompat !== 'closed') { + return invalid(`Fixture ${JSON.stringify(routeId)} resultCompat must be "additive" or "closed".`); + } + optionalArray(value.inputs, `Fixture ${JSON.stringify(routeId)} inputs`); + optionalArray(value.previousResults, `Fixture ${JSON.stringify(routeId)} previousResults`); + if (value.cancellation !== undefined) { + if (!isRecord(value.cancellation)) { + return invalid(`Fixture ${JSON.stringify(routeId)} cancellation must be an object when provided.`); + } + fields( + value.cancellation, + ['abortAfterMs', 'input'], + `Fixture ${JSON.stringify(routeId)} cancellation`, + ); + const abortAfterMs = value.cancellation.abortAfterMs; + if (abortAfterMs !== undefined && (typeof abortAfterMs !== 'number' || !Number.isFinite(abortAfterMs) || abortAfterMs < 0)) { + return invalid(`Fixture ${JSON.stringify(routeId)} cancellation.abortAfterMs must be a non-negative finite number.`); + } + } + validateLifecycle(value.lifecycle, routeId); + return Object.freeze({ ...value }) as ContractRouteFixture; +}; + +const validateFixtures = (value: unknown): Readonly> => { + if (!isRecord(value)) return invalid('The development contract fixture module must default-export an object.'); + const fixtures: Record = {}; + for (const [routeId, fixture] of Object.entries(value)) { + if (routeId.trim().length === 0) return invalid('Development contract fixture route ids must be nonempty.'); + fixtures[routeId] = validateFixture(fixture, routeId); + } + return Object.freeze(fixtures); +}; + +const declaration = (config: AgentBundleConfig): AgentBundleDevContractsConfig | undefined => { + const configured: unknown = config.dev?.contracts; + if (configured === undefined) return undefined; + if (!isRecord(configured) || typeof configured.fixtures !== 'string' || configured.fixtures.trim().length === 0) { + return invalid('dev.contracts.fixtures must be a nonempty project-relative module path.'); + } + fields(configured, ['fixtures', 'server'], 'dev.contracts'); + if (configured.server !== undefined && (typeof configured.server !== 'string' || configured.server.trim().length === 0)) { + invalid('dev.contracts.server must be a nonempty string when provided.'); + } + return configured as unknown as AgentBundleDevContractsConfig; +}; + +/** Loads the optional dev-only fixture module without making its diagnostics build-fatal. */ +export const loadDevContractMatrix = async ( + config: AgentBundleConfig, + configPath: string, + projectRoot: string, +): Promise => { + let configured: AgentBundleDevContractsConfig | undefined; + try { + configured = declaration(config); + } catch (error) { + return Object.freeze({ + diagnostics: Object.freeze([diagnostic(configPath, error instanceof Error ? error.message : String(error))]), + modulePath: configPath, + }); + } + if (configured === undefined) return undefined; + const requestedPath = resolve(dirname(configPath), configured.fixtures); + const base = { + modulePath: requestedPath, + ...(configured.server === undefined ? {} : { server: configured.server }), + }; + try { + const [root, modulePath] = await Promise.all([realpath(projectRoot), realpath(requestedPath)]); + if (!isInsideOrEqual(root, modulePath)) { + invalid('dev.contracts.fixtures must resolve inside the project root.'); + } + const jiti = createJiti(configPath, { + interopDefault: true, + jsx: { runtime: 'automatic' }, + moduleCache: false, + nativeModules: ['typescript'], + }); + const exported = await jiti.import(modulePath, { default: true }); + return Object.freeze({ + diagnostics: Object.freeze([]), + fixtures: validateFixtures(exported), + modulePath, + ...(configured.server === undefined ? {} : { server: configured.server }), + }); + } catch (error) { + return Object.freeze({ + ...base, + diagnostics: Object.freeze([diagnostic(requestedPath, error instanceof Error ? error.message : String(error))]), + }); + } +}; diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 03923eb55..46f81a4b5 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -40,6 +40,7 @@ export type AgentBundleConfig = CoreAgentBundleConfig export type { AgentBundleConfigExtensions, AgentBundleDevConfig, + AgentBundleDevContractsConfig, AgentBundleDevRuntimeConfig, AgentBundleHostConfig, AgentBundlePayloadConfig, diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index b4926b3b8..6f416f997 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -273,7 +273,16 @@ export interface AgentBundleDevRuntimeConfig { readonly provider: string; } +/** Development-only generated-server contract checks run before host adoption. */ +export interface AgentBundleDevContractsConfig { + /** Project-relative module default-exporting route-id keyed contract fixtures. */ + readonly fixtures: string; + /** MCP server to check; optional only when the project compiles exactly one server. */ + readonly server?: string; +} + export interface AgentBundleDevConfig { + readonly contracts?: AgentBundleDevContractsConfig; readonly runtime?: AgentBundleDevRuntimeConfig; } diff --git a/packages/agent-bundle/src/dev/dev-contract-runner.ts b/packages/agent-bundle/src/dev/dev-contract-runner.ts new file mode 100644 index 000000000..3a063e282 --- /dev/null +++ b/packages/agent-bundle/src/dev/dev-contract-runner.ts @@ -0,0 +1,188 @@ +import { + DEV_EPOCH_PROOF_LEVEL, + testManifestFromRouteGraph, +} from '../test/manifest.ts'; +import { + ContractMatrixViolationError, + runDevEpochContractMatrix, + type ContractMatrixClient, +} from '../test/contract.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { emptyCompiledRouteGraph } from '../routes/graph.ts'; +import type { PreparedDevContractMatrix } from '../config/dev-contracts.ts'; +import type { PreparedProject } from './project-service.ts'; +import type { McpSession, McpSessionService } from './mcp-session/mcp-session-service.ts'; +import { + contractFailures, + type EpochContractEvaluation, +} from './epoch-adoption-policy.ts'; + +export interface RunDevEpochContractsOptions { + readonly contracts: PreparedDevContractMatrix; + readonly epochId: string; + readonly mcpSessions: McpSessionService; + readonly prepared: PreparedProject; +} + +const failureDiagnostic = (epochId: string, message: string): Diagnostic => Object.freeze({ + code: 'AB7006', + message, + recovery: 'Fix the failing route or fixture, then rebuild; host-facing surfaces keep the last passing epoch active.', + severity: 'error', + target: epochId, +}); + +const failed = ( + epochId: string, + summary: string, + diagnostics: readonly Diagnostic[], + failures: EpochContractEvaluation['failures'] = Object.freeze([]), +): EpochContractEvaluation => Object.freeze({ + diagnostics: Object.freeze([...diagnostics]), + epochId, + failures, + state: 'failed', + summary, +}); + +const targetFor = (prepared: PreparedProject): string => { + const targets = prepared.model?.targets.map((target) => target.name) ?? []; + const target = targets.includes('portable') ? 'portable' : targets[0]; + if (target === undefined) throw new Error('Development contract matrix requires at least one generated target.'); + return target; +}; + +const serverFor = (prepared: PreparedProject, requested: string | undefined): string => { + if (requested !== undefined) return requested; + const names = [...new Set((prepared.routeGraph?.servers ?? []).map((server) => server.name))].sort(); + if (names.length !== 1 || names[0] === undefined) { + throw new Error('dev.contracts.server is required unless the project compiles exactly one MCP server.'); + } + return names[0]; +}; + +const matrixClient = (session: McpSession, signal: AbortSignal): ContractMatrixClient => ({ + callTool: async (params, options) => session.callTool({ + arguments: params.arguments ?? {}, + name: params.name, + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + }), + getPrompt: async (params, options) => session.getPrompt({ + ...(params.arguments === undefined ? {} : { arguments: params.arguments }), + name: params.name, + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + }), + listPrompts: async (_params, options) => ({ + prompts: [...await session.listPrompts({ + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + })], + }), + listResources: async (_params, options) => ({ + resources: [...await session.listResources({ + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + })], + }), + listTools: async (_params, options) => ({ + tools: [...await session.listTools({ + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + })], + }), + readResource: async (params, options) => ({ + contents: [...(await session.readResource({ + signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]), + ...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }), + uri: params.uri, + })).contents] as never[], + }), +}); + +/** Executes one generated epoch through the same session class used by live host connections. */ +export const runDevEpochContracts = async ( + options: RunDevEpochContractsOptions, +): Promise => { + const { contracts, epochId, prepared } = options; + if (contracts.fixtures === undefined) { + return failed( + epochId, + 'Development contract fixture declaration is invalid.', + contracts.diagnostics, + ); + } + const target = targetFor(prepared); + const serverName = serverFor(prepared, contracts.server); + const manifest = testManifestFromRouteGraph({ + apps: prepared.model?.mcpApps ?? [], + configPath: prepared.configPath, + diagnostics: prepared.diagnostics, + graph: prepared.routeGraph ?? emptyCompiledRouteGraph, + ...(prepared.model === undefined + ? {} + : { + plugin: { + name: prepared.model.metadata.name, + ...(prepared.model.metadata.packageName === undefined + ? {} + : { packageName: prepared.model.metadata.packageName }), + ...(prepared.model.metadata.packageVersion === undefined + ? {} + : { packageVersion: prepared.model.metadata.packageVersion }), + version: prepared.model.metadata.version, + }, + }), + projectRoot: prepared.root, + ...(prepared.model?.state === undefined ? {} : { state: prepared.model.state }), + targets: prepared.model?.targets.map((entry) => entry.name) ?? [], + }); + let session: McpSession | undefined; + try { + session = await options.mcpSessions.open({ + epochId, + serverName, + target, + }); + const signal = AbortSignal.timeout(session.timeoutMs); + await runDevEpochContractMatrix({ + fixtures: contracts.fixtures, + manifest, + ...(contracts.server === undefined ? {} : { server: contracts.server }), + session: { + client: matrixClient(session, signal), + provenance: { + epochId, + proofLevel: DEV_EPOCH_PROOF_LEVEL, + serverName, + target, + }, + stderr: () => session?.stderr() ?? '', + }, + }); + return Object.freeze({ + diagnostics: Object.freeze([]), + epochId, + failures: Object.freeze([]), + state: 'passed', + summary: 'Development contract matrix passed.', + }); + } catch (error) { + if (error instanceof ContractMatrixViolationError) { + return failed( + epochId, + `Development contract matrix reported ${String(error.failures.length)} violation(s).`, + [failureDiagnostic(epochId, error.message)], + contractFailures(error.failures), + ); + } + return failed( + epochId, + 'Development contract matrix could not complete.', + [failureDiagnostic(epochId, error instanceof Error ? error.message : String(error))], + ); + } finally { + await session?.close().catch(() => undefined); + } +}; diff --git a/packages/agent-bundle/src/dev/epoch-adoption-policy.ts b/packages/agent-bundle/src/dev/epoch-adoption-policy.ts new file mode 100644 index 000000000..4038f9e33 --- /dev/null +++ b/packages/agent-bundle/src/dev/epoch-adoption-policy.ts @@ -0,0 +1,178 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import type { PreparedDevContractMatrix } from '../config/dev-contracts.ts'; +import type { + ProjectEventHub, + ProjectEventSubscription, +} from './events.ts'; +import type { + DevContractFailure, + DevContractStatusEvent, +} from './types.ts'; + +export type EpochContractEvaluation = DevContractStatusEvent; +export type EpochAdoptionListener = (epochId: string) => void; + +export interface EpochAdoptionSource { + readonly currentEpochId: string | undefined; + subscribe(listener: EpochAdoptionListener): ProjectEventSubscription; +} + +/** Compatibility bridge for direct service construction; product wiring supplies one shared policy. */ +export const subscribeToEpochAdoption = ( + adoption: EpochAdoptionSource | undefined, + eventHub: ProjectEventHub, + listener: EpochAdoptionListener, +): ProjectEventSubscription => adoption?.subscribe(listener) ?? eventHub.subscribe( + { afterSequence: eventHub.latestSequence }, + (event) => { + if (event.type === 'artifact.available') listener(event.epochId); + }, +); + +export interface EpochAdoptionPolicyOptions { + readonly contracts: () => PreparedDevContractMatrix | undefined; + readonly eventHub: ProjectEventHub; + readonly run: ( + epochId: string, + contracts: PreparedDevContractMatrix, + ) => Promise; +} + +interface PendingEpoch { + readonly contracts: PreparedDevContractMatrix; + readonly epochId: string; + readonly sequence: number; +} + +const runnerDiagnostic = (epochId: string, error: unknown): Diagnostic => Object.freeze({ + code: 'AB7006', + message: `Development contract matrix failed for epoch ${epochId}: ${ + error instanceof Error ? error.message : String(error) + }`, + recovery: 'Fix the fixture declaration or generated MCP server, then rebuild; the last passing host epoch remains active.', + severity: 'error', +}); + +const failedEvaluation = (epochId: string, error: unknown): EpochContractEvaluation => Object.freeze({ + diagnostics: Object.freeze([runnerDiagnostic(epochId, error)]), + epochId, + failures: Object.freeze([]), + state: 'failed', + summary: 'Development contract matrix could not complete.', +}); + +/** + * One host-facing epoch gate. Workbench playground surfaces continue to follow + * artifact.available directly; only subscribers here wait for contract proof. + */ +export class EpochAdoptionPolicy implements EpochAdoptionSource { + readonly #contracts: () => PreparedDevContractMatrix | undefined; + readonly #eventHub: ProjectEventHub; + readonly #listeners = new Set(); + readonly #run: EpochAdoptionPolicyOptions['run']; + readonly #subscription: ProjectEventSubscription; + #closed = false; + #currentEpochId: string | undefined; + #pending: PendingEpoch | undefined; + #processing: Promise | undefined; + #sequence = 0; + + constructor(options: EpochAdoptionPolicyOptions) { + this.#contracts = options.contracts; + this.#eventHub = options.eventHub; + this.#run = options.run; + this.#subscription = options.eventHub.subscribe( + { afterSequence: options.eventHub.latestSequence }, + (event) => { + if (event.type !== 'artifact.available' || this.#closed) return; + const contracts = this.#contracts(); + if (contracts === undefined) { + this.#adopt(event.epochId); + return; + } + this.#sequence += 1; + this.#pending = Object.freeze({ + contracts, + epochId: event.epochId, + sequence: this.#sequence, + }); + this.#processing ??= this.#drain().finally(() => { + this.#processing = undefined; + }); + }, + ); + } + + get currentEpochId(): string | undefined { + return this.#currentEpochId; + } + + subscribe(listener: EpochAdoptionListener): ProjectEventSubscription { + if (this.#closed) throw new Error('Epoch adoption policy is closed.'); + this.#listeners.add(listener); + return Object.freeze({ + unsubscribe: () => this.#listeners.delete(listener), + }); + } + + settled(): Promise { + return this.#processing ?? Promise.resolve(); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#subscription.unsubscribe(); + this.#pending = undefined; + await this.#processing; + this.#listeners.clear(); + } + + async #drain(): Promise { + while (!this.#closed && this.#pending !== undefined) { + const candidate = this.#pending; + this.#pending = undefined; + let evaluation: EpochContractEvaluation; + try { + evaluation = await this.#run(candidate.epochId, candidate.contracts); + } catch (error) { + evaluation = failedEvaluation(candidate.epochId, error); + } + if (this.#closed || candidate.sequence !== this.#sequence) continue; + this.#eventHub.publish({ + epochId: candidate.epochId, + payload: evaluation, + type: 'dev.contract.status', + }); + if (evaluation.state === 'passed') this.#adopt(candidate.epochId); + } + } + + #adopt(epochId: string): void { + this.#currentEpochId = epochId; + for (const listener of this.#listeners) { + try { + listener(epochId); + } catch { + // Adoption consumers own their async failure reporting; one cannot starve its peers. + } + } + } +} + +export const contractFailures = ( + failures: readonly Readonly<{ readonly check: string; readonly routeId: string }>[], +): readonly DevContractFailure[] => { + const checks = new Map>(); + for (const failure of failures) { + const route = checks.get(failure.routeId) ?? new Set(); + route.add(failure.check); + checks.set(failure.routeId, route); + } + return Object.freeze([...checks.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, routeChecks]) => Object.freeze({ + checks: Object.freeze([...routeChecks].sort()), + routeId, + }))); +}; diff --git a/packages/agent-bundle/src/dev/events.ts b/packages/agent-bundle/src/dev/events.ts index f29a93a7a..1d4344685 100644 --- a/packages/agent-bundle/src/dev/events.ts +++ b/packages/agent-bundle/src/dev/events.ts @@ -9,7 +9,7 @@ import { type ProjectReplayGap, } from './types.ts'; -type EpochScopedProjectEventType = 'artifact.available' | 'dev.host.sync'; +type EpochScopedProjectEventType = 'artifact.available' | 'dev.contract.status' | 'dev.host.sync'; type ProjectEventInputFor = Readonly<{ readonly occurredAt?: string; @@ -80,12 +80,13 @@ const eventTypes = new Set([ 'build.failed', 'artifact.available', 'artifact.status', + 'dev.contract.status', 'dev.host.sync', 'runtime.event', ]); const requiresEpoch = (type: ProjectEventType): boolean => - type === 'artifact.available' || type === 'dev.host.sync'; + type === 'artifact.available' || type === 'dev.contract.status' || type === 'dev.host.sync'; const ensureReplayLimit = (replayLimit: number): number => { if (!Number.isSafeInteger(replayLimit) || replayLimit < 1) { diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 610522509..4b10725ec 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -22,6 +22,10 @@ import { type InstallResult, } from '../install/install.ts'; import { devProxyServerCommand } from './dev-proxy-command.ts'; +import { + subscribeToEpochAdoption, + type EpochAdoptionSource, +} from './epoch-adoption-policy.ts'; import type { EpochReference, EpochStore } from './epoch-store.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; @@ -32,6 +36,7 @@ interface EpochReferenceSource { } export interface DevHostInstallManagerOptions { + readonly adoption?: EpochAdoptionSource; readonly environment?: Readonly; readonly epochStore: EpochReferenceSource | Pick; readonly eventHub: ProjectEventHub; @@ -282,6 +287,7 @@ const syncDiagnostic = (host: InstallHost, epochId: string, error: unknown): Dia /** Owns opt-in host development installs for one foreground dev session. */ export class DevHostInstallManager { + readonly #adoption: EpochAdoptionSource | undefined; readonly #epochStore: EpochReferenceSource; readonly #environment: Readonly; readonly #eventHub: ProjectEventHub; @@ -295,6 +301,7 @@ export class DevHostInstallManager { #subscription: ProjectEventSubscription | undefined; constructor(options: DevHostInstallManagerOptions) { + this.#adoption = options.adoption; this.#epochStore = options.epochStore; this.#environment = options.environment ?? process.env; this.#eventHub = options.eventHub; @@ -306,11 +313,10 @@ export class DevHostInstallManager { start(): void { if (this.#subscription !== undefined || this.#closed) return; - this.#subscription = this.#eventHub.subscribe( - { afterSequence: this.#eventHub.latestSequence }, - (event) => { - if (event.type === 'artifact.available') this.sync(event.epochId); - }, + this.#subscription = subscribeToEpochAdoption( + this.#adoption, + this.#eventHub, + (epochId) => this.sync(epochId), ); } diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index 7c7ae349f..f8248b8e7 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -5,6 +5,10 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { ProtocolError, Server, type ReadResourceResult } from '@modelcontextprotocol/server'; import { EpochStoreError, type EpochStore } from './epoch-store.ts'; +import { + subscribeToEpochAdoption, + type EpochAdoptionSource, +} from './epoch-adoption-policy.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; import { McpSessionStaleEpochError, @@ -45,6 +49,7 @@ interface HostMcpEpochSession { } interface HostMcpRoutesOptions { + readonly adoption?: EpochAdoptionSource; readonly epochStore: EpochStore; readonly eventHub: ProjectEventHub; readonly mcpSessions: McpSessionService; @@ -74,6 +79,7 @@ const isEpochDrift = (error: unknown): boolean => (error.code === 'EPOCH_NOT_FOUND' || error.code === 'EPOCH_METADATA_INVALID')); class HostMcpConnection { + readonly #adoption: EpochAdoptionSource | undefined; readonly #binding: HostMcpBinding; readonly #epochStore: EpochStore; readonly #mcpSessions: McpSessionService; @@ -91,9 +97,10 @@ class HostMcpConnection { constructor( binding: HostMcpBinding, - options: Pick, + options: Pick, onSessionInitialized: (sessionId: string, connection: HostMcpConnection) => void, ) { + this.#adoption = options.adoption; this.#binding = binding; this.#epochStore = options.epochStore; this.#mcpSessions = options.mcpSessions; @@ -198,7 +205,13 @@ class HostMcpConnection { if (this.#activeEpochSession === undefined) { this.#scheduleTransition(this.#lastEpochId ?? 'unknown', async () => { if (this.#activeEpochSession !== undefined) return; - const reference = await this.#epochStore.acquireActiveEpochReference(); + const adoptedEpochId = this.#adoption?.currentEpochId; + if (this.#adoption !== undefined && adoptedEpochId === undefined) { + throw new Error('No contract-approved development epoch is available for this host connection.'); + } + const reference = adoptedEpochId === undefined + ? await this.#epochStore.acquireActiveEpochReference() + : await this.#epochStore.acquireEpochReference(adoptedEpochId); this.#lastEpochId = reference.epoch.id; try { this.#activeEpochSession = await this.#openEpochSession(reference.epoch.id); @@ -333,6 +346,7 @@ class HostMcpConnection { /** Stateful host-facing MCP transport whose handlers resolve the active artifact epoch per operation. */ export class HostMcpRoutes { + readonly #adoption: EpochAdoptionSource | undefined; readonly #connections = new Set(); readonly #epochStore: EpochStore; readonly #mcpSessions: McpSessionService; @@ -341,11 +355,11 @@ export class HostMcpRoutes { #closed = false; constructor(options: HostMcpRoutesOptions) { + this.#adoption = options.adoption; this.#epochStore = options.epochStore; this.#mcpSessions = options.mcpSessions; - this.#subscription = options.eventHub.subscribe((event) => { - if (event.type !== 'artifact.available') return; - for (const connection of this.#connections) connection.refreshCatalog(event.epochId); + this.#subscription = subscribeToEpochAdoption(options.adoption, options.eventHub, (epochId) => { + for (const connection of this.#connections) connection.refreshCatalog(epochId); }); } @@ -374,7 +388,7 @@ export class HostMcpRoutes { const connection = new HostMcpConnection( binding, - { epochStore: this.#epochStore, mcpSessions: this.#mcpSessions }, + { adoption: this.#adoption, epochStore: this.#epochStore, mcpSessions: this.#mcpSessions }, (id, initialized) => this.#sessions.set(id, initialized), ); this.#connections.add(connection); diff --git a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts index 9dca8e4a4..7110d9b89 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts @@ -21,8 +21,8 @@ export const devLogKinds = Object.freeze({ build: Object.freeze(['artifact.available', 'build.failed', 'build.started'] as const), diagnostic: Object.freeze([ 'artifact.available.diagnostic', 'artifact.status.diagnostic', 'build.failed.diagnostic', 'build.started.diagnostic', - 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', - 'source.status.diagnostic', + 'dev.contract.status.diagnostic', 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'runtime.event.diagnostic', + 'source.changed.diagnostic', 'source.status.diagnostic', ] as const), eval: Object.freeze(['eval.run.completed', 'eval.run.failed', 'eval.run.started'] as const), hook: Object.freeze([ @@ -36,7 +36,7 @@ export const devLogKinds = Object.freeze({ mcp: Object.freeze(['mcp.logging', 'mcp.stderr', 'mcp.operation.failed', 'mcp.operation.started', 'mcp.operation.succeeded'] as const), playground: Object.freeze(['playground.event.appended'] as const), project: Object.freeze([ - 'artifact.status', 'dev.host.sync', 'dev.shutdown.completed', 'dev.shutdown.started', 'invalidation', + 'artifact.status', 'dev.contract.status', 'dev.host.sync', 'dev.shutdown.completed', 'dev.shutdown.started', 'invalidation', 'project.events.replay-gap', 'project.invalid-source', 'project.load', 'project.prepared', 'runtime.event', 'source.changed', 'source.status', ] as const), diff --git a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts index 989a81abf..e2940e05e 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts @@ -17,14 +17,15 @@ const contextFor = (event: ProjectEvent): Readonly> => { if (event.type === 'build.started' || event.type === 'build.failed') { return buildId === undefined ? Object.freeze({}) : Object.freeze({ buildId }); } - if (event.type === 'artifact.available' || event.type === 'dev.host.sync' || event.type === 'runtime.event') { + if (event.type === 'artifact.available' || event.type === 'dev.contract.status' || event.type === 'dev.host.sync' || event.type === 'runtime.event') { return event.epochId === undefined ? Object.freeze({}) : Object.freeze({ epochId: event.epochId }); } return Object.freeze({}); }; const levelFor = (event: ProjectEvent): DevLogInput['level'] => - event.type === 'build.failed' || event.type === 'dev.host.sync' && stringAt(event.payload, 'state') === 'failed' + event.type === 'build.failed' || + (event.type === 'dev.contract.status' || event.type === 'dev.host.sync') && stringAt(event.payload, 'state') === 'failed' ? 'error' : event.type === 'source.status' && stringAt(event.payload, 'state') === 'invalid' ? 'warning' @@ -38,6 +39,7 @@ const summaryFor = (event: ProjectEvent): string => { if (event.type === 'build.failed') return 'Project build failed.'; if (event.type === 'artifact.available') return 'Project artifact became available.'; if (event.type === 'artifact.status') return 'Project artifact status was updated.'; + if (event.type === 'dev.contract.status') return 'Development contract matrix settled.'; if (event.type === 'dev.host.sync') return 'Development host install was synchronized.'; return 'Project runtime event was published.'; }; @@ -99,6 +101,7 @@ const recordEvent = (sink: DevLogSink, message: ProjectEventMessage): void => { write(sink, { ...shared, kind: message.type, producer: 'build' }); break; case 'artifact.status': + case 'dev.contract.status': case 'dev.host.sync': case 'invalidation': case 'runtime.event': diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 4bb3d6d5f..0c3d3bcd0 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -3,6 +3,10 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { + loadDevContractMatrix, + type PreparedDevContractMatrix, +} from '../config/dev-contracts.ts'; import { configuredPayloadRoots, discoverProject } from '../config/discover.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from '../config/ignore.ts'; import { loadConfig } from '../config/load.ts'; @@ -63,6 +67,8 @@ export interface PreparedProject { readonly configPath: string; /** The validated development-only Agent API flag from the prepared configuration. */ readonly devAgentApiEnabled?: boolean; + /** Dev-only fixture module result; its diagnostics gate host adoption, never compilation. */ + readonly devContracts?: PreparedDevContractMatrix; readonly diagnostics: readonly Diagnostic[]; readonly devRuntime?: DevRuntimePreparedProject; readonly devRuntimeDiagnostic?: Diagnostic; @@ -592,10 +598,12 @@ const preparedProject = ( devAgentApiEnabled?: boolean, tools?: AgentBundleToolsConfig, routeGraph?: CompiledRouteGraph, + devContracts?: PreparedDevContractMatrix, ): PreparedProject => Object.freeze({ artifactDistPath, configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), + ...(devContracts === undefined ? {} : { devContracts }), diagnostics, ...(model === undefined ? {} : { model }), ...(devRuntime === undefined ? {} : { devRuntime }), @@ -783,6 +791,9 @@ export class ProjectService { const snapshot = await snapshotForLoadFailure(root, configPath, outputRoots); return failedPreparation('AB7000', 'Unable to load project source.', configPath, 'project.invalid-source', snapshot); } + const devContracts = command === 'dev' + ? await loadDevContractMatrix(loaded.config, loaded.configPath, root) + : undefined; const targetNames = loaded.context.selectedTargets.length > 0 ? loaded.context.selectedTargets @@ -990,6 +1001,7 @@ export class ProjectService { devAgentApiEnabled, tools, discovered.routeGraph, + devContracts, ); } } diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 05c343316..24587f898 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -279,11 +279,25 @@ export interface DevHostSyncEvent { readonly state: 'failed' | 'succeeded'; } +export interface DevContractFailure { + readonly checks: readonly string[]; + readonly routeId: string; +} + +export interface DevContractStatusEvent { + readonly diagnostics: readonly Diagnostic[]; + readonly epochId: string; + readonly failures: readonly DevContractFailure[]; + readonly state: 'failed' | 'passed'; + readonly summary: string; +} + export interface ProjectEventPayloadMap { readonly 'artifact.available': ActiveArtifactStatus; readonly 'artifact.status': ArtifactStatus; readonly 'build.failed': FailedBuildAttempt; readonly 'build.started': RunningBuildAttempt; + readonly 'dev.contract.status': DevContractStatusEvent; readonly 'dev.host.sync': DevHostSyncEvent; readonly invalidation: Invalidation; readonly 'runtime.event': RuntimeEvent; @@ -292,7 +306,7 @@ export interface ProjectEventPayloadMap { } export type ProjectEventType = keyof ProjectEventPayloadMap; -type EpochScopedProjectEventType = 'artifact.available' | 'dev.host.sync'; +type EpochScopedProjectEventType = 'artifact.available' | 'dev.contract.status' | 'dev.host.sync'; type ProjectEventFor = TType extends ProjectEventType ? Readonly<{ diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 278c70b85..44f1862e9 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -7,6 +7,8 @@ import type { InstallHost } from '../install/install.ts'; import { AgentApi } from './agent-api.ts'; import { ArtifactInspectionService } from './artifacts/artifact-inspection-service.ts'; import { DevCoordinator } from './coordinator.ts'; +import { runDevEpochContracts } from './dev-contract-runner.ts'; +import { EpochAdoptionPolicy } from './epoch-adoption-policy.ts'; import { DevLogService } from './logs/dev-log-service.ts'; import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogger } from './logs/dev-log-producers.ts'; import { EpochStore } from './epoch-store.ts'; @@ -84,7 +86,7 @@ interface Closeable { export interface DevServerLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'coordinator' | 'host-installs' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; + readonly resource: 'coordinator' | 'epoch-adoption' | 'host-installs' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; } /** Reports session and coordinator cleanup failures without hiding either resource. */ @@ -427,6 +429,7 @@ export interface DevServerRuntimeLifecycleResources { export interface DevServerLifecycleOptions { readonly coordinator: Closeable; readonly detachProjectLogs?: () => void; + readonly epochAdoption?: Closeable; readonly hostInstalls?: Closeable; readonly logs?: DevLogService; readonly mcpApps?: Closeable; @@ -440,6 +443,7 @@ export interface DevServerLifecycleOptions { export const closeDevServerLifecycle = async ({ coordinator, detachProjectLogs, + epochAdoption, hostInstalls, inspector, logs, @@ -464,6 +468,7 @@ export const closeDevServerLifecycle = async ({ ['mcp-apps', mcpApps], ['runtime-client-surfaces', runtimeResources?.clientSurfaces], ['runtime', runtimeResources?.runtime], + ['epoch-adoption', epochAdoption], ['mcp-sessions', mcpSessions], ['host-installs', hostInstalls], ['coordinator', coordinator], @@ -500,6 +505,7 @@ const withMcpSessionLifecycle = ( logs: DevLogService, detachProjectLogs: () => void, inspector: Closeable, + epochAdoption: EpochAdoptionPolicy, hostInstalls?: DevHostInstallManager, ): ForegroundCoordinator => Object.freeze({ close: () => { @@ -507,6 +513,7 @@ const withMcpSessionLifecycle = ( return closeDevServerLifecycle({ coordinator, detachProjectLogs, + epochAdoption, hostInstalls, inspector, logs, @@ -521,11 +528,8 @@ const withMcpSessionLifecycle = ( start: async () => { hostInstalls?.start(); await coordinator.start(); - const artifact = coordinator.status().artifact; - if (hostInstalls !== undefined && (artifact.state === 'active' || artifact.state === 'stale')) { - hostInstalls.sync(artifact.activeEpoch.id); - await hostInstalls.settled(); - } + await epochAdoption.settled(); + await hostInstalls?.settled(); await runtime?.start(); }, status, @@ -717,14 +721,6 @@ export const startDevServer = async (options: StartDevServerOptions): Promise Object.freeze({ ...coordinator.status(), ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }), @@ -735,7 +731,27 @@ export const startDevServer = async (options: StartDevServerOptions): Promise latestValidPreparedProject?.devContracts, + eventHub, + run: (epochId, contracts) => { + const prepared = latestValidPreparedProject; + if (prepared === undefined || prepared.devContracts !== contracts) { + throw new Error('Development contract preparation was superseded before its epoch run started.'); + } + return runDevEpochContracts({ contracts, epochId, mcpSessions, prepared }); + }, + }); + const hostInstalls = options.installHosts === undefined || options.installHosts.length === 0 + ? undefined + : new DevHostInstallManager({ + adoption: epochAdoption, + epochStore, + eventHub, + hosts: options.installHosts, + projectRoot: root, + }); + const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions }); const hookPlayground = new HookPlaygroundService({ epochStore, logger: logs, registry }); const preparedBundle = () => { const prepared = latestValidPreparedProject; @@ -846,6 +862,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise; + export interface ContractMatrixRestartSession { readonly client: Client; } @@ -170,6 +176,7 @@ export interface ContractRouteReport { } export type ContractMatrixProvenance = + | DevEpochMcpProvenance | InstalledHostMcpProvenance | McpProjectionProvenance | PackedMcpProvenance; @@ -196,6 +203,27 @@ export interface PackedContractMatrixOptions { readonly restart?: () => Promise; } +export interface DevEpochMcpProvenance { + readonly epochId: string; + readonly proofLevel: typeof DEV_EPOCH_PROOF_LEVEL; + readonly serverName: string; + readonly target: string; +} + +export interface DevEpochContractMatrixSession { + readonly client: ContractMatrixClient; + readonly provenance: DevEpochMcpProvenance; + readonly stderr: () => string; +} + +export interface DevEpochContractMatrixOptions { + readonly fixtures: Readonly>; + readonly manifest: AgentBundleTestManifest; + readonly server?: string; + /** An already-open epoch-pinned generated stdio session; this entry point never opens or closes it. */ + readonly session: DevEpochContractMatrixSession; +} + export interface InstalledHostContractMatrixOptions { readonly fixtures: Readonly>; readonly manifest: AgentBundleTestManifest; @@ -232,6 +260,9 @@ const PACKED_MODULE_SCHEMA_NOT_APPLICABLE_REASON = const INSTALLED_HOST_MODULE_SCHEMA_NOT_APPLICABLE_REASON = 'installed-host sessions cannot load project route modules without crossing back into the source/build tree; the installed server validates every tool result through its bundled resultSchema before returning — a successful sweep invocation is that evidence.'; +const DEV_EPOCH_MODULE_SCHEMA_NOT_APPLICABLE_REASON = + 'dev-epoch sessions run the generated server process and cannot load project route modules without crossing back into source; the generated server validates every tool result through its bundled resultSchema before returning — a successful sweep invocation is that evidence.'; + const IN_MEMORY_BOUNDARY: MatrixBoundaryCapabilities = Object.freeze({ canLoadRouteModules: true, eventRuntimeNotApplicableReason: 'the mcp-in-memory boundary has no generated event runtime.', @@ -274,6 +305,16 @@ const installedHostBoundaryFromSession = ( recovery: 'Fix the installed layout, route, or fixture; reinstall and re-run runInstalledHostContractMatrix.', }); +const DEV_EPOCH_BOUNDARY: MatrixBoundaryCapabilities = Object.freeze({ + canLoadRouteModules: false, + eventRuntimeNotApplicableReason: + 'the dev-epoch MCP session does not expose a generated event runtime endpoint.', + moduleSchemaNotApplicableReason: DEV_EPOCH_MODULE_SCHEMA_NOT_APPLICABLE_REASON, + proofLevel: DEV_EPOCH_PROOF_LEVEL, + registersAppResources: true, + recovery: 'Fix the failing route or fixture; rebuild so runDevEpochContractMatrix can prove the next epoch.', +}); + const COMPAT_PROBE_KEY = '__agentBundleContractProbe'; const CHECK_SURFACE = 'surface-completeness'; @@ -296,12 +337,28 @@ const CHECK_STATE_CATALOG = 'state-catalog'; const CHECK_RESTART_DURABILITY = 'restart-durability'; const CHECK_RUNTIME_INSTANCE_IDENTITY = 'runtime-instance-identity'; -interface MatrixFailure { +export interface ContractMatrixFailure { readonly check: string; readonly reason: string; readonly routeId: string; } +export class ContractMatrixViolationError extends AgentTestError { + readonly failures: readonly ContractMatrixFailure[]; + readonly report: ContractMatrixReport; + + constructor( + message: string, + failures: readonly ContractMatrixFailure[], + report: ContractMatrixReport, + options: { readonly details: readonly string[]; readonly recovery: string }, + ) { + super('contract-violation', message, options); + this.failures = Object.freeze(failures.map((failure) => Object.freeze({ ...failure }))); + this.report = report; + } +} + interface ToolListingEntry { readonly inputSchema?: Record; readonly name: string; @@ -484,7 +541,7 @@ const createRuntimeIdentityTracker = ( }; const recordFailure = ( - failures: MatrixFailure[], + failures: ContractMatrixFailure[], routeId: string, check: string, reason: string, @@ -493,7 +550,7 @@ const recordFailure = ( }; const outcomeFromCheck = ( - failures: MatrixFailure[], + failures: ContractMatrixFailure[], routeId: string, check: string, outcome: ContractCheckOutcome, @@ -534,7 +591,7 @@ const loadRouteModule = async ( return { ...module, resultSchema: module.resultSchema }; }; -const listLiveSurface = async (client: Client): Promise => { +const listLiveSurface = async (client: ContractMatrixClient): Promise => { const [tools, resources, prompts] = await Promise.all([ client.listTools(), client.listResources(), @@ -558,7 +615,7 @@ const invocationCacheKey = (name: string, input: unknown): string => `${name}\0${JSON.stringify(input ?? {})}`; const callToolResult = async ( - client: Client, + client: ContractMatrixClient, name: string, input: unknown, options?: { @@ -774,7 +831,7 @@ const checkSurfaceCompleteness = ( }; const runSweep = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, fixture: ContractRouteFixture, manifest: AgentBundleTestManifest, @@ -826,7 +883,7 @@ const runSweep = async ( }; const runSerializedRoundTrip = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, fixture: ContractRouteFixture, module: AgentRouteModule & { readonly resultSchema: { parse: (value: unknown) => unknown } }, @@ -858,7 +915,7 @@ const runSerializedRoundTrip = async ( }; const runCompatProbe = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, fixture: ContractRouteFixture, module: AgentRouteModule & { readonly resultSchema: { parse: (value: unknown) => unknown } }, @@ -911,7 +968,7 @@ const runVersionSkew = ( }; const runNegativeInputs = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, surface: LiveSurface, ): Promise => { @@ -951,7 +1008,7 @@ const runNegativeInputs = async ( }; const runCancellation = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, fixture: ContractRouteFixture, cache: Map, @@ -1051,7 +1108,7 @@ type ClientNotificationHandler = ( ) => void | Promise; const executeLifecycleTransitions = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, lifecycle: ContractLifecycleFixture, runtimeIdentity: RuntimeIdentityTracker, @@ -1246,7 +1303,7 @@ const checkStateCatalog = ( }; const runStateIdempotency = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, evidence: LifecycleEvidence, assertion: NonNullable['idempotency']>, @@ -1270,7 +1327,7 @@ const runStateIdempotency = async ( }; const runStateBudget = async ( - client: Client, + client: ContractMatrixClient, descriptor: TestableRouteDescriptor, evidence: LifecycleEvidence, assertion: NonNullable['budget']>, @@ -1303,7 +1360,7 @@ const matrixRouteDescriptors = ( ].sort((left, right) => left.id.localeCompare(right.id)); const finalizeContractMatrixReport = ( - failures: MatrixFailure[], + failures: ContractMatrixFailure[], boundary: MatrixBoundaryCapabilities, checks: Readonly>, provenance: ContractMatrixProvenance, @@ -1319,9 +1376,10 @@ const finalizeContractMatrixReport = ( const proofLabel = proofLevelLabel(boundary.proofLevel); const details = failures.map((entry) => `- ${entry.routeId} / ${entry.check}: ${entry.reason} (${proofLabel})`); - throw new AgentTestError( - 'contract-violation', + throw new ContractMatrixViolationError( `Contract matrix reported ${String(failures.length)} violation(s) at the ${boundary.proofLevel} proof level.`, + failures, + report, { details, recovery: boundary.recovery, @@ -1331,14 +1389,14 @@ const finalizeContractMatrixReport = ( const executeContractMatrix = async (options: { readonly boundary: MatrixBoundaryCapabilities; - readonly client: Client; + readonly client: ContractMatrixClient; readonly fixtures: Readonly>; readonly manifest: AgentBundleTestManifest; readonly provenance: ContractMatrixProvenance; readonly serverName: string; }): Promise => { const { boundary, client, fixtures, manifest, provenance, serverName } = options; - const failures: MatrixFailure[] = []; + const failures: ContractMatrixFailure[] = []; const matrixChecks: Record = {}; const routeReports: Record = {}; const runtimeIdentity = createRuntimeIdentityTracker(boundary, manifest, serverName); @@ -1685,6 +1743,25 @@ export const runContractMatrix = async ( } }; +/** + * Runs #218 stage 4's shared matrix against an already-open dev epoch session. + * The caller owns the epoch lease and process lifetime so host adoption can + * settle without replacing or dropping an existing live host connection. + */ +export const runDevEpochContractMatrix = async ( + options: DevEpochContractMatrixOptions, +): Promise => { + const serverName = resolveServerName(options.manifest, options.server); + return executeContractMatrix({ + boundary: DEV_EPOCH_BOUNDARY, + client: options.session.client, + fixtures: options.fixtures, + manifest: options.manifest, + provenance: options.session.provenance, + serverName, + }); +}; + /** * Runs the contract matrix against an already-open packed stdio session. * Never opens or closes the session; stamps the session's own proof level. diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index ac25b1e51..6489d69bb 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -1,7 +1,7 @@ /** * `agent-bundle/test` — the consumer test harness helpers. * - * Seven Node proof levels ship here, and the browser-safe eighth level ships + * Eight Node proof levels ship here, and the browser-safe ninth level ships * from `agent-bundle/test/browser`. The repository's real-host install proof * uses the same level convention. Each helper names the level it supplies, * stamps it into its provenance, and prints it in every failure: @@ -10,6 +10,7 @@ * | --- | --- | --- | * | `route-unit` | `renderRoute`, `renderRouteEvents`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer; explicit target-capability projection through the real MCP projector, without transport or host proof | * | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | the real generated MCP server's protocol contract, over the SDK's in-memory transport | + * | `dev-epoch` | `runDevEpochContractMatrix` | an epoch-pinned generated stdio process opened through the Workbench session service | * | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a compiled plain or rendered CLI command dispatched through the routed CLI's own shell, including rendered output modes, in this process | * | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | a built artifact's generated entry running as a real process over stdio | * | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | the packed stdio process still runs after project source and configuration are removed and verified absent | @@ -24,6 +25,7 @@ export { BROWSER_APP_PROOF_LEVEL, CLI_DISPATCH_PROOF_LEVEL, + DEV_EPOCH_PROOF_LEVEL, HOST_INSTALL_PROOF_LEVEL, MCP_IN_MEMORY_PROOF_LEVEL, PACKED_DELETED_SOURCE_PROOF_LEVEL, @@ -84,8 +86,10 @@ export { readMcpResource, } from './mcp.ts'; export { + ContractMatrixViolationError, negativeInputsFromJsonSchema, runContractMatrix, + runDevEpochContractMatrix, runInstalledHostContractMatrix, runPackedContractMatrix, } from './contract.ts'; @@ -96,10 +100,15 @@ export type { ContractLifecycleFixture, ContractLifecyclePhase, ContractLifecycleTransition, + ContractMatrixClient, ContractMatrixOptions, + ContractMatrixFailure, ContractMatrixProvenance, ContractMatrixReport, ContractMatrixRestartSession, + DevEpochContractMatrixOptions, + DevEpochContractMatrixSession, + DevEpochMcpProvenance, ContractRouteFixture, ContractRouteReport, InstalledHostContractMatrixOptions, diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index e99a6423b..17962505c 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -23,6 +23,9 @@ import type { * client over the SDK's in-memory transport pair. It proves the protocol * contract — registration, schemas, content projection — and proves * **nothing** about a process, stdout framing, or a packed artifact. + * - `dev-epoch` opens an epoch-pinned generated stdio entry through the + * Workbench session service and drives its real process. It proves the + * generated development artifact, not packed or native-host provenance. * - `cli-dispatch` runs an argv vector through the routed CLI's own shell over * the compiled command graph, in this process. It proves command * resolution, argv projection, and exit codes, not a spawned binary. @@ -45,6 +48,7 @@ import type { export type AgentTestProofLevel = | 'route-unit' | 'mcp-in-memory' + | 'dev-epoch' | 'cli-dispatch' | 'packed-stdio' | 'packed-deleted-source' @@ -54,6 +58,7 @@ export type AgentTestProofLevel = export const ROUTE_UNIT_PROOF_LEVEL = 'route-unit' as const; export const MCP_IN_MEMORY_PROOF_LEVEL = 'mcp-in-memory' as const; +export const DEV_EPOCH_PROOF_LEVEL = 'dev-epoch' as const; export const CLI_DISPATCH_PROOF_LEVEL = 'cli-dispatch' as const; export const PACKED_STDIO_PROOF_LEVEL = 'packed-stdio' as const; export const PACKED_DELETED_SOURCE_PROOF_LEVEL = 'packed-deleted-source' as const; @@ -72,6 +77,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { return 'route-unit (real Agent renderer; no transport, browser, or artifact)'; case 'mcp-in-memory': return 'mcp-in-memory (real generated MCP server + real client over the SDK in-memory transport; NOT process or packed-artifact evidence)'; + case 'dev-epoch': + return 'dev-epoch (epoch-pinned generated stdio entry spawned as a real process through the Workbench session service; NOT packed or native-host evidence)'; case 'cli-dispatch': return 'cli-dispatch (argv dispatched through the routed CLI shell in-process; NOT a spawned binary)'; case 'packed-stdio': diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts new file mode 100644 index 000000000..2f0e04909 --- /dev/null +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -0,0 +1,255 @@ +import { mkdir, symlink, writeFile } from 'node:fs/promises'; +import { get as httpGet, type IncomingMessage } from 'node:http'; +import { join } from 'node:path'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { expect, it } from '@rstest/core'; + +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; + +const cliEntry = join(import.meta.dirname, '..', 'bin', 'agent-bundle.js'); +const fixtureNodeModules = join(import.meta.dirname, '..', '..', '..', 'examples', 'audiobook-curator', 'node_modules'); + +const within = async ( + promise: Promise, + milliseconds = 30_000, + phase = 'operation', +): Promise => Promise.race([ + promise, + new Promise((_resolvePromise, rejectPromise) => { + setTimeout(() => rejectPromise(new Error(`${phase} timed out after ${milliseconds}ms.`)), milliseconds); + }), +]); + +const toolSource = (version: 'v1' | 'v3', projectRoot: string): string => [ + `// fixture: ${projectRoot}`, + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const config = { description: 'Reports the generated epoch version.' };", + 'export const inputSchema = z.object({ token: z.string() });', + 'export const resultSchema = z.object({ version: z.string() });', + '', + 'export default async function Version() {', + ` return createElement('agent-result', { value: { version: ${JSON.stringify(version)} } }, createElement('agent-text', null, ${JSON.stringify(version)}));`, + '}', + '', +].join('\n'); + +const contractFixtureSource = (routeId: string): string => [ + 'export default {', + ` ${JSON.stringify(routeId)}: { input: { token: 'fixture' }, resultCompat: 'closed' },`, + '};', + '', +].join('\n'); + +const writeProject = async (root: string, contracts: boolean): Promise => { + const source = join(root, 'src', 'mcp', 'fixture', 'tools', 'version.tsx'); + await Promise.all([ + mkdir(join(root, 'src', 'mcp', 'fixture', 'tools'), { recursive: true }), + symlink(fixtureNodeModules, join(root, 'node_modules'), 'dir'), + ]); + await Promise.all([ + writeFile(join(root, '.gitignore'), '.dist.stage-*\ndist/\n'), + writeFile(join(root, 'package.json'), JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'dev-contract-gate', + type: 'module', + version: '1.0.0', + })), + writeFile(source, toolSource('v1', root)), + writeFile(join(root, 'contract-fixtures.ts'), contractFixtureSource('tool:fixture/version')), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + ...(contracts + ? [" dev: { contracts: { fixtures: './contract-fixtures.ts', server: 'fixture' } },"] + : []), + " plugin: { name: 'dev-contract-gate', version: '1.0.0' },", + ' routes: { mcpCommands: true },', + " targets: ['portable'],", + '};', + '', + ].join('\n')), + ]); + return source; +}; + +const openProxy = async (root: string, url: string): Promise => { + const transport = new StdioClientTransport({ + args: [cliEntry, 'dev', 'proxy', '--root', root, '--server', 'fixture', '--url', url], + command: process.execPath, + stderr: 'pipe', + }); + const client = new Client({ name: 'dev-contract-adoption-test', version: '1.0.0' }); + await client.connect(transport); + return client; +}; + +const versionOf = async (client: Client): Promise => { + const result = await client.callTool({ arguments: { token: 'live' }, name: 'version' }); + return (result.structuredContent as { readonly version?: unknown } | undefined)?.version; +}; + +const foregroundCookie = async (url: string): Promise => { + const response = await fetch(`${url}/api/project/session`, { headers: { origin: url } }); + const cookie = response.headers.get('set-cookie')?.split(';', 1)[0]; + if (!response.ok || cookie === undefined) throw new Error('Could not open the project event stream.'); + return cookie; +}; + +const projectEvents = async (url: string): Promise; + until(marker: string): Promise; +}>> => { + const cookie = await foregroundCookie(url); + let received = ''; + let response: IncomingMessage | undefined; + let awaited: { readonly marker: string; readonly resolve: (value: string) => void } | undefined; + const opened = Promise.withResolvers(); + const request = httpGet(`${url}/api/project/events`, { headers: { cookie, origin: url } }, (stream) => { + response = stream; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + received += chunk; + if (awaited !== undefined && received.includes(awaited.marker)) awaited.resolve(received); + }); + opened.resolve(); + }); + request.once('error', opened.reject); + return Object.freeze({ + close: () => { + response?.destroy(); + request.destroy(); + }, + opened: opened.promise, + until: (marker: string) => received.includes(marker) + ? Promise.resolve(received) + : new Promise((resolve) => { awaited = { marker, resolve }; }), + }); +}; + +const waitFor = async (assertion: () => Promise, milliseconds = 30_000): Promise => within((async () => { + while (!await assertion()) await new Promise((resolve) => { setTimeout(resolve, 20); }); +})(), milliseconds); + +const waitForActive = async (server: Awaited>): Promise => waitFor(async () => { + const status = server.status(); + if (status.build.state === 'failed') { + throw new Error(`Initial development build failed: ${JSON.stringify(status.build.lastAttempt?.diagnostics)}`); + } + return status.artifact.state === 'active'; +}); + +it('keeps failed epochs inactive and adopts the next passing epoch on one live host connection', async () => { + const project = await createProjectFixture({ config: 'export default {};\n', files: {} }); + let client: Client | undefined; + let events: Awaited> | undefined; + let lateClient: Client | undefined; + let server: Awaited> | undefined; + try { + const source = await writeProject(project.root, true); + server = await startDevServer({ + open: false, + port: 0, + root: project.root, + }); + await waitForActive(server); + client = await openProxy(project.root, server.url); + events = await projectEvents(server.url); + await events.opened; + await within(events.until('"summary":"Development contract matrix passed."'), 30_000, 'initial matrix'); + + expect(await versionOf(client)).toBe('v1'); + let listChanged = 0; + client.setNotificationHandler('notifications/tools/list_changed', async () => { listChanged += 1; }); + + const initialEpoch = server.status().artifact; + if (initialEpoch.state !== 'active') throw new Error('Expected an initial active epoch.'); + await replaceWatchedSource( + project.root, + join(project.root, 'contract-fixtures.ts'), + contractFixtureSource('tool:fixture/unknown'), + ); + await waitFor(async () => { + const artifact = server?.status().artifact; + return artifact?.state === 'active' && artifact.activeEpoch.id !== initialEpoch.activeEpoch.id; + }); + const failedEpoch = server.status().artifact; + if (failedEpoch.state !== 'active') throw new Error('Expected the failed-contract build to publish an artifact.'); + const failedWire = await within(events.until('"summary":"Development contract matrix reported'), 30_000, 'failed matrix'); + + expect(failedWire).toContain('event: dev.contract.status'); + expect(failedWire).toContain('"state":"failed"'); + expect(failedWire).toContain('"routeId":"tool:fixture/unknown"'); + expect(failedWire).toContain('"checks":["coverage"]'); + await new Promise((resolve) => { setTimeout(resolve, 100); }); + expect(listChanged).toBe(0); + expect(await versionOf(client)).toBe('v1'); + lateClient = await openProxy(project.root, server.url); + expect(await versionOf(lateClient)).toBe('v1'); + + const changed = Promise.withResolvers(); + client.setNotificationHandler('notifications/tools/list_changed', async () => { + listChanged += 1; + changed.resolve(); + }); + await Promise.all([ + replaceWatchedSource(project.root, source, toolSource('v3', project.root)), + replaceWatchedSource( + project.root, + join(project.root, 'contract-fixtures.ts'), + contractFixtureSource('tool:fixture/version'), + ), + ]); + await within(changed.promise, 30_000, 'changed notification'); + expect(await versionOf(client)).toBe('v3'); + const passingEpoch = server.status().artifact; + if (passingEpoch.state !== 'active') throw new Error('Expected the repaired build to publish an artifact.'); + const passingWire = await within( + events.until(`"epochId":"${passingEpoch.activeEpoch.id}","failures":[],"state":"passed"`), + 30_000, + 'passing matrix', + ); + expect(passingWire).toContain('"state":"passed"'); + expect(listChanged).toBe(1); + } finally { + events?.close(); + await lateClient?.close().catch(() => undefined); + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 180_000); + +it('adopts artifact.available directly when development contracts are not declared', async () => { + const project = await createProjectFixture({ config: 'export default {};\n', files: {} }); + let client: Client | undefined; + let server: Awaited> | undefined; + try { + const source = await writeProject(project.root, false); + server = await startDevServer({ open: false, port: 0, root: project.root }); + await waitForActive(server); + client = await openProxy(project.root, server.url); + expect(await versionOf(client)).toBe('v1'); + const changed = Promise.withResolvers(); + client.setNotificationHandler('notifications/tools/list_changed', async () => changed.resolve()); + + await replaceWatchedSource(project.root, source, toolSource('v3', project.root)); + await within(changed.promise); + + expect(await versionOf(client)).toBe('v3'); + } finally { + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 120_000); diff --git a/packages/agent-bundle/tests/dev-contract-config.test.ts b/packages/agent-bundle/tests/dev-contract-config.test.ts new file mode 100644 index 000000000..0fcc4a48d --- /dev/null +++ b/packages/agent-bundle/tests/dev-contract-config.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from '@rstest/core'; + +import { ProjectService } from '../src/dev/project-service.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; + +const configSource = [ + 'export default {', + " dev: { contracts: { fixtures: './contract-fixtures.ts', server: 'fixture' } },", + " plugin: { name: 'dev-contract-fixture', version: '1.0.0' },", + '};', + '', +].join('\n'); + +it('loads validated development contract fixtures into the prepared project', async () => { + const project = await createProjectFixture({ + config: configSource, + files: { + 'contract-fixtures.ts': [ + 'export default {', + " 'mcp:fixture/tool:version': { input: {}, resultCompat: 'closed' },", + '};', + '', + ].join('\n'), + }, + }); + try { + const prepared = await new ProjectService({ root: project.root }).prepare('dev'); + + expect(prepared.devContracts).toMatchObject({ + fixtures: { + 'mcp:fixture/tool:version': { input: {}, resultCompat: 'closed' }, + }, + modulePath: expect.stringContaining('contract-fixtures.ts'), + server: 'fixture', + }); + expect(prepared.devContracts?.diagnostics).toEqual([]); + } finally { + await removeProjectFixture(project.root); + } +}, 30_000); + +it('retains a buildable prepared project when the fixture module shape is invalid', async () => { + const project = await createProjectFixture({ + config: configSource, + files: { + 'contract-fixtures.ts': 'export default [];\n', + }, + }); + try { + const prepared = await new ProjectService({ root: project.root }).prepare('dev'); + + expect(prepared.source.state).toBe('ready'); + expect(prepared.model).toBeDefined(); + expect(prepared.devContracts).toMatchObject({ + diagnostics: [{ + code: 'AB7005', + severity: 'error', + sourcePath: expect.stringContaining('contract-fixtures.ts'), + }], + modulePath: expect.stringContaining('contract-fixtures.ts'), + server: 'fixture', + }); + expect(prepared.diagnostics).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7005' }), + ])); + } finally { + await removeProjectFixture(project.root); + } +}, 30_000); + +it('leaves development contract preparation absent when the channel is not declared', async () => { + const project = await createProjectFixture(); + try { + const prepared = await new ProjectService({ root: project.root }).prepare('dev'); + expect(prepared.devContracts).toBeUndefined(); + } finally { + await removeProjectFixture(project.root); + } +}); diff --git a/packages/agent-bundle/tests/epoch-adoption-policy.test.ts b/packages/agent-bundle/tests/epoch-adoption-policy.test.ts new file mode 100644 index 000000000..32be1a3e1 --- /dev/null +++ b/packages/agent-bundle/tests/epoch-adoption-policy.test.ts @@ -0,0 +1,132 @@ +import { expect, it } from '@rstest/core'; + +import { + EpochAdoptionPolicy, + type EpochContractEvaluation, +} from '../src/dev/epoch-adoption-policy.ts'; +import { ProjectEventHub } from '../src/dev/events.ts'; +import type { ActiveArtifactStatus, ArtifactEpoch } from '../src/dev/types.ts'; + +const epoch = (id: string): ArtifactEpoch => Object.freeze({ + configDigest: `config-${id}`, + createdAt: '2026-09-02T12:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, + id, + manifestPath: `/project/.agent-bundle/epochs/${id}/manifest.json`, + modelDigest: `model-${id}`, + projectRevision: `source-${id}`, + targetDigests: { portable: `target-${id}` }, +}); + +const available = (value: ArtifactEpoch): ActiveArtifactStatus => Object.freeze({ + activeEpoch: value, + currentSourceRevision: value.projectRevision, + state: 'active', +}); + +const publish = (hub: ProjectEventHub, id: string): void => { + const value = epoch(id); + hub.publish({ epochId: id, payload: available(value), type: 'artifact.available' }); +}; + +const passed = (epochId: string): EpochContractEvaluation => Object.freeze({ + diagnostics: Object.freeze([]), + epochId, + failures: Object.freeze([]), + state: 'passed', + summary: 'Development contract matrix passed.', +}); + +it('adopts artifact epochs immediately when contracts are disabled', async () => { + const eventHub = new ProjectEventHub(); + const adopted: string[] = []; + const policy = new EpochAdoptionPolicy({ + contracts: () => undefined, + eventHub, + run: async () => { throw new Error('disabled contracts must not run'); }, + }); + policy.subscribe((epochId) => adopted.push(epochId)); + + publish(eventHub, 'epoch-1'); + await policy.settled(); + + expect(adopted).toEqual(['epoch-1']); + expect(eventHub.latestSequence).toBe(1); + await policy.close(); +}); + +it('adopts only passing contract epochs and publishes exact route check failures', async () => { + const eventHub = new ProjectEventHub(); + const adopted: string[] = []; + const statuses: unknown[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.contract.status') statuses.push(event.payload); + }); + const policy = new EpochAdoptionPolicy({ + contracts: () => ({ diagnostics: [], fixtures: {}, modulePath: '/project/fixtures.ts' }), + eventHub, + run: async (epochId) => epochId === 'epoch-1' + ? passed(epochId) + : Object.freeze({ + diagnostics: Object.freeze([]), + epochId, + failures: Object.freeze([Object.freeze({ + checks: Object.freeze(['version-quadruple', 'sweep']), + routeId: 'mcp:fixture/tool:version', + })]), + state: 'failed' as const, + summary: 'Development contract matrix reported 2 violations.', + }), + }); + policy.subscribe((epochId) => adopted.push(epochId)); + + publish(eventHub, 'epoch-1'); + await policy.settled(); + publish(eventHub, 'epoch-2'); + await policy.settled(); + + expect(adopted).toEqual(['epoch-1']); + expect(statuses).toEqual([ + expect.objectContaining({ epochId: 'epoch-1', state: 'passed' }), + expect.objectContaining({ + epochId: 'epoch-2', + failures: [{ + checks: ['version-quadruple', 'sweep'], + routeId: 'mcp:fixture/tool:version', + }], + state: 'failed', + }), + ]); + await policy.close(); +}); + +it('discards a superseded contract result and evaluates only the latest pending epoch', async () => { + const eventHub = new ProjectEventHub(); + const first = Promise.withResolvers(); + const runs: string[] = []; + const adopted: string[] = []; + const statuses: string[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.contract.status') statuses.push(event.epochId); + }); + const policy = new EpochAdoptionPolicy({ + contracts: () => ({ diagnostics: [], fixtures: {}, modulePath: '/project/fixtures.ts' }), + eventHub, + run: async (epochId) => { + runs.push(epochId); + return epochId === 'epoch-1' ? first.promise : passed(epochId); + }, + }); + policy.subscribe((epochId) => adopted.push(epochId)); + + publish(eventHub, 'epoch-1'); + publish(eventHub, 'epoch-2'); + publish(eventHub, 'epoch-3'); + first.resolve(passed('epoch-1')); + await policy.settled(); + + expect(runs).toEqual(['epoch-1', 'epoch-3']); + expect(statuses).toEqual(['epoch-3']); + expect(adopted).toEqual(['epoch-3']); + await policy.close(); +}); diff --git a/packages/workbench/src/logs/log-client.ts b/packages/workbench/src/logs/log-client.ts index 3555eaf30..c5af0aa1f 100644 --- a/packages/workbench/src/logs/log-client.ts +++ b/packages/workbench/src/logs/log-client.ts @@ -54,15 +54,15 @@ const devLogKinds = deepFreeze({ build: ['artifact.available', 'build.failed', 'build.started'], diagnostic: [ 'artifact.available.diagnostic', 'artifact.status.diagnostic', 'build.failed.diagnostic', 'build.started.diagnostic', - 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', - 'source.status.diagnostic', + 'dev.contract.status.diagnostic', 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'runtime.event.diagnostic', + 'source.changed.diagnostic', 'source.status.diagnostic', ], eval: ['eval.run.completed', 'eval.run.failed', 'eval.run.started'], hook: ['hook.simulate.completed', 'hook.simulate.failed', 'hook.simulate.started'], mcp: ['mcp.logging', 'mcp.stderr', 'mcp.operation.failed', 'mcp.operation.started', 'mcp.operation.succeeded'], playground: ['playground.event.appended'], project: [ - 'artifact.status', 'dev.host.sync', 'dev.shutdown.completed', 'dev.shutdown.started', 'invalidation', + 'artifact.status', 'dev.contract.status', 'dev.host.sync', 'dev.shutdown.completed', 'dev.shutdown.started', 'invalidation', 'project.events.replay-gap', 'project.invalid-source', 'project.load', 'project.prepared', 'runtime.event', 'source.changed', 'source.status', ], diff --git a/packages/workbench/tests/log-client.test.ts b/packages/workbench/tests/log-client.test.ts index f58e8f755..15a3842a6 100644 --- a/packages/workbench/tests/log-client.test.ts +++ b/packages/workbench/tests/log-client.test.ts @@ -63,6 +63,38 @@ it('accepts development host sync project and diagnostic log kinds', async () => })).replay()).resolves.toMatchObject({ records }); }); +it('accepts development contract status project and diagnostic log kinds', async () => { + const records = [ + { + ...record, + context: { epochId: 'epoch-1' }, + details: { + diagnostics: [], + epochId: 'epoch-1', + failures: [], + state: 'passed', + summary: 'Development contract matrix passed.', + }, + kind: 'dev.contract.status', + producer: 'project', + summary: 'Development contract matrix settled.', + }, + { + ...record, + context: { diagnosticCode: 'AB7006' }, + details: { code: 'AB7006', message: 'Contract matrix failed.', severity: 'error' }, + kind: 'dev.contract.status.diagnostic', + level: 'error', + producer: 'diagnostic', + sequence: 2, + summary: 'Project diagnostic was recorded.', + }, + ]; + await expect(clientFor(json({ + replay: { cursor: { afterSequence: 2 }, records }, + })).replay()).resolves.toMatchObject({ records }); +}); + it('rejects malformed or noncontiguous replay envelopes before exposing them to the page', async () => { await expect(clientFor(new Response('{')).replay()).rejects.toBeInstanceOf(LogClientError); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 5eab47715..35d9eaaa6 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -19,6 +19,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/build.test.ts', 'packages/agent-bundle/tests/cli-routes-build.test.ts', 'packages/agent-bundle/tests/cli.test.ts', + 'packages/agent-bundle/tests/dev-contract-adoption.test.ts', 'packages/agent-bundle/tests/dev-artifact-service.test.ts', 'packages/agent-bundle/tests/dev-host-install.test.ts', 'packages/agent-bundle/tests/dev-live-host.test.ts', From 98356d9325935010d63ea784c7d9633e51830b37 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:27:51 +0000 Subject: [PATCH 2/3] fix(dev): unique contract-gate codes, cold-start seeding, and Workbench host-adoption visibility (#218 stage 4) - AB7005 was already the Cursor install content-collision code; the contract gate now reports AB7210 (invalid dev.contracts declaration/fixture module) and AB7211 (matrix violations), documented in docs/diagnostics.md alongside AB7200-AB7202 and AB8024-AB8025. - EpochAdoptionPolicy.seed(): a failing initial build publishes no artifact.available, so the restored last-good epoch is run through the gate instead of leaving hosts with nothing. - ProjectStatus.hostAdoption snapshot (mode, adoptedEpochId, latest contracts evaluation) on startDevServer().status() and /api/project/status; the Workbench Overview renders it as "Host adoption" with the failed checks per route and folds gate diagnostics into Diagnostics. - Workbench project client listens to dev.contract.status so the Overview refreshes when the gate settles; host-adoption.e2e proves the failed/passed cycle in a real browser at 1440x900. - Shared dev-contract fixture project for the integration test and the e2e; drop the wall-clock negative sleep from the adoption test. --- .changeset/dev-epoch-contract-matrix.md | 2 +- docs/diagnostics.md | 24 +++++ docs/framework-mode.md | 35 +++++++ packages/agent-bundle/README.md | 24 +++-- .../agent-bundle/src/config/dev-contracts.ts | 2 +- .../agent-bundle/src/contracts/project.ts | 3 + .../src/dev/dev-contract-runner.ts | 2 +- .../src/dev/epoch-adoption-policy.ts | 62 +++++++++--- .../agent-bundle/src/dev/host-mcp-routes.ts | 5 +- packages/agent-bundle/src/dev/types.ts | 15 +++ .../agent-bundle/src/dev/workbench-server.ts | 15 ++- .../tests/dev-contract-adoption.test.ts | 95 +++++++----------- .../tests/dev-contract-config.test.ts | 4 +- .../tests/epoch-adoption-policy.test.ts | 71 ++++++++++++++ .../tests/support/dev-contract-project.ts | 79 +++++++++++++++ packages/workbench/src/main.tsx | 4 +- packages/workbench/src/overview-model.ts | 97 ++++++++++++++++++- packages/workbench/src/overview-page.tsx | 43 +++++++- packages/workbench/src/project-client.ts | 21 ++++ .../workbench/tests/host-adoption.e2e.test.ts | 77 +++++++++++++++ packages/workbench/tests/log-client.test.ts | 4 +- .../workbench/tests/overview-model.test.ts | 86 ++++++++++++++++ .../workbench/tests/overview-page.test.ts | 62 +++++++++++- rstest.integration-tests.ts | 1 + 24 files changed, 727 insertions(+), 106 deletions(-) create mode 100644 packages/agent-bundle/tests/support/dev-contract-project.ts create mode 100644 packages/workbench/tests/host-adoption.e2e.test.ts diff --git a/.changeset/dev-epoch-contract-matrix.md b/.changeset/dev-epoch-contract-matrix.md index 5f4d8d55e..61f78430c 100644 --- a/.changeset/dev-epoch-contract-matrix.md +++ b/.changeset/dev-epoch-contract-matrix.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Gate live host and development-install epoch adoption on an opt-in, project-declared contract matrix. +Gate live host and development-install epoch adoption on an opt-in, project-declared contract matrix (`dev.contracts`). Each published epoch runs the generated contract matrix through an epoch-pinned generated stdio session at the new `dev-epoch` proof level (`runDevEpochContractMatrix`); failing epochs stay inactive for live host MCP connections and `--install-host` installs while the last passing epoch keeps serving, and are reported on the `dev.contract.status` project event with `AB7210` (invalid declaration or fixture module) or `AB7211` (contract violations). `startDevServer().status()` and `/api/project/status` now carry a `hostAdoption` snapshot (`mode`, `adoptedEpochId`, latest `contracts` evaluation) that the Workbench Overview renders as **Host adoption**. A cold start whose initial build fails now seeds the restored last-good epoch through the same gate instead of leaving hosts without an epoch. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 17535049e..4b2f09ea9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -28,6 +28,7 @@ gate a build, a validation, or a dev rebuild. | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | +| `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | | `AB7300`–`AB7320` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, and static bytes-at-rest validation. | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | @@ -35,6 +36,7 @@ gate a build, a validation, or a dev rebuild. | `AB8215`–`AB8218` | Workbench read-only host discovery route. | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | +| `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | @@ -454,6 +456,28 @@ host CLI, repair a bundle, or perform a live protocol exchange. | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | | `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract; the loader-recognized entry remains `installed`. | Reinstall an invalid Cursor plugin or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +## Live development into hosts (`AB7200`–`AB7202`, `AB7210`–`AB7211`, `AB8024`–`AB8025`) + +`agent-bundle dev` keeps a host's one stdio MCP process connected while it +swaps the generated plugin behind it (`dev proxy`), re-syncs opted-in +development installs (`--install-host`) on every adopted epoch, and — when a +project declares `dev.contracts` — gates host-facing adoption on the +development contract matrix. Every failure on that path is a structured +diagnostic; none of them silently changes what a host serves. A failing gate +is not a build failure: the epoch publishes to the Workbench playground, and +the Overview page's **Host adoption** section names both the published and the +host-facing build together with the failed checks. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB7200` | error | A development rebuild could not be admitted: the coordinator is closed, closing, or not yet started. | Restart `agent-bundle dev`; no epoch changed. | +| `AB7201` | error | The prepare, lint, or artifact phase of a development rebuild threw instead of reporting diagnostics. The message names the phase and the underlying error. | Fix the named failure and save again; the last-good epoch stays active. | +| `AB7202` | error | Publishing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed. Pointers were rolled back to the previous generation and the failure was published on `dev.host.sync`. | Repair the host cache path or permissions named in the message; the next successful epoch re-syncs. | +| `AB7210` | error | `dev.contracts` is malformed, its `fixtures` module escapes the project root, cannot be loaded, or default-exports something other than route-id keyed `ContractRouteFixture` objects. Reported on `dev.contract.status` for the affected epoch; compilation is unaffected. | Correct `dev.contracts` or the fixture module and rebuild; host surfaces keep the last passing epoch meanwhile. | +| `AB7211` | error | The development contract matrix failed or could not complete for a published epoch. The message carries the aggregated `contract-violation` detail; `dev.contract.status` lists the failed check names grouped by route. That epoch is never adopted by live host connections or development installs. | Fix the failing route or fixture and rebuild; a passing epoch is adopted normally. | +| `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. | +| `AB8025` | error (MCP) | `agent-bundle dev proxy` found no running development server for the project (cold start or shutdown), so the host-facing connection fails closed rather than serving stale bytes. | Start `agent-bundle dev` for that project root; installed hooks and Skills remain in place. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 6f758d3d3..c550f929d 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -199,6 +199,41 @@ release-identity config rejects absolute paths. The per-invocation CLI subject to the same project-root containment check; absolute and external output roots are unsupported. +## Live development into hosts + +`agent-bundle dev` is the webpack-HMR analog for plugins that are installed +and in use in a real host. Three pieces make a rebuild reach the host without +the host ever seeing a disconnect: + +1. **A stable host-facing proxy.** `agent-bundle dev proxy --root + --server [--target ]` is the thin stdio process a host spawns + and holds. It forwards the developed plugin's MCP surface from the dev + server's `/mcp/host/` endpoint. On every adopted epoch the dev + server opens and primes a session on the new generated server, promotes it + behind the same connection, emits `notifications/tools/list_changed` (and + the resources/prompts equivalents the catalog advertises), lets in-flight + calls finish against the epoch they started on, then drains the old + session. A failed build changes nothing; a vanished epoch or stopped dev + server fails closed (`AB8024` / `AB8025`). +2. **Installed-host re-sync.** `agent-bundle dev --install-host ` + installs a marked development variant through the ordinary installer once, + pointing the host's MCP document at the proxy, then re-syncs hooks, Skills, + and MCP Apps into the host's own layout on every adopted epoch with atomic + generation swaps and rollback (`AB7202`). Hooks are spawned per event, so + they pick up the new epoch on their next invocation. +3. **A contract gate on adoption.** Declaring `dev.contracts` in + `agent-bundle.config.ts` runs the generated contract matrix against each + published epoch through an epoch-pinned generated stdio session before any + host-facing surface adopts it. A failing epoch stays inactive for hosts, + is reported on the `dev.contract.status` project event (`AB7210` for an + invalid declaration, `AB7211` for violations), and appears in the + Workbench Overview's **Host adoption** section beside the published + build. Playground sessions stay independently epoch-pinned. + +The package README's [Developer workbench](../packages/agent-bundle/README.md#developer-workbench) +section carries the exact commands, install layouts, and event payloads; +[Diagnostics](diagnostics.md) lists every code on this path. + ## Distribution `agent-bundle build` makes each target directory independently distributable. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 6d28b2654..77e850c89 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -237,16 +237,24 @@ export default { The module default-exports the same `Record` consumed by `runContractMatrix`. Agent Bundle reloads and validates it for every prepared epoch. An invalid -module does not fail compilation: it fails that epoch's contract run with a diagnostic instead. -Omitting `dev.contracts` leaves the matrix off and preserves direct `artifact.available` adoption. +module does not fail compilation: it fails that epoch's contract run with an `AB7210` diagnostic +instead. Omitting `dev.contracts` leaves the matrix off and preserves direct `artifact.available` +adoption. For an enabled project, each published epoch is exercised through an already-open, epoch-pinned -generated stdio session. Passing epochs atomically replace the server behind existing live host MCP -connections and refresh opted-in development host installs. Failing or timed-out epochs remain -inactive on those host-facing surfaces, leaving the last passing epoch connected and installed. -The Workbench project stream emits `dev.contract.status`; the Logs page includes its diagnostics and -the exact failed check names grouped by route. A later passing rebuild is adopted normally. -Workbench playground sessions remain independently epoch-pinned and are not gated by this matrix. +generated stdio session at the `dev-epoch` proof level. Passing epochs atomically replace the server +behind existing live host MCP connections and refresh opted-in development host installs. Failing or +timed-out epochs remain inactive on those host-facing surfaces (`AB7211`), leaving the last passing +epoch connected and installed. On a cold start whose initial build fails, the last-good epoch the +epoch store restored is run through the same gate before hosts serve it. + +The Workbench project stream emits `dev.contract.status`, and `status()` (and `/api/project/status`) +carries a `hostAdoption` snapshot — `mode` (`gated` or `direct`), the `adoptedEpochId` hosts serve, +and the latest `contracts` evaluation. The Overview page renders it as **Host adoption**: a failed +gate names the published build, the build hosts kept, and the failed check names grouped by route, +and folds the gate diagnostics into the Diagnostics table; the Logs page carries the same records. +A later passing rebuild is adopted normally. Workbench playground sessions remain independently +epoch-pinned and are not gated by this matrix. ### Live host MCP proxy diff --git a/packages/agent-bundle/src/config/dev-contracts.ts b/packages/agent-bundle/src/config/dev-contracts.ts index 323d30e16..8d4865059 100644 --- a/packages/agent-bundle/src/config/dev-contracts.ts +++ b/packages/agent-bundle/src/config/dev-contracts.ts @@ -20,7 +20,7 @@ export interface PreparedDevContractMatrix { } const diagnostic = (sourcePath: string, message: string): Diagnostic => Object.freeze({ - code: 'AB7005', + code: 'AB7210', message, recovery: 'Correct dev.contracts and its fixture module, then rebuild; contract failures do not invalidate the artifact.', severity: 'error', diff --git a/packages/agent-bundle/src/contracts/project.ts b/packages/agent-bundle/src/contracts/project.ts index 84b4382de..36f9139fb 100644 --- a/packages/agent-bundle/src/contracts/project.ts +++ b/packages/agent-bundle/src/contracts/project.ts @@ -11,6 +11,9 @@ export type { ArtifactStatus, BuildAttempt, BuildStatus, + DevContractFailure, + DevContractStatusEvent, + HostAdoptionStatus, Invalidation, JsonObject, JsonValue, diff --git a/packages/agent-bundle/src/dev/dev-contract-runner.ts b/packages/agent-bundle/src/dev/dev-contract-runner.ts index 3a063e282..aedffc9ce 100644 --- a/packages/agent-bundle/src/dev/dev-contract-runner.ts +++ b/packages/agent-bundle/src/dev/dev-contract-runner.ts @@ -25,7 +25,7 @@ export interface RunDevEpochContractsOptions { } const failureDiagnostic = (epochId: string, message: string): Diagnostic => Object.freeze({ - code: 'AB7006', + code: 'AB7211', message, recovery: 'Fix the failing route or fixture, then rebuild; host-facing surfaces keep the last passing epoch active.', severity: 'error', diff --git a/packages/agent-bundle/src/dev/epoch-adoption-policy.ts b/packages/agent-bundle/src/dev/epoch-adoption-policy.ts index 4038f9e33..c814ea028 100644 --- a/packages/agent-bundle/src/dev/epoch-adoption-policy.ts +++ b/packages/agent-bundle/src/dev/epoch-adoption-policy.ts @@ -7,6 +7,7 @@ import type { import type { DevContractFailure, DevContractStatusEvent, + HostAdoptionStatus, } from './types.ts'; export type EpochContractEvaluation = DevContractStatusEvent; @@ -45,7 +46,7 @@ interface PendingEpoch { } const runnerDiagnostic = (epochId: string, error: unknown): Diagnostic => Object.freeze({ - code: 'AB7006', + code: 'AB7211', message: `Development contract matrix failed for epoch ${epochId}: ${ error instanceof Error ? error.message : String(error) }`, @@ -73,6 +74,8 @@ export class EpochAdoptionPolicy implements EpochAdoptionSource { readonly #subscription: ProjectEventSubscription; #closed = false; #currentEpochId: string | undefined; + #latestEvaluation: EpochContractEvaluation | undefined; + #observed = false; #pending: PendingEpoch | undefined; #processing: Promise | undefined; #sequence = 0; @@ -84,21 +87,7 @@ export class EpochAdoptionPolicy implements EpochAdoptionSource { this.#subscription = options.eventHub.subscribe( { afterSequence: options.eventHub.latestSequence }, (event) => { - if (event.type !== 'artifact.available' || this.#closed) return; - const contracts = this.#contracts(); - if (contracts === undefined) { - this.#adopt(event.epochId); - return; - } - this.#sequence += 1; - this.#pending = Object.freeze({ - contracts, - epochId: event.epochId, - sequence: this.#sequence, - }); - this.#processing ??= this.#drain().finally(() => { - this.#processing = undefined; - }); + if (event.type === 'artifact.available') this.#consider(event.epochId); }, ); } @@ -107,6 +96,46 @@ export class EpochAdoptionPolicy implements EpochAdoptionSource { return this.#currentEpochId; } + /** The Workbench-facing snapshot of what hosts serve and why. */ + status(): HostAdoptionStatus { + return Object.freeze({ + ...(this.#currentEpochId === undefined ? {} : { adoptedEpochId: this.#currentEpochId }), + ...(this.#latestEvaluation === undefined ? {} : { contracts: this.#latestEvaluation }), + mode: this.#contracts() === undefined ? 'direct' : 'gated', + }); + } + + /** + * Considers an epoch that was already active before any `artifact.available` + * reached this policy — the cold-start last-good case, where a failing + * initial build publishes nothing but hosts must still serve the prior epoch. + * A no-op once any epoch has been observed. + */ + seed(epochId: string): void { + if (this.#closed || this.#observed) return; + this.#consider(epochId); + } + + #consider(epochId: string): void { + if (this.#closed) return; + this.#observed = true; + const contracts = this.#contracts(); + if (contracts === undefined) { + this.#latestEvaluation = undefined; + this.#adopt(epochId); + return; + } + this.#sequence += 1; + this.#pending = Object.freeze({ + contracts, + epochId, + sequence: this.#sequence, + }); + this.#processing ??= this.#drain().finally(() => { + this.#processing = undefined; + }); + } + subscribe(listener: EpochAdoptionListener): ProjectEventSubscription { if (this.#closed) throw new Error('Epoch adoption policy is closed.'); this.#listeners.add(listener); @@ -139,6 +168,7 @@ export class EpochAdoptionPolicy implements EpochAdoptionSource { evaluation = failedEvaluation(candidate.epochId, error); } if (this.#closed || candidate.sequence !== this.#sequence) continue; + this.#latestEvaluation = evaluation; this.#eventHub.publish({ epochId: candidate.epochId, payload: evaluation, diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index f8248b8e7..964dc3090 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -207,7 +207,10 @@ class HostMcpConnection { if (this.#activeEpochSession !== undefined) return; const adoptedEpochId = this.#adoption?.currentEpochId; if (this.#adoption !== undefined && adoptedEpochId === undefined) { - throw new Error('No contract-approved development epoch is available for this host connection.'); + throw new Error( + '[AB7211] No development epoch has passed the contract matrix yet, so this host connection has nothing to serve; ' + + 'fix the reported contract violations and rebuild.', + ); } const reference = adoptedEpochId === undefined ? await this.#epochStore.acquireActiveEpochReference() diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 24587f898..ab14012ef 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -244,9 +244,24 @@ export interface ProjectRuntimeTopology { readonly state: 'configured'; } +/** + * What live host MCP connections and opted-in development installs currently + * serve. Present only on a foreground that owns host-facing surfaces; absent + * from coordinator-only status. + */ +export interface HostAdoptionStatus { + /** The epoch host-facing surfaces serve; absent until one has been adopted. */ + readonly adoptedEpochId?: string; + /** The latest contract-matrix evaluation, present only when `dev.contracts` gates adoption. */ + readonly contracts?: DevContractStatusEvent; + /** `gated` when `dev.contracts` is declared; `direct` when hosts follow `artifact.available`. */ + readonly mode: 'direct' | 'gated'; +} + export interface ProjectStatus { readonly artifact: ArtifactStatus; readonly build: BuildStatus; + readonly hostAdoption?: HostAdoptionStatus; readonly runtime?: ProjectRuntimeTopology; readonly source: SourceStatus; } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 44f1862e9..01de56aee 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -528,6 +528,10 @@ const withMcpSessionLifecycle = ( start: async () => { hostInstalls?.start(); await coordinator.start(); + // A failing initial build publishes no artifact.available; hosts must still + // serve the last-good epoch the store restored, so seed it through the gate. + const artifact = coordinator.status().artifact; + if (artifact.state === 'active' || artifact.state === 'stale') epochAdoption.seed(artifact.activeEpoch.id); await epochAdoption.settled(); await hostInstalls?.settled(); await runtime?.start(); @@ -721,10 +725,6 @@ export const startDevServer = async (options: StartDevServerOptions): Promise Object.freeze({ - ...coordinator.status(), - ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }), - }); const mcpSessions = new McpSessionService({ epochStore, projectRoot: root, @@ -742,6 +742,11 @@ export const startDevServer = async (options: StartDevServerOptions): Promise Object.freeze({ + ...coordinator.status(), + hostAdoption: epochAdoption.status(), + ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }), + }); const hostInstalls = options.installHosts === undefined || options.installHosts.length === 0 ? undefined : new DevHostInstallManager({ @@ -952,7 +957,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise clientSurfaces.open(surfaceId), - status: () => coordinator.status(), + status, url: foreground.url, }); }; diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index 2f0e04909..815a56568 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -1,4 +1,3 @@ -import { mkdir, symlink, writeFile } from 'node:fs/promises'; import { get as httpGet, type IncomingMessage } from 'node:http'; import { join } from 'node:path'; @@ -8,10 +7,16 @@ import { expect, it } from '@rstest/core'; import { startDevServer } from '../src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { + DEV_CONTRACT_SERVER, + DEV_CONTRACT_TOOL_ROUTE, + devContractFixtureSource as contractFixtureSource, + devContractToolSource as toolSource, + writeDevContractProject, +} from './support/dev-contract-project.ts'; import { replaceWatchedSource } from './support/watched-files.ts'; const cliEntry = join(import.meta.dirname, '..', 'bin', 'agent-bundle.js'); -const fixtureNodeModules = join(import.meta.dirname, '..', '..', '..', 'examples', 'audiobook-curator', 'node_modules'); const within = async ( promise: Promise, @@ -24,67 +29,12 @@ const within = async ( }), ]); -const toolSource = (version: 'v1' | 'v3', projectRoot: string): string => [ - `// fixture: ${projectRoot}`, - "import { createElement } from 'react';", - "import { z } from 'zod';", - '', - "export const config = { description: 'Reports the generated epoch version.' };", - 'export const inputSchema = z.object({ token: z.string() });', - 'export const resultSchema = z.object({ version: z.string() });', - '', - 'export default async function Version() {', - ` return createElement('agent-result', { value: { version: ${JSON.stringify(version)} } }, createElement('agent-text', null, ${JSON.stringify(version)}));`, - '}', - '', -].join('\n'); - -const contractFixtureSource = (routeId: string): string => [ - 'export default {', - ` ${JSON.stringify(routeId)}: { input: { token: 'fixture' }, resultCompat: 'closed' },`, - '};', - '', -].join('\n'); - -const writeProject = async (root: string, contracts: boolean): Promise => { - const source = join(root, 'src', 'mcp', 'fixture', 'tools', 'version.tsx'); - await Promise.all([ - mkdir(join(root, 'src', 'mcp', 'fixture', 'tools'), { recursive: true }), - symlink(fixtureNodeModules, join(root, 'node_modules'), 'dir'), - ]); - await Promise.all([ - writeFile(join(root, '.gitignore'), '.dist.stage-*\ndist/\n'), - writeFile(join(root, 'package.json'), JSON.stringify({ - dependencies: { - '@agent-bundle/runtime': 'workspace:*', - '@modelcontextprotocol/server': '2.0.0', - react: '19.2.8', - zod: '4.4.3', - }, - name: 'dev-contract-gate', - type: 'module', - version: '1.0.0', - })), - writeFile(source, toolSource('v1', root)), - writeFile(join(root, 'contract-fixtures.ts'), contractFixtureSource('tool:fixture/version')), - writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - ...(contracts - ? [" dev: { contracts: { fixtures: './contract-fixtures.ts', server: 'fixture' } },"] - : []), - " plugin: { name: 'dev-contract-gate', version: '1.0.0' },", - ' routes: { mcpCommands: true },', - " targets: ['portable'],", - '};', - '', - ].join('\n')), - ]); - return source; -}; +const writeProject = async (root: string, contracts: boolean): Promise => + (await writeDevContractProject(root, { contracts })).toolSource; const openProxy = async (root: string, url: string): Promise => { const transport = new StdioClientTransport({ - args: [cliEntry, 'dev', 'proxy', '--root', root, '--server', 'fixture', '--url', url], + args: [cliEntry, 'dev', 'proxy', '--root', root, '--server', DEV_CONTRACT_SERVER, '--url', url], command: process.execPath, stderr: 'pipe', }); @@ -191,11 +141,24 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h expect(failedWire).toContain('"state":"failed"'); expect(failedWire).toContain('"routeId":"tool:fixture/unknown"'); expect(failedWire).toContain('"checks":["coverage"]'); - await new Promise((resolve) => { setTimeout(resolve, 100); }); expect(listChanged).toBe(0); expect(await versionOf(client)).toBe('v1'); lateClient = await openProxy(project.root, server.url); expect(await versionOf(lateClient)).toBe('v1'); + expect(server.status().hostAdoption).toEqual({ + adoptedEpochId: initialEpoch.activeEpoch.id, + contracts: { + diagnostics: [expect.objectContaining({ code: 'AB7211', severity: 'error', target: failedEpoch.activeEpoch.id })], + epochId: failedEpoch.activeEpoch.id, + failures: [ + { checks: ['coverage'], routeId: 'tool:fixture/unknown' }, + { checks: ['coverage'], routeId: DEV_CONTRACT_TOOL_ROUTE }, + ], + state: 'failed', + summary: 'Development contract matrix reported 2 violation(s).', + }, + mode: 'gated', + }); const changed = Promise.withResolvers(); client.setNotificationHandler('notifications/tools/list_changed', async () => { @@ -207,7 +170,7 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h replaceWatchedSource( project.root, join(project.root, 'contract-fixtures.ts'), - contractFixtureSource('tool:fixture/version'), + contractFixtureSource(DEV_CONTRACT_TOOL_ROUTE), ), ]); await within(changed.promise, 30_000, 'changed notification'); @@ -221,6 +184,11 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h ); expect(passingWire).toContain('"state":"passed"'); expect(listChanged).toBe(1); + expect(server.status().hostAdoption).toMatchObject({ + adoptedEpochId: passingEpoch.activeEpoch.id, + contracts: { epochId: passingEpoch.activeEpoch.id, failures: [], state: 'passed' }, + mode: 'gated', + }); } finally { events?.close(); await lateClient?.close().catch(() => undefined); @@ -247,6 +215,9 @@ it('adopts artifact.available directly when development contracts are not declar await within(changed.promise); expect(await versionOf(client)).toBe('v3'); + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected the rebuilt artifact to be active.'); + expect(server.status().hostAdoption).toEqual({ adoptedEpochId: artifact.activeEpoch.id, mode: 'direct' }); } finally { await client?.close().catch(() => undefined); await server?.close().catch(() => undefined); diff --git a/packages/agent-bundle/tests/dev-contract-config.test.ts b/packages/agent-bundle/tests/dev-contract-config.test.ts index 0fcc4a48d..9a5622ab5 100644 --- a/packages/agent-bundle/tests/dev-contract-config.test.ts +++ b/packages/agent-bundle/tests/dev-contract-config.test.ts @@ -53,7 +53,7 @@ it('retains a buildable prepared project when the fixture module shape is invali expect(prepared.model).toBeDefined(); expect(prepared.devContracts).toMatchObject({ diagnostics: [{ - code: 'AB7005', + code: 'AB7210', severity: 'error', sourcePath: expect.stringContaining('contract-fixtures.ts'), }], @@ -61,7 +61,7 @@ it('retains a buildable prepared project when the fixture module shape is invali server: 'fixture', }); expect(prepared.diagnostics).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7005' }), + expect.objectContaining({ code: 'AB7210' }), ])); } finally { await removeProjectFixture(project.root); diff --git a/packages/agent-bundle/tests/epoch-adoption-policy.test.ts b/packages/agent-bundle/tests/epoch-adoption-policy.test.ts index 32be1a3e1..c3111ffc1 100644 --- a/packages/agent-bundle/tests/epoch-adoption-policy.test.ts +++ b/packages/agent-bundle/tests/epoch-adoption-policy.test.ts @@ -100,6 +100,77 @@ it('adopts only passing contract epochs and publishes exact route check failures await policy.close(); }); +it('seeds a restored last-good epoch only until the first published epoch is observed', async () => { + const eventHub = new ProjectEventHub(); + const adopted: string[] = []; + const policy = new EpochAdoptionPolicy({ + contracts: () => undefined, + eventHub, + run: async () => { throw new Error('disabled contracts must not run'); }, + }); + policy.subscribe((epochId) => adopted.push(epochId)); + + expect(policy.status()).toEqual({ mode: 'direct' }); + policy.seed('epoch-restored'); + await policy.settled(); + expect(adopted).toEqual(['epoch-restored']); + expect(policy.status()).toEqual({ adoptedEpochId: 'epoch-restored', mode: 'direct' }); + + publish(eventHub, 'epoch-1'); + policy.seed('epoch-ignored'); + await policy.settled(); + + expect(adopted).toEqual(['epoch-restored', 'epoch-1']); + expect(policy.currentEpochId).toBe('epoch-1'); + await policy.close(); +}); + +it('runs the contract matrix over a seeded epoch and reports it in the status snapshot', async () => { + const eventHub = new ProjectEventHub(); + const runs: string[] = []; + const statuses: string[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.contract.status') statuses.push(event.epochId); + }); + const policy = new EpochAdoptionPolicy({ + contracts: () => ({ diagnostics: [], fixtures: {}, modulePath: '/project/fixtures.ts' }), + eventHub, + run: async (epochId) => { + runs.push(epochId); + return epochId === 'epoch-restored' + ? Object.freeze({ + diagnostics: Object.freeze([]), + epochId, + failures: Object.freeze([Object.freeze({ checks: Object.freeze(['sweep']), routeId: 'tool:fixture/version' })]), + state: 'failed' as const, + summary: 'Development contract matrix reported 1 violation(s).', + }) + : passed(epochId); + }, + }); + + expect(policy.status()).toEqual({ mode: 'gated' }); + policy.seed('epoch-restored'); + await policy.settled(); + + expect(runs).toEqual(['epoch-restored']); + expect(statuses).toEqual(['epoch-restored']); + expect(policy.currentEpochId).toBeUndefined(); + expect(policy.status()).toEqual({ + contracts: expect.objectContaining({ epochId: 'epoch-restored', state: 'failed' }), + mode: 'gated', + }); + + publish(eventHub, 'epoch-1'); + await policy.settled(); + expect(policy.status()).toEqual({ + adoptedEpochId: 'epoch-1', + contracts: passed('epoch-1'), + mode: 'gated', + }); + await policy.close(); +}); + it('discards a superseded contract result and evaluates only the latest pending epoch', async () => { const eventHub = new ProjectEventHub(); const first = Promise.withResolvers(); diff --git a/packages/agent-bundle/tests/support/dev-contract-project.ts b/packages/agent-bundle/tests/support/dev-contract-project.ts new file mode 100644 index 000000000..632006954 --- /dev/null +++ b/packages/agent-bundle/tests/support/dev-contract-project.ts @@ -0,0 +1,79 @@ +import { mkdir, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * A minimal route-mode project whose host-facing adoption can be gated by + * `dev.contracts`: one generated `fixture` server with one `version` tool and a + * project-local contract fixture module the dev server reloads per epoch. + */ +export interface DevContractProject { + readonly contractFixtures: string; + readonly toolSource: string; +} + +export const DEV_CONTRACT_SERVER = 'fixture'; +export const DEV_CONTRACT_TOOL_ROUTE = 'tool:fixture/version'; + +const fixtureNodeModules = join(import.meta.dirname, '..', '..', '..', '..', 'examples', 'audiobook-curator', 'node_modules'); + +export const devContractToolSource = (version: string, projectRoot: string): string => [ + `// fixture: ${projectRoot}`, + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const config = { description: 'Reports the generated epoch version.' };", + 'export const inputSchema = z.object({ token: z.string() });', + 'export const resultSchema = z.object({ version: z.string() });', + '', + 'export default async function Version() {', + ` return createElement('agent-result', { value: { version: ${JSON.stringify(version)} } }, createElement('agent-text', null, ${JSON.stringify(version)}));`, + '}', + '', +].join('\n'); + +export const devContractFixtureSource = (routeId: string): string => [ + 'export default {', + ` ${JSON.stringify(routeId)}: { input: { token: 'fixture' }, resultCompat: 'closed' },`, + '};', + '', +].join('\n'); + +export const writeDevContractProject = async ( + root: string, + options: { readonly contracts: boolean }, +): Promise => { + const toolSource = join(root, 'src', 'mcp', DEV_CONTRACT_SERVER, 'tools', 'version.tsx'); + const contractFixtures = join(root, 'contract-fixtures.ts'); + await Promise.all([ + mkdir(join(root, 'src', 'mcp', DEV_CONTRACT_SERVER, 'tools'), { recursive: true }), + symlink(fixtureNodeModules, join(root, 'node_modules'), 'dir'), + ]); + await Promise.all([ + writeFile(join(root, '.gitignore'), '.dist.stage-*\ndist/\n'), + writeFile(join(root, 'package.json'), JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'dev-contract-gate', + type: 'module', + version: '1.0.0', + })), + writeFile(toolSource, devContractToolSource('v1', root)), + writeFile(contractFixtures, devContractFixtureSource(DEV_CONTRACT_TOOL_ROUTE)), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + ...(options.contracts + ? [` dev: { contracts: { fixtures: './contract-fixtures.ts', server: ${JSON.stringify(DEV_CONTRACT_SERVER)} } },`] + : []), + " plugin: { name: 'dev-contract-gate', version: '1.0.0' },", + ' routes: { mcpCommands: true },', + " targets: ['portable'],", + '};', + '', + ].join('\n')), + ]); + return Object.freeze({ contractFixtures, toolSource }); +}; diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 0f0c0fccc..e65178f09 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -63,7 +63,7 @@ import { import { RoutesPage } from './routes/routes-page.tsx'; import { overviewFor } from './overview-model.ts'; import { downloadBlob, errorMessage as messageFrom } from './client-helpers.ts'; -import { BundleWorkflow } from './overview-page.tsx'; +import { BundleWorkflow, HostAdoptionSection } from './overview-page.tsx'; import { ProjectClient, type ProjectConnectionState } from './project-client.ts'; import { SkillClient } from './skill-client.ts'; import { SkillsPage } from './skills-page.tsx'; @@ -429,6 +429,8 @@ const Overview = ({ capabilities, changedFiles, client, connectionError, onNavig {error === undefined ? undefined :

{error}

} + +

Diagnostics ({overview.diagnostics.length})

{overview.diagnostics.length === 0 ?

No source or latest-build diagnostics.

: ( diff --git a/packages/workbench/src/overview-model.ts b/packages/workbench/src/overview-model.ts index 234d146a8..034d14eb1 100644 --- a/packages/workbench/src/overview-model.ts +++ b/packages/workbench/src/overview-model.ts @@ -1,5 +1,12 @@ import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; -import type { ArtifactEpoch, ArtifactState, ProjectStatus, SourceState } from '../../agent-bundle/src/contracts/project.ts'; +import type { + ArtifactEpoch, + ArtifactState, + DevContractFailure, + HostAdoptionStatus, + ProjectStatus, + SourceState, +} from '../../agent-bundle/src/contracts/project.ts'; import type { WorkbenchCapabilities } from './workbench-capabilities.ts'; import type { WorkbenchPage } from './workbench-screen.tsx'; @@ -37,10 +44,23 @@ export interface BundleSummary { readonly targetCount: number; } +export type OverviewHostAdoptionState = 'direct' | 'failed' | 'passed' | 'pending'; + +/** What live host connections and development installs serve, and why. */ +export interface OverviewHostAdoption { + readonly adoptedEpochId?: string; + readonly failures: readonly DevContractFailure[]; + readonly gateSummary?: string; + readonly mode: HostAdoptionStatus['mode']; + readonly state: OverviewHostAdoptionState; + readonly summary: string; +} + export interface OverviewModel { readonly changedFiles: readonly string[]; readonly diagnostics: readonly Diagnostic[]; readonly epoch: OverviewEpoch; + readonly hostAdoption?: OverviewHostAdoption; readonly nextAction: OverviewNextAction; readonly normalization: OverviewNormalization; readonly targets: readonly OverviewTarget[]; @@ -117,8 +137,71 @@ const targetsFor = (status: ProjectStatus): readonly OverviewTarget[] => { .sort((left, right) => left.name.localeCompare(right.name))); }; -const nextActionFor = (status: ProjectStatus, diagnostics: readonly Diagnostic[]): OverviewNextAction => { +const contractDiagnostics = (status: ProjectStatus): readonly Diagnostic[] => + status.hostAdoption?.contracts?.state === 'failed' ? status.hostAdoption.contracts.diagnostics : []; + +const contractViolationCount = (failures: readonly DevContractFailure[]): number => + failures.reduce((count, failure) => count + failure.checks.length, 0); + +/** + * A failed contract gate is a host-facing condition, not a build failure: the + * artifact published, but live hosts and development installs kept the last + * passing epoch. The summary names both epochs so the divergence is visible. + */ +const hostAdoptionFor = (status: ProjectStatus): OverviewHostAdoption | undefined => { + const adoption = status.hostAdoption; + if (adoption === undefined) return undefined; + const epoch = activeEpochFor(status); + const failures = adoption.contracts?.failures ?? []; + const shared = { + ...(adoption.adoptedEpochId === undefined ? {} : { adoptedEpochId: adoption.adoptedEpochId }), + failures: Object.freeze(failures.map((failure) => Object.freeze({ ...failure, checks: Object.freeze([...failure.checks]) }))), + ...(adoption.contracts === undefined ? {} : { gateSummary: adoption.contracts.summary }), + mode: adoption.mode, + }; + if (adoption.mode === 'direct') { + return Object.freeze({ + ...shared, + state: 'direct', + summary: adoption.adoptedEpochId === undefined + ? 'Hosts adopt each published build directly; none has been published yet' + : 'Hosts serve the published build directly', + }); + } + if (adoption.contracts === undefined) { + return Object.freeze({ ...shared, state: 'pending', summary: 'Contract matrix has not settled for a published build yet' }); + } + if (adoption.contracts.state === 'passed') { + return Object.freeze({ + ...shared, + state: 'passed', + summary: adoption.contracts.epochId === epoch?.id + ? 'Contract matrix passed; hosts serve the current build' + : `Contract matrix passed for build ${adoption.contracts.epochId}`, + }); + } + const violations = contractViolationCount(failures); + const held = adoption.adoptedEpochId === undefined + ? 'no build is served to hosts' + : `hosts keep build ${adoption.adoptedEpochId}`; + return Object.freeze({ + ...shared, + state: 'failed', + summary: violations === 0 + ? `Contract matrix could not complete for build ${adoption.contracts.epochId}; ${held}` + : `Contract matrix failed for build ${adoption.contracts.epochId} with ${counted(violations, 'violation')}; ${held}`, + }); +}; + +const nextActionFor = ( + status: ProjectStatus, + diagnostics: readonly Diagnostic[], + hostAdoption: OverviewHostAdoption | undefined, +): OverviewNextAction => { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length; + if (hostAdoption?.state === 'failed' && errors > 0) { + return { label: 'Rebuild', summary: `Resolve ${errors} ${errors === 1 ? 'error' : 'errors'}, then rebuild; hosts keep the last passing build` }; + } if (errors > 0) return { label: 'Rebuild', summary: `Resolve ${errors} ${errors === 1 ? 'error' : 'errors'}, then rebuild` }; if (status.artifact.state === 'missing') return { label: 'Rebuild', summary: 'Create the first successful build' }; if (status.artifact.state === 'stale') return { label: 'Rebuild', summary: 'Rebuild the latest normalized source' }; @@ -126,12 +209,18 @@ const nextActionFor = (status: ProjectStatus, diagnostics: readonly Diagnostic[] }; export const overviewFor = (status: ProjectStatus, changedFiles: readonly string[] = []): OverviewModel => { - const diagnostics = uniqueDiagnostics([...status.source.diagnostics, ...buildDiagnostics(status)]); + const diagnostics = uniqueDiagnostics([ + ...status.source.diagnostics, + ...buildDiagnostics(status), + ...contractDiagnostics(status), + ]); + const hostAdoption = hostAdoptionFor(status); return Object.freeze({ changedFiles: Object.freeze([...changedFiles]), diagnostics, epoch: Object.freeze(epochFor(status)), - nextAction: Object.freeze(nextActionFor(status, diagnostics)), + ...(hostAdoption === undefined ? {} : { hostAdoption }), + nextAction: Object.freeze(nextActionFor(status, diagnostics, hostAdoption)), normalization: Object.freeze({ label: sourceLabel(status.source.state), ...(status.source.revision === undefined ? {} : { revision: status.source.revision }), diff --git a/packages/workbench/src/overview-page.tsx b/packages/workbench/src/overview-page.tsx index 46fda37c8..8a2d0f580 100644 --- a/packages/workbench/src/overview-page.tsx +++ b/packages/workbench/src/overview-page.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; import type { ProjectStatus } from '../../agent-bundle/src/contracts/project.ts'; -import { bundleSummaryFor, overviewFor } from './overview-model.ts'; +import { bundleSummaryFor, overviewFor, type OverviewHostAdoption } from './overview-model.ts'; import type { ProjectClient } from './project-client.ts'; import type { WorkbenchCapabilities } from './workbench-capabilities.ts'; import { Navigation, Topbar, type WorkbenchPage } from './workbench-screen.tsx'; @@ -39,6 +39,45 @@ const actionFor: Readonly { + if (hostAdoption === undefined) return undefined; + return
+

Host adoption

+
+ +
+ {hostAdoption.summary} +

+ {hostAdoption.mode === 'gated' + ? 'Live host connections and development installs adopt a build only after the development contract matrix passes.' + : 'Declare dev.contracts to gate host adoption on the development contract matrix.'} +

+
+
+
+
Host-facing build
{hostAdoption.adoptedEpochId ?? 'None adopted'}
+
Published build
{publishedEpochId ?? 'None published'}
+ {hostAdoption.gateSummary === undefined ? undefined :
Contract matrix
{hostAdoption.gateSummary}
} +
+ {hostAdoption.failures.length === 0 ? undefined : ( +
+ + {hostAdoption.failures.map((failure) => + + + )} +
RouteFailed checks
{failure.routeId}{failure.checks.join(', ')}
+ )} +
; +}; + /** A capability-aware entry point; authoritative build state remains below. */ export const BundleWorkflow = ({ capabilities, onNavigate }: { readonly capabilities?: Pick; @@ -120,6 +159,8 @@ export const Overview = ({ capabilities, changedFiles, client, connectionError,
{error === undefined ? undefined :

{error}

} + +

Diagnostics ({overview.diagnostics.length})

{overview.diagnostics.length === 0 ?

No source or latest-build diagnostics.

: ( diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts index 4193a61b2..8741353e5 100644 --- a/packages/workbench/src/project-client.ts +++ b/packages/workbench/src/project-client.ts @@ -8,6 +8,8 @@ import { type ArtifactStatus, type BuildAttempt, type BuildStatus, + type DevContractStatusEvent, + type HostAdoptionStatus, type Invalidation, type ProjectEvent, type ProjectEventOf, @@ -79,6 +81,7 @@ const projectEventTypes = [ 'artifact.status', 'build.failed', 'build.started', + 'dev.contract.status', 'invalidation', 'replay.gap', 'runtime.event', @@ -200,9 +203,27 @@ const artifactStatusSchema: z.ZodType = z.discriminatedUnion('st }), ]); +const devContractStatusSchema: z.ZodType = z.strictObject({ + diagnostics: z.array(diagnosticSchema), + epochId: z.string(), + failures: z.array(z.strictObject({ + checks: z.array(z.string()), + routeId: z.string(), + })), + state: z.enum(['failed', 'passed']), + summary: z.string(), +}); + +const hostAdoptionStatusSchema: z.ZodType = z.strictObject({ + adoptedEpochId: z.string().optional(), + contracts: devContractStatusSchema.optional(), + mode: z.enum(['direct', 'gated']), +}); + const projectStatusSchema: z.ZodType = z.strictObject({ artifact: artifactStatusSchema, build: buildStatusSchema, + hostAdoption: hostAdoptionStatusSchema.optional(), runtime: z.strictObject({ state: z.literal('configured') }).optional(), source: sourceStatusSchema, }); diff --git a/packages/workbench/tests/host-adoption.e2e.test.ts b/packages/workbench/tests/host-adoption.e2e.test.ts new file mode 100644 index 000000000..94ac781eb --- /dev/null +++ b/packages/workbench/tests/host-adoption.e2e.test.ts @@ -0,0 +1,77 @@ +import { expect } from '@rstest/playwright'; + +import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; +import { + DEV_CONTRACT_TOOL_ROUTE, + devContractFixtureSource, + writeDevContractProject, + type DevContractProject, +} from '../../agent-bundle/tests/support/dev-contract-project.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; +import { buildWorkbench, e2e, startWorkbenchDevServer, withWorkbenchServer } from './support/workbench-e2e.ts'; + +const browserTimeout = 30_000 * timeScale; + +interface ContractProjectFixture { + readonly project: DevContractProject; + readonly root: string; +} + +/** + * #218 stage 4 in the browser: a rebuild whose generated server no longer + * satisfies the declared contract publishes to the Workbench but must not be + * adopted by hosts, and the Overview has to say so rather than silently + * applying it. + */ +e2e('shows a failed contract gate on the Overview while hosts keep the last passing build', { timeout: 180_000 }, async ({ page }) => { + await buildWorkbench(); + await withWorkbenchServer>, ContractProjectFixture, void>({ + close: (server) => server.close(), + createProject: async () => { + const fixture = await createProjectFixture({ config: 'export default {};\n', files: {} }); + const project = await writeDevContractProject(fixture.root, { contracts: true }); + return Object.freeze({ project, root: fixture.root }); + }, + dispose: (fixture) => removeProjectFixture(fixture.root), + start: (fixture) => startWorkbenchDevServer({ root: fixture.root }), + }, async (server, fixture) => { + await page.goto(server.url); + await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); + + const timeout = { timeout: browserTimeout }; + const hostAdoption = page.locator('section.host-adoption'); + await expect(hostAdoption).toHaveAttribute('data-state', 'passed', timeout); + await expect(hostAdoption).toContainText('Contract matrix passed; hosts serve the current build', timeout); + const initial = server.status(); + if (initial.artifact.state !== 'active') throw new Error('Expected an active initial epoch.'); + await expect(hostAdoption.locator('dd.identifier').first()).toHaveText(initial.artifact.activeEpoch.id, timeout); + + await replaceWatchedSource(fixture.root, fixture.project.contractFixtures, devContractFixtureSource('tool:fixture/unknown')); + + await expect(hostAdoption).toHaveAttribute('data-state', 'failed', timeout); + await expect(hostAdoption).toContainText(`hosts keep build ${initial.artifact.activeEpoch.id}`, timeout); + const violations = hostAdoption.getByRole('table', { name: 'Contract violations' }); + await expect(violations).toContainText('tool:fixture/unknown', timeout); + await expect(violations).toContainText('coverage', timeout); + await expect(page.getByRole('heading', { name: /^Diagnostics \(1\)$/u })).toBeVisible(timeout); + await expect(page.locator('section[aria-labelledby="diagnostics-heading"] table')).toContainText('AB7211', timeout); + + const failed = server.status(); + if (failed.artifact.state !== 'active') throw new Error('Expected the failed-contract build to publish an artifact.'); + expect(failed.artifact.activeEpoch.id).not.toBe(initial.artifact.activeEpoch.id); + expect(failed.hostAdoption).toMatchObject({ + adoptedEpochId: initial.artifact.activeEpoch.id, + contracts: { epochId: failed.artifact.activeEpoch.id, state: 'failed' }, + mode: 'gated', + }); + await expect(hostAdoption.locator('dd.identifier').nth(0)).toHaveText(initial.artifact.activeEpoch.id, timeout); + await expect(hostAdoption.locator('dd.identifier').nth(1)).toHaveText(failed.artifact.activeEpoch.id, timeout); + + await replaceWatchedSource(fixture.root, fixture.project.contractFixtures, devContractFixtureSource(DEV_CONTRACT_TOOL_ROUTE)); + + await expect(hostAdoption).toHaveAttribute('data-state', 'passed', timeout); + await expect(hostAdoption).toContainText('Contract matrix passed; hosts serve the current build', timeout); + await expect(page.getByRole('heading', { name: /^Diagnostics \(0\)$/u })).toBeVisible(timeout); + }); +}); diff --git a/packages/workbench/tests/log-client.test.ts b/packages/workbench/tests/log-client.test.ts index 15a3842a6..336b2a982 100644 --- a/packages/workbench/tests/log-client.test.ts +++ b/packages/workbench/tests/log-client.test.ts @@ -81,8 +81,8 @@ it('accepts development contract status project and diagnostic log kinds', async }, { ...record, - context: { diagnosticCode: 'AB7006' }, - details: { code: 'AB7006', message: 'Contract matrix failed.', severity: 'error' }, + context: { diagnosticCode: 'AB7211' }, + details: { code: 'AB7211', message: 'Contract matrix failed.', severity: 'error' }, kind: 'dev.contract.status.diagnostic', level: 'error', producer: 'diagnostic', diff --git a/packages/workbench/tests/overview-model.test.ts b/packages/workbench/tests/overview-model.test.ts index 2152dcc9d..edf3accfb 100644 --- a/packages/workbench/tests/overview-model.test.ts +++ b/packages/workbench/tests/overview-model.test.ts @@ -73,6 +73,92 @@ it('uses only real diagnostics to explain a stale epoch and its next rebuild act expect(overview.nextAction).toEqual({ label: 'Rebuild', summary: 'Resolve 1 error, then rebuild' }); }); +const activeStatus = (epochId: string) => ({ + artifact: { + activeEpoch: { + configDigest: 'config', + createdAt: '2026-08-14T12:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, + id: epochId, + manifestPath: 'agent-bundle.manifest.json', + modelDigest: 'model', + projectRevision: 'revision-2', + targetDigests: { portable: 'portable-digest' }, + }, + currentSourceRevision: 'revision-2', + state: 'active' as const, + }, + build: { state: 'idle' as const }, + source: { diagnostics: [], revision: 'revision-2', state: 'ready' as const }, +}); + +it('omits host adoption when the foreground reports none', () => { + expect(overviewFor(activeStatus('epoch-1')).hostAdoption).toBeUndefined(); +}); + +it('surfaces a failed contract gate as host-facing diagnostics while the build itself is current', () => { + const overview = overviewFor({ + ...activeStatus('epoch-2'), + hostAdoption: { + adoptedEpochId: 'epoch-1', + contracts: { + diagnostics: [{ + code: 'AB7211', + message: 'Contract matrix reported 2 violation(s) at the dev-epoch proof level.', + severity: 'error', + target: 'epoch-2', + }], + epochId: 'epoch-2', + failures: [{ checks: ['coverage', 'sweep'], routeId: 'tool:fixture/version' }], + state: 'failed', + summary: 'Development contract matrix reported 2 violation(s).', + }, + mode: 'gated', + }, + }); + + expect(overview.epoch).toMatchObject({ id: 'epoch-2', state: 'active', summary: 'Current build' }); + expect(overview.hostAdoption).toEqual({ + adoptedEpochId: 'epoch-1', + failures: [{ checks: ['coverage', 'sweep'], routeId: 'tool:fixture/version' }], + gateSummary: 'Development contract matrix reported 2 violation(s).', + mode: 'gated', + state: 'failed', + summary: 'Contract matrix failed for build epoch-2 with 2 violations; hosts keep build epoch-1', + }); + expect(overview.diagnostics).toEqual([expect.objectContaining({ code: 'AB7211', target: 'epoch-2' })]); + expect(overview.nextAction).toEqual({ + label: 'Rebuild', + summary: 'Resolve 1 error, then rebuild; hosts keep the last passing build', + }); +}); + +it('describes passing, pending, and direct host adoption without adding diagnostics', () => { + const passed = overviewFor({ + ...activeStatus('epoch-2'), + hostAdoption: { + adoptedEpochId: 'epoch-2', + contracts: { diagnostics: [], epochId: 'epoch-2', failures: [], state: 'passed', summary: 'Development contract matrix passed.' }, + mode: 'gated', + }, + }); + expect(passed.hostAdoption).toMatchObject({ state: 'passed', summary: 'Contract matrix passed; hosts serve the current build' }); + expect(passed.diagnostics).toEqual([]); + + const pending = overviewFor({ ...activeStatus('epoch-2'), hostAdoption: { mode: 'gated' } }); + expect(pending.hostAdoption).toMatchObject({ failures: [], state: 'pending' }); + + const direct = overviewFor({ ...activeStatus('epoch-2'), hostAdoption: { adoptedEpochId: 'epoch-2', mode: 'direct' } }); + expect(direct.hostAdoption).toEqual({ + adoptedEpochId: 'epoch-2', + failures: [], + mode: 'direct', + state: 'direct', + summary: 'Hosts serve the published build directly', + }); + expect(direct.nextAction).toEqual({ label: 'Rebuild', summary: 'The current build matches your source' }); +}); + it('projects a detached immutable changed-file list and defaults absent browser activity to empty', () => { const status = { artifact: { state: 'missing' as const }, diff --git a/packages/workbench/tests/overview-page.test.ts b/packages/workbench/tests/overview-page.test.ts index 53b9b37a3..6f69a0a5f 100644 --- a/packages/workbench/tests/overview-page.test.ts +++ b/packages/workbench/tests/overview-page.test.ts @@ -3,8 +3,11 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { expect, it } from '@rstest/core'; -import { BundleWorkflow } from '../src/overview-page.tsx'; +import type { ProjectStatus } from '../../agent-bundle/src/contracts/project.ts'; +import { BundleWorkflow, Overview } from '../src/overview-page.tsx'; +import type { ProjectClient } from '../src/project-client.ts'; import type { WorkbenchCapabilities } from '../src/workbench-capabilities.ts'; +import type { WorkbenchPage } from '../src/workbench-screen.tsx'; const capabilities: Pick = { counts: { evalSuites: 1, hooks: 0, mcpServers: 0, scripts: 0, skills: 1, targets: 3 }, @@ -32,3 +35,60 @@ it('offers only unique actions supported by the current bundle', () => { expect(markup).not.toContain(' renderToStaticMarkup(createElement(Overview, { + changedFiles: [], + client: {} as unknown as ProjectClient, + onNavigate: () => undefined, + onStatus: () => undefined, + pages: new Set(['overview']), + status, +})); + +it('renders a failed host-adoption gate with its violations instead of silently applying the build', () => { + const markup = renderOverview({ + ...activeStatus, + hostAdoption: { + adoptedEpochId: 'epoch-1', + contracts: { + diagnostics: [{ code: 'AB7211', message: 'Contract matrix reported 1 violation(s).', severity: 'error', target: 'epoch-2' }], + epochId: 'epoch-2', + failures: [{ checks: ['coverage'], routeId: 'tool:fixture/unknown' }], + state: 'failed', + summary: 'Development contract matrix reported 1 violation(s).', + }, + mode: 'gated', + }, + }); + + expect(markup).toContain('Host adoption'); + expect(markup).toContain('data-state="failed"'); + expect(markup).toContain('Contract matrix failed for build epoch-2 with 1 violation; hosts keep build epoch-1'); + expect(markup).toContain('tool:fixture/unknown'); + expect(markup).toContain('coverage'); + expect(markup).toContain('AB7211'); + expect(markup).toContain('Diagnostics (1)'); +}); + +it('omits the host-adoption section when the foreground reports no host-facing surfaces', () => { + expect(renderOverview(activeStatus)).not.toContain('Host adoption'); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 35d9eaaa6..6ec14e2a6 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -67,6 +67,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/workbench/tests/discovery.e2e.test.ts', 'packages/workbench/tests/evals-real.e2e.test.ts', 'packages/workbench/tests/examples-real.e2e.test.ts', + 'packages/workbench/tests/host-adoption.e2e.test.ts', 'packages/workbench/tests/lifecycles-page.browser.test.tsx', 'packages/workbench/tests/lifecycles.e2e.test.ts', 'packages/workbench/tests/logs-real.e2e.test.ts', From 6e4b2a15b425c35684ea38e4be2ff5ed4d748036 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:35:38 +0000 Subject: [PATCH 3/3] docs(dev): describe the host-adoption gate in the architecture map and README dev command --- README.md | 2 +- docs/architecture/rsc-runtime-workbench.md | 13 +++++++++++++ packages/agent-bundle/README.md | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f9fcb4ff5..5b0977e13 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The same config also owns the npm package build — no second bundler config, bi - `build` — validate the project and write an artifact (plus the `bin`/`lib` package build when declared) - `validate` — check project source, or a built artifact with `--artifact ` - `inspect` — show the normalized configuration and per-target plans; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) -- `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change +- `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change; `--install-host ` installs a development variant whose stable `dev proxy` MCP command hot-swaps epochs behind the host's open connection and re-syncs hooks and Skills on every adopted rebuild (see [Framework mode › Live development into hosts](docs/framework-mode.md#live-development-into-hosts)) - `mcp list` / `mcp invoke` / `mcp run` — list, invoke, or run an artifact's MCP servers locally - `hooks list` / `hooks simulate` — inspect and simulate generated hooks - `eval` — run eval suites against a built artifact diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 2c4d87892..f54e5884b 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -227,6 +227,19 @@ 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. +Host-facing adoption is a further, separately gated axis (#179 / #218 stage 4). +`EpochAdoptionPolicy` sits between `artifact.available` and the two surfaces a +real host holds: `HostMcpRoutes` (the stable `/mcp/host/` endpoint the +`dev proxy` stdio process forwards) and `DevHostInstallManager` (the +`--install-host` generations). Without `dev.contracts` it forwards every +published epoch; with it, `runDevEpochContracts` opens an epoch-pinned generated +stdio session through `McpSessionService`, runs the shared contract matrix at +the `dev-epoch` proof level, publishes `dev.contract.status`, and adopts only a +passing epoch. Workbench playground sessions stay epoch-pinned on their own and +are never gated. `ProjectStatus.hostAdoption` exposes the adopted epoch and the +latest evaluation, and the Overview renders it as **Host adoption** beside the +published build, so a rejected epoch is visible rather than silently skipped. + The RSC result tree is not the MCP App document. A current preview moves through `McpAppPreview`, `SecureAppRenderer`, the official App renderer, the generation-bound bridge, and the runtime client-surface proxy to an opaque-origin diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 77e850c89..9eb71ce1f 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -246,7 +246,9 @@ generated stdio session at the `dev-epoch` proof level. Passing epochs atomicall behind existing live host MCP connections and refresh opted-in development host installs. Failing or timed-out epochs remain inactive on those host-facing surfaces (`AB7211`), leaving the last passing epoch connected and installed. On a cold start whose initial build fails, the last-good epoch the -epoch store restored is run through the same gate before hosts serve it. +epoch store restored is seeded through the same gate before hosts serve it; when the project no +longer prepares at all, the `dev.contracts` declaration cannot be read and the restored epoch is +adopted directly, exactly as an undeclared project would be. The Workbench project stream emits `dev.contract.status`, and `status()` (and `/api/project/status`) carries a `hostAdoption` snapshot — `mode` (`gated` or `direct`), the `adoptedEpochId` hosts serve,