diff --git a/.changeset/dev-epoch-contract-matrix.md b/.changeset/dev-epoch-contract-matrix.md
new file mode 100644
index 000000000..61f78430c
--- /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 (`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/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/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 935b9aa74..9eb71ce1f 100644
--- a/packages/agent-bundle/README.md
+++ b/packages/agent-bundle/README.md
@@ -218,6 +218,46 @@ 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 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 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 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,
+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
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..8d4865059
--- /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: 'AB7210',
+ 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/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/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..aedffc9ce
--- /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: 'AB7211',
+ 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..c814ea028
--- /dev/null
+++ b/packages/agent-bundle/src/dev/epoch-adoption-policy.ts
@@ -0,0 +1,208 @@
+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,
+ HostAdoptionStatus,
+} 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: 'AB7211',
+ 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;
+ #latestEvaluation: EpochContractEvaluation | undefined;
+ #observed = false;
+ #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.#consider(event.epochId);
+ },
+ );
+ }
+
+ get currentEpochId(): string | undefined {
+ 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);
+ 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.#latestEvaluation = evaluation;
+ 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..964dc3090 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,16 @@ 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(
+ '[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()
+ : await this.#epochStore.acquireEpochReference(adoptedEpochId);
this.#lastEpochId = reference.epoch.id;
try {
this.#activeEpochSession = await this.#openEpochSession(reference.epoch.id);
@@ -333,6 +349,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 +358,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 +391,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..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;
}
@@ -279,11 +294,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 +321,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..01de56aee 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,12 @@ 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 (hostInstalls !== undefined && (artifact.state === 'active' || artifact.state === 'stale')) {
- hostInstalls.sync(artifact.activeEpoch.id);
- await hostInstalls.settled();
- }
+ if (artifact.state === 'active' || artifact.state === 'stale') epochAdoption.seed(artifact.activeEpoch.id);
+ await epochAdoption.settled();
+ await hostInstalls?.settled();
await runtime?.start();
},
status,
@@ -717,25 +725,38 @@ 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 });
+ },
+ });
+ status = () => Object.freeze({
+ ...coordinator.status(),
+ hostAdoption: epochAdoption.status(),
+ ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }),
+ });
const hostInstalls = options.installHosts === undefined || options.installHosts.length === 0
? undefined
: new DevHostInstallManager({
+ adoption: epochAdoption,
epochStore,
eventHub,
hosts: options.installHosts,
projectRoot: root,
});
- status = () => Object.freeze({
- ...coordinator.status(),
- ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }),
- });
- const mcpSessions = new McpSessionService({
- epochStore,
- projectRoot: root,
- registry,
- traceSink: createMcpDevLogTraceSink(logs),
- });
- const hostMcp = new HostMcpRoutes({ epochStore, eventHub, mcpSessions });
+ const hostMcp = new HostMcpRoutes({ adoption: epochAdoption, epochStore, eventHub, mcpSessions });
const hookPlayground = new HookPlaygroundService({ epochStore, logger: logs, registry });
const preparedBundle = () => {
const prepared = latestValidPreparedProject;
@@ -846,6 +867,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise clientSurfaces.open(surfaceId),
- status: () => coordinator.status(),
+ status,
url: foreground.url,
});
};
diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts
index 5d409e1e2..42e91eb39 100644
--- a/packages/agent-bundle/src/index.ts
+++ b/packages/agent-bundle/src/index.ts
@@ -109,6 +109,7 @@ export type AgentBundleConfig = CoreAgentBundleConfig
export type {
AgentBundleConfigExtensions,
AgentBundleDevConfig,
+ AgentBundleDevContractsConfig,
AgentBundleHostConfig,
AgentBundleMcpApp,
AgentBundleMcpConfig,
diff --git a/packages/agent-bundle/src/test/contract.ts b/packages/agent-bundle/src/test/contract.ts
index 22f3d3bd9..01e85453c 100644
--- a/packages/agent-bundle/src/test/contract.ts
+++ b/packages/agent-bundle/src/test/contract.ts
@@ -49,6 +49,7 @@ import {
} from '../events/ipc.ts';
import { AgentTestError, captured } from './errors.ts';
import {
+ DEV_EPOCH_PROOF_LEVEL,
MCP_IN_MEMORY_PROOF_LEVEL,
proofLevelLabel,
type AgentBundleTestManifest,
@@ -145,6 +146,11 @@ export interface ContractRouteFixture {
readonly lifecycle?: ContractLifecycleFixture;
}
+export type ContractMatrixClient = Pick<
+ Client,
+ 'callTool' | 'getPrompt' | 'listPrompts' | 'listResources' | 'listTools' | 'readResource'
+>;
+
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..815a56568
--- /dev/null
+++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts
@@ -0,0 +1,226 @@
+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 {
+ 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 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 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', DEV_CONTRACT_SERVER, '--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"]');
+ 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 () => {
+ 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(DEV_CONTRACT_TOOL_ROUTE),
+ ),
+ ]);
+ 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);
+ 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);
+ 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');
+ 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);
+ 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..9a5622ab5
--- /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: 'AB7210',
+ 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: 'AB7210' }),
+ ]));
+ } 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..c3111ffc1
--- /dev/null
+++ b/packages/agent-bundle/tests/epoch-adoption-policy.test.ts
@@ -0,0 +1,203 @@
+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('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();
+ 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/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/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/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 : (
+
+ | Route | Failed checks |
+ {hostAdoption.failures.map((failure) =>
+ | {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 f58e8f755..336b2a982 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: 'AB7211' },
+ details: { code: 'AB7211', 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/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 5eab47715..6ec14e2a6 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',
@@ -66,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',