diff --git a/.changeset/soft-hosts-sync.md b/.changeset/soft-hosts-sync.md new file mode 100644 index 000000000..65469aae8 --- /dev/null +++ b/.changeset/soft-hosts-sync.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Keep opt-in Claude, Codex, and Cursor development installs synchronized with each successful dev epoch so hosts pick up changed Skills, Hooks, MCP Apps, and manifests without reinstalling. diff --git a/package.json b/package.json index cf47c0a63..fd88856b9 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:packed:native": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-native-smoke.test.ts", "test:packed:native:claude": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CLAUDE_SMOKE=1 pnpm test:packed:native", "test:packed:native:codex": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CODEX_SMOKE=1 pnpm test:packed:native", - "test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts", + "test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts packages/agent-bundle/tests/dev-host-install.test.ts", "test:host-install:build": "pnpm build && pnpm test:host-install", "test:host-install:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-host-install-proof.test.ts", "test:host-install:packed:build": "pnpm build && pnpm test:host-install:packed", diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 235f19825..ba88a52c6 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -146,7 +146,7 @@ artifact-bound MCP playground with the raw protocol trace, a hook playground tha wrapper, a durable ordered Playground trace with replay and export, and eval runs and comparisons. The same session is available programmatically through the public `startDevServer` export, which -accepts the options the CLI flags map to (`root`, `port`, `open`, `agentApi`) and resolves to a +accepts the options the CLI flags map to (`root`, `port`, `open`, `agentApi`, `installHosts`) and resolves to a `DevServerSession` exposing the loopback `url`, a `status()` snapshot, and `close()`: ```ts @@ -157,6 +157,35 @@ console.log(session.url); await session.close(); ``` +Pass `--install-host ` more than once to install development variants into +the selected hosts: + +```sh +agent-bundle dev --install-host cursor --install-host claude +``` + +The first successful epoch uses the ordinary host installer. Claude and Codex therefore register +the plugin normally and read its files from their host-owned +`plugins/cache///` directory; Cursor reads +`~/.cursor/plugins/local/`. The installed root contains +`.agent-bundle-dev.json` with schema version `1`, the project root, host, and installed epoch. +Its MCP document always launches +the framework CLI through the running dev server's Node executable as +`agent-bundle dev proxy --root --server --target `; +rebuilds never replace that stable command with an epoch path, and host process `PATH` contents do +not affect whether the project-local framework can be spawned. + +Each later `artifact.available` event copies the new target into an immutable installed generation. +Top-level directories switch by atomic symlink (or Windows junction) rename and top-level files by +atomic sibling-file rename, so a host sees an old or new complete entry and no synchronized +directory disappears between generations. A failed publication rolls pointers back to the prior +generation and emits an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no +`artifact.available`, so the last-good install is unchanged. Re-sync writes the host cache directly +and does not invoke the Claude or Codex CLI again. + +Stopping the dev server leaves the marked development install in place. Hooks and Skills remain on +disk, while the stable proxy command fails closed until that project dev server is running again. + `script.run` is a production-mounted, trusted-local Playground operation. It runs only the selected manifest-owned emitted script for the selected target, in a managed workspace, and preserves bounded stdout/stderr, exit, cancellation, and raw event references. Native prompts choose a server catalog diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index c421a1f74..06d22987d 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -128,6 +128,7 @@ interface JsonInputOptions { interface DevCommandOptions { readonly agentApi?: boolean; + readonly installHost: readonly InstallHost[]; readonly open?: boolean; readonly port?: number; readonly root: string; @@ -160,6 +161,9 @@ const installHost = (value: string): InstallHost => { throw new TypeError('Install host must be claude, codex, or cursor.'); }; +const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] => + [...previous, installHost(value)]; + const installScope = (value: string): InstallScope => { if (value === 'user' || value === 'project' || value === 'local') return value; throw new TypeError('Install scope must be user, project, or local.'); @@ -468,12 +472,14 @@ export const runCli = async ( .option('--port ', 'Loopback TCP port', port) .option('--agent-api', 'Enable the authenticated Agent API on /mcp') .option('--no-agent-api', 'Disable the authenticated Agent API on /mcp') + .option('--install-host ', 'Install and re-sync a development host (repeatable)', collectInstallHost, []) .option('--open', 'Open the workbench after the foreground server starts') .option('--no-open', 'Do not open the workbench after the foreground server starts'); devCommand.action(async (options: DevCommandOptions) => { const { startDevServer: start } = await import('./api.ts'); const session = await (dependencies.startDevServer ?? start)({ ...(options.agentApi === undefined ? {} : { agentApi: options.agentApi }), + installHosts: options.installHost, open: options.open === true, ...(options.port === undefined ? {} : { port: options.port }), root: options.root, diff --git a/packages/agent-bundle/src/dev/dev-proxy-command.ts b/packages/agent-bundle/src/dev/dev-proxy-command.ts new file mode 100644 index 000000000..21f8fbce1 --- /dev/null +++ b/packages/agent-bundle/src/dev/dev-proxy-command.ts @@ -0,0 +1,81 @@ +import { lstat, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +import type { InstallHost } from '../install/install.ts'; + +interface AgentBundlePackage { + readonly bin?: unknown; + readonly name?: unknown; +} + +const packageRootFor = async (modulePath: string): Promise> => { + let directory = dirname(modulePath); + for (;;) { + const packagePath = join(directory, 'package.json'); + try { + const document = JSON.parse(await readFile(packagePath, 'utf8')) as AgentBundlePackage; + if (document.name === 'agent-bundle') return Object.freeze({ document, root: directory }); + } catch (error) { + if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const parent = dirname(directory); + if (parent === directory) { + throw new Error('Cannot locate the installed agent-bundle package root.'); + } + directory = parent; + } +}; + +const binPath = (document: AgentBundlePackage): string | undefined => { + if (typeof document.bin === 'string') return document.bin; + if ( + typeof document.bin === 'object' && + document.bin !== null && + !Array.isArray(document.bin) && + typeof (document.bin as Record)['agent-bundle'] === 'string' + ) { + return (document.bin as Record)['agent-bundle']; + } + return undefined; +}; + +const resolveAgentBundleCliEntry = async (): Promise => { + const { document, root } = await packageRootFor(fileURLToPath(import.meta.url)); + const declaredBin = binPath(document); + if (declaredBin === undefined) { + throw new Error(`agent-bundle package at ${JSON.stringify(root)} does not declare its CLI bin entry.`); + } + const entry = resolve(root, declaredBin); + const metadata = await lstat(entry).catch(() => undefined); + if (metadata === undefined || !metadata.isFile()) { + throw new Error(`agent-bundle CLI entry ${JSON.stringify(entry)} does not exist as a regular file.`); + } + return entry; +}; + +/** The single stage-1 integration seam for host-facing development MCP commands. */ +export const devProxyServerCommand = async ( + projectRoot: string, + serverName: string, + host: InstallHost, +): Promise> => Object.freeze({ + args: Object.freeze([ + await resolveAgentBundleCliEntry(), + 'dev', + 'proxy', + '--root', + projectRoot, + '--server', + serverName, + '--target', + host, + ]), + command: process.execPath, +}); diff --git a/packages/agent-bundle/src/dev/events.ts b/packages/agent-bundle/src/dev/events.ts index cd1091d06..f29a93a7a 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'; +type EpochScopedProjectEventType = 'artifact.available' | 'dev.host.sync'; type ProjectEventInputFor = Readonly<{ readonly occurredAt?: string; @@ -80,11 +80,12 @@ const eventTypes = new Set([ 'build.failed', 'artifact.available', 'artifact.status', + 'dev.host.sync', 'runtime.event', ]); const requiresEpoch = (type: ProjectEventType): boolean => - type === 'artifact.available'; + type === 'artifact.available' || 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 new file mode 100644 index 000000000..610522509 --- /dev/null +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -0,0 +1,417 @@ +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { basename, join, relative, resolve } from 'node:path'; + +import { stableJson } from '../core/digest.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { + installBundle as defaultInstallBundle, + type InstallBundleOptions, + type InstallHost, + type InstallResult, +} from '../install/install.ts'; +import { devProxyServerCommand } from './dev-proxy-command.ts'; +import type { EpochReference, EpochStore } from './epoch-store.ts'; +import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; + +export const DEV_INSTALL_MARKER = '.agent-bundle-dev.json'; + +interface EpochReferenceSource { + acquireEpochReference(epochId: string): Promise>; +} + +export interface DevHostInstallManagerOptions { + readonly environment?: Readonly; + readonly epochStore: EpochReferenceSource | Pick; + readonly eventHub: ProjectEventHub; + readonly home?: string; + readonly hosts: readonly InstallHost[]; + readonly installBundle?: (options: InstallBundleOptions) => Promise; + readonly projectRoot: string; +} + +interface InstalledDevHost { + readonly destination: string; + readonly host: InstallHost; + epochId: string; +} + +interface DevInstallMarker { + readonly epochId: string; + readonly host: InstallHost; + readonly projectRoot: string; + readonly schemaVersion: 1; +} + +const mcpDocumentPath = (host: InstallHost): string => { + switch (host) { + case 'claude': + case 'codex': + return '.mcp.json'; + case 'cursor': + return 'mcp.json'; + default: { + const exhaustive: never = host; + throw new TypeError(`Unsupported development install host ${String(exhaustive)}.`); + } + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const rewriteMcpDocument = async ( + bundleRoot: string, + host: InstallHost, + projectRoot: string, +): Promise => { + const path = join(bundleRoot, mcpDocumentPath(host)); + let document: unknown; + try { + document = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + if (!isRecord(document) || !isRecord(document.mcpServers)) { + throw new TypeError(`Development ${host} MCP configuration must contain an mcpServers object.`); + } + const mcpServers = Object.fromEntries(await Promise.all( + Object.keys(document.mcpServers) + .sort((left, right) => left.localeCompare(right)) + .map(async (serverName) => [ + serverName, + { + ...(host === 'cursor' ? {} : { type: 'stdio' }), + ...await devProxyServerCommand(projectRoot, serverName, host), + }, + ] as const), + )); + await writeFile(path, `${stableJson({ ...document, mcpServers })}\n`, 'utf8'); +}; + +const marker = ( + epochId: string, + host: InstallHost, + projectRoot: string, +): DevInstallMarker => Object.freeze({ + epochId, + host, + projectRoot, + schemaVersion: 1, +}); + +const prepareDevBundle = async ( + source: string, + host: InstallHost, + epochId: string, + projectRoot: string, +): Promise Promise; readonly root: string }>> => { + const parent = await mkdtemp(join(tmpdir(), `agent-bundle-dev-${host}-`)); + const root = join(parent, 'bundle'); + try { + await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true }); + await rewriteMcpDocument(root, host, projectRoot); + await writeFile(join(root, DEV_INSTALL_MARKER), `${stableJson(marker(epochId, host, projectRoot))}\n`, 'utf8'); + return Object.freeze({ + cleanup: () => rm(parent, { force: true, recursive: true }), + root, + }); + } catch (error) { + await rm(parent, { force: true, recursive: true }); + throw error; + } +}; + +const installedDestination = ( + result: InstallResult, + home: string | undefined, + environment: Readonly, +): string => { + if (result.destination !== undefined) return result.destination; + const userHome = home ?? homedir(); + const cacheRoot = result.host === 'claude' + ? join(environment.CLAUDE_CONFIG_DIR ?? join(userHome, '.claude'), 'plugins', 'cache') + : result.host === 'codex' + ? join(environment.CODEX_HOME ?? join(userHome, '.codex'), 'plugins', 'cache') + : undefined; + if (cacheRoot === undefined || result.marketplace === undefined) { + throw new TypeError(`Cannot resolve the installed ${result.host} development bundle.`); + } + return join(cacheRoot, result.marketplace, result.plugin, result.version); +}; + +const pathExists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +}; + +const generationRoot = (destination: string, epochId: string): string => + join(destination, '.agent-bundle-dev', 'generations', epochId); + +const installGeneration = async ( + destination: string, + bundleRoot: string, + epochId: string, +): Promise => { + const generation = generationRoot(destination, epochId); + await rm(generation, { force: true, recursive: true }); + await mkdir(generation, { recursive: true }); + for (const entry of await readdir(bundleRoot, { withFileTypes: true })) { + await cp(join(bundleRoot, entry.name), join(generation, entry.name), { + errorOnExist: true, + force: false, + recursive: entry.isDirectory(), + verbatimSymlinks: true, + }); + } +}; + +const publishDirectoryPointer = async ( + destination: string, + entryName: string, + epochId: string, +): Promise => { + const path = join(destination, entryName); + const target = relative(destination, join(generationRoot(destination, epochId), entryName)); + const temporary = join(destination, `.${basename(entryName)}.dev-link-${process.pid}-${crypto.randomUUID()}`); + const movedAside = join(destination, `.${basename(entryName)}.dev-previous-${process.pid}-${crypto.randomUUID()}`); + await symlink(target, temporary, process.platform === 'win32' ? 'junction' : 'dir'); + let moved = false; + try { + const metadata = await lstat(path).catch(() => undefined); + if (metadata !== undefined && !metadata.isSymbolicLink()) { + await rename(path, movedAside); + moved = true; + } + try { + await rename(temporary, path); + } catch (error) { + if (moved) await rename(movedAside, path); + throw error; + } + if (moved) await rm(movedAside, { force: true, recursive: true }); + } finally { + await rm(temporary, { force: true, recursive: true }); + await rm(movedAside, { force: true, recursive: true }); + } +}; + +const publishFile = async ( + destination: string, + source: string, + entryName: string, +): Promise => { + const temporary = join(destination, `.${basename(entryName)}.dev-file-${process.pid}-${crypto.randomUUID()}`); + await cp(source, temporary, { errorOnExist: true, force: false }); + try { + await rename(temporary, join(destination, entryName)); + } finally { + await rm(temporary, { force: true }); + } +}; + +/** + * Publishes each top-level artifact entry independently. Directories are + * immutable epoch generations selected by an atomic symlink rename; files are + * complete sibling copies selected by an atomic file rename. + */ +const publishInstalledGeneration = async ( + destination: string, + epochId: string, +): Promise => { + const generation = generationRoot(destination, epochId); + const entries = await readdir(generation, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.isDirectory()) { + await publishDirectoryPointer(destination, entry.name, epochId); + } else if (entry.isFile()) { + await publishFile(destination, join(generation, entry.name), entry.name); + } else { + throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is not a regular file or directory.`); + } + } +}; + +const publishDevGeneration = async ( + destination: string, + bundleRoot: string, + epochId: string, +): Promise => { + await installGeneration(destination, bundleRoot, epochId); + await publishInstalledGeneration(destination, epochId); +}; + +const pruneGenerations = async ( + destination: string, + retainedEpochIds: readonly string[], +): Promise => { + const root = join(destination, '.agent-bundle-dev', 'generations'); + const retained = new Set(retainedEpochIds); + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory() && !retained.has(entry.name)) { + await rm(join(root, entry.name), { force: true, recursive: true }); + } + } +}; + +const syncDiagnostic = (host: InstallHost, epochId: string, error: unknown): Diagnostic => Object.freeze({ + code: 'AB7202', + message: `Failed to sync ${host} development install to epoch ${epochId}: ${ + error instanceof Error ? error.message : String(error) + }`, + severity: 'error', + target: host, +}); + +/** Owns opt-in host development installs for one foreground dev session. */ +export class DevHostInstallManager { + readonly #epochStore: EpochReferenceSource; + readonly #environment: Readonly; + readonly #eventHub: ProjectEventHub; + readonly #home: string | undefined; + readonly #hosts: readonly InstallHost[]; + readonly #installBundle: (options: InstallBundleOptions) => Promise; + readonly #installed = new Map(); + readonly #projectRoot: string; + #closed = false; + #pending: Promise = Promise.resolve(); + #subscription: ProjectEventSubscription | undefined; + + constructor(options: DevHostInstallManagerOptions) { + this.#epochStore = options.epochStore; + this.#environment = options.environment ?? process.env; + this.#eventHub = options.eventHub; + this.#home = options.home; + this.#hosts = Object.freeze([...new Set(options.hosts)]); + this.#installBundle = options.installBundle ?? defaultInstallBundle; + this.#projectRoot = resolve(options.projectRoot); + } + + 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); + }, + ); + } + + sync(epochId: string): void { + if (this.#closed) return; + this.#pending = this.#pending.then(async () => { + const reference = await this.#epochStore.acquireEpochReference(epochId); + try { + for (const host of this.#hosts) { + if (this.#installed.get(host)?.epochId === epochId) continue; + try { + await this.#syncHost(reference.root, epochId, host); + this.#eventHub.publish({ + epochId, + payload: Object.freeze({ + diagnostics: Object.freeze([]), + epochId, + host, + state: 'succeeded' as const, + }), + type: 'dev.host.sync', + }); + } catch (error) { + this.#eventHub.publish({ + epochId, + payload: Object.freeze({ + diagnostics: Object.freeze([syncDiagnostic(host, epochId, error)]), + epochId, + host, + state: 'failed' as const, + }), + type: 'dev.host.sync', + }); + } + } + } finally { + await reference.close(); + } + }).catch((error: unknown) => { + for (const host of this.#hosts) { + this.#eventHub.publish({ + epochId, + payload: Object.freeze({ + diagnostics: Object.freeze([syncDiagnostic(host, epochId, error)]), + epochId, + host, + state: 'failed' as const, + }), + type: 'dev.host.sync', + }); + } + }); + } + + settled(): Promise { + return this.#pending; + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#subscription?.unsubscribe(); + this.#subscription = undefined; + await this.#pending; + } + + async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise { + const prepared = await prepareDevBundle(join(epochRoot, host), host, epochId, this.#projectRoot); + try { + let installed = this.#installed.get(host); + if (installed === undefined) { + const result = await this.#installBundle({ + from: prepared.root, + ...(this.#home === undefined ? {} : { home: this.#home }), + host, + scope: 'user', + }); + installed = { + destination: installedDestination(result, this.#home, this.#environment), + epochId: '', + host, + }; + this.#installed.set(host, installed); + } + const previousEpochId = installed.epochId; + try { + await publishDevGeneration(installed.destination, prepared.root, epochId); + } catch (error) { + if (previousEpochId.length > 0 && await pathExists(generationRoot(installed.destination, previousEpochId))) { + await publishInstalledGeneration(installed.destination, previousEpochId); + } + await rm(generationRoot(installed.destination, epochId), { force: true, recursive: true }); + throw error; + } + installed.epochId = epochId; + await pruneGenerations( + installed.destination, + previousEpochId.length === 0 ? [epochId] : [previousEpochId, epochId], + ); + } finally { + await prepared.cleanup(); + } + } +} 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 65480645e..9dca8e4a4 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts @@ -21,7 +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', - 'invalidation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', 'source.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([ @@ -35,8 +36,9 @@ 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.shutdown.completed', 'dev.shutdown.started', 'invalidation', 'project.events.replay-gap', - 'project.invalid-source', 'project.load', 'project.prepared', 'runtime.event', 'source.changed', 'source.status', + 'artifact.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), } satisfies { readonly [TProducer in DevLogProducer]: readonly string[] }); 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 669883f6d..989a81abf 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,18 @@ 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 === 'runtime.event') { + if (event.type === 'artifact.available' || 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' ? 'error' : event.type === 'source.status' && stringAt(event.payload, 'state') === 'invalid' ? 'warning' : 'info'; + event.type === 'build.failed' || event.type === 'dev.host.sync' && stringAt(event.payload, 'state') === 'failed' + ? 'error' + : event.type === 'source.status' && stringAt(event.payload, 'state') === 'invalid' + ? 'warning' + : 'info'; const summaryFor = (event: ProjectEvent): string => { if (event.type === 'source.changed') return 'Project source changed.'; @@ -34,6 +38,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.host.sync') return 'Development host install was synchronized.'; return 'Project runtime event was published.'; }; @@ -94,6 +99,7 @@ const recordEvent = (sink: DevLogSink, message: ProjectEventMessage): void => { write(sink, { ...shared, kind: message.type, producer: 'build' }); break; case 'artifact.status': + case 'dev.host.sync': case 'invalidation': case 'runtime.event': case 'source.changed': diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 70484402b..05c343316 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -272,11 +272,19 @@ export interface RuntimeEvent { readonly type: import('./runtime-provider.ts').DevRuntimeEventInput['type']; } +export interface DevHostSyncEvent { + readonly diagnostics: readonly Diagnostic[]; + readonly epochId: string; + readonly host: 'claude' | 'codex' | 'cursor'; + readonly state: 'failed' | 'succeeded'; +} + export interface ProjectEventPayloadMap { readonly 'artifact.available': ActiveArtifactStatus; readonly 'artifact.status': ArtifactStatus; readonly 'build.failed': FailedBuildAttempt; readonly 'build.started': RunningBuildAttempt; + readonly 'dev.host.sync': DevHostSyncEvent; readonly invalidation: Invalidation; readonly 'runtime.event': RuntimeEvent; readonly 'source.changed': Invalidation; @@ -284,7 +292,7 @@ export interface ProjectEventPayloadMap { } export type ProjectEventType = keyof ProjectEventPayloadMap; -type EpochScopedProjectEventType = 'artifact.available'; +type EpochScopedProjectEventType = 'artifact.available' | '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 9a09bfdf0..278c70b85 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { join, resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +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'; @@ -13,6 +14,7 @@ import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; +import { DevHostInstallManager } from './host-install-manager.ts'; import { HostDiscoveryService, type HostDiscoveryServiceOptions, @@ -82,7 +84,7 @@ interface Closeable { export interface DevServerLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'coordinator' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; + readonly resource: 'coordinator' | 'host-installs' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; } /** Reports session and coordinator cleanup failures without hiding either resource. */ @@ -103,6 +105,8 @@ export interface StartDevServerOptions { readonly agentApiToken?: string; /** Supplied by integration tests; published callers use the packaged assets. */ readonly assets?: WorkbenchAssetSource; + /** Hosts whose installed development variant follows successful artifact epochs. */ + readonly installHosts?: readonly InstallHost[]; /** Launch the foreground URL after it has started. Defaults to false. */ readonly open?: boolean; /** Injectable browser launcher for embedding and deterministic tests. */ @@ -423,6 +427,7 @@ export interface DevServerRuntimeLifecycleResources { export interface DevServerLifecycleOptions { readonly coordinator: Closeable; readonly detachProjectLogs?: () => void; + readonly hostInstalls?: Closeable; readonly logs?: DevLogService; readonly mcpApps?: Closeable; readonly inspector?: Closeable; @@ -435,6 +440,7 @@ export interface DevServerLifecycleOptions { export const closeDevServerLifecycle = async ({ coordinator, detachProjectLogs, + hostInstalls, inspector, logs, mcpApps, @@ -459,6 +465,7 @@ export const closeDevServerLifecycle = async ({ ['runtime-client-surfaces', runtimeResources?.clientSurfaces], ['runtime', runtimeResources?.runtime], ['mcp-sessions', mcpSessions], + ['host-installs', hostInstalls], ['coordinator', coordinator], ]; const failures: DevServerLifecycleCloseFailure[] = []; @@ -493,12 +500,14 @@ const withMcpSessionLifecycle = ( logs: DevLogService, detachProjectLogs: () => void, inspector: Closeable, + hostInstalls?: DevHostInstallManager, ): ForegroundCoordinator => Object.freeze({ close: () => { clientSurfaces.beginClose(); return closeDevServerLifecycle({ coordinator, detachProjectLogs, + hostInstalls, inspector, logs, mcpApps: mcpApps(), @@ -510,7 +519,13 @@ const withMcpSessionLifecycle = ( publishServerUrl: (url: string) => coordinator.publishServerUrl(url), rebuild: (invalidation: Invalidation) => coordinator.rebuild(invalidation), start: async () => { + hostInstalls?.start(); await coordinator.start(); + const artifact = coordinator.status().artifact; + if (hostInstalls !== undefined && (artifact.state === 'active' || artifact.state === 'stale')) { + hostInstalls.sync(artifact.activeEpoch.id); + await hostInstalls.settled(); + } await runtime?.start(); }, status, @@ -702,6 +717,14 @@ export const startDevServer = async (options: StartDevServerOptions): Promise Object.freeze({ ...coordinator.status(), ...(runtimeTopology === undefined ? {} : { runtime: runtimeTopology }), @@ -823,6 +846,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { + fixture = await buildHostInstallFixture({ environment: process.env }); +}, 180_000); + +afterAll(async () => { + if (fixture !== undefined) await disposeHostInstallFixture(fixture); +}); + +const createRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-dev-host-install-')); + roots.push(root); + return root; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const builtFixture = (): BuiltHostInstallFixture => { + if (fixture === undefined) throw new Error('The shared host-install fixture was not built.'); + return fixture; +}; + +const epoch = (projectRoot: string, id: string): ArtifactEpoch => Object.freeze({ + configDigest: `${id}-config`, + createdAt: '2026-09-02T12:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, + id, + manifestPath: join(projectRoot, '.agent-bundle', 'epochs', id, 'manifest.json'), + modelDigest: `${id}-model`, + projectRevision: `${id}-source`, + targetDigests: { cursor: `${id}-target` }, +}); + +const writeEpoch = async ( + projectRoot: string, + id: string, + values: { readonly hook: string; readonly skill: string }, +): Promise => { + const root = join(projectRoot, '.agent-bundle', 'epochs', id); + const target = join(root, 'cursor'); + await Promise.all([ + mkdir(join(target, '.cursor-plugin'), { recursive: true }), + mkdir(join(target, 'hooks'), { recursive: true }), + mkdir(join(target, 'mcp'), { recursive: true }), + mkdir(join(target, 'skills', 'probe'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'manifest.json'), '{}\n'), + writeFile(join(target, '.cursor-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), + writeFile(join(target, 'hooks', 'hooks.json'), values.hook), + writeFile(join(target, 'mcp', 'probe-old.mjs'), 'export const old = true;\n'), + writeFile( + join(target, 'mcp.json'), + '{"mcpServers":{"probe":{"args":["${CURSOR_PLUGIN_ROOT}/mcp/probe-old.mjs"],"command":"node","type":"stdio"}}}\n', + ), + writeFile(join(target, 'skills', 'probe', 'SKILL.md'), values.skill), + ]); + return root; +}; + +const activePayload = (value: ArtifactEpoch) => Object.freeze({ + activeEpoch: value, + currentSourceRevision: value.projectRevision, + state: 'active' as const, +}); + +it('defines the stage-1 proxy command shape with an absolute framework CLI entry', async () => { + expect(await devProxyServerCommand('/workspace/project', 'probe', 'claude')).toEqual({ + args: [ + join(process.cwd(), 'packages', 'agent-bundle', 'bin', 'agent-bundle.js'), + 'dev', + 'proxy', + '--root', + '/workspace/project', + '--server', + 'probe', + '--target', + 'claude', + ], + command: process.execPath, + }); +}); + +it('installs a marked Cursor dev variant and atomically re-points top-level directories on epoch swap', async () => { + const root = await createRoot(); + const home = join(root, 'home'); + const projectRoot = join(root, 'project'); + const destination = join(home, '.cursor', 'plugins', 'local', 'dev-proof'); + await mkdir(join(home, '.cursor'), { recursive: true }); + const firstEpochRoot = await writeEpoch(projectRoot, 'epoch-1', { + hook: 'first hook\n', + skill: 'first skill\n', + }); + const secondEpochRoot = await writeEpoch(projectRoot, 'epoch-2', { + hook: 'second hook\n', + skill: 'second skill\n', + }); + const thirdEpochRoot = await writeEpoch(projectRoot, 'epoch-3', { + hook: 'third hook\n', + skill: 'third skill\n', + }); + const rootsByEpoch = new Map([ + ['epoch-1', firstEpochRoot], + ['epoch-2', secondEpochRoot], + ['epoch-3', thirdEpochRoot], + ]); + const installs: InstallBundleOptions[] = []; + const syncEvents: unknown[] = []; + const installBundle = async (options: InstallBundleOptions): Promise => { + installs.push(options); + await mkdir(join(destination, '..'), { recursive: true }); + await cp(options.from, destination, { recursive: true }); + return { + bundleRoot: options.from, + destination, + host: 'cursor', + plugin: 'dev-proof', + state: 'installed', + version: '1.0.0', + }; + }; + const eventHub = new ProjectEventHub(); + eventHub.subscribe((event) => { + if (event.type === 'dev.host.sync') syncEvents.push(event.payload); + }); + const manager = new DevHostInstallManager({ + epochStore: { + acquireEpochReference: async (epochId) => ({ + close: async () => undefined, + epoch: epoch(projectRoot, epochId), + root: rootsByEpoch.get(epochId)!, + }), + }, + eventHub, + home, + hosts: ['cursor'], + installBundle, + projectRoot, + }); + manager.start(); + + eventHub.publish({ + epochId: 'epoch-1', + payload: activePayload(epoch(projectRoot, 'epoch-1')), + type: 'artifact.available', + }); + await manager.settled(); + expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-1', state: 'succeeded' }); + + expect(installs).toHaveLength(1); + expect(JSON.parse(await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'))).toMatchObject({ + epochId: 'epoch-1', + host: 'cursor', + projectRoot, + schemaVersion: 1, + }); + const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + expect(JSON.parse(mcpBefore)).toEqual({ + mcpServers: { + probe: await devProxyServerCommand(projectRoot, 'probe', 'cursor'), + }, + }); + expect((await lstat(join(destination, 'skills'))).isSymbolicLink()).toBe(true); + expect((await lstat(join(destination, 'hooks'))).isSymbolicLink()).toBe(true); + + eventHub.publish({ + epochId: 'epoch-2', + payload: activePayload(epoch(projectRoot, 'epoch-2')), + type: 'artifact.available', + }); + await manager.settled(); + expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-2', state: 'succeeded' }); + + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('second skill\n'); + expect(await readFile(join(destination, 'hooks', 'hooks.json'), 'utf8')).toBe('second hook\n'); + expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + expect((await lstat(join(destination, 'skills'))).isSymbolicLink()).toBe(true); + expect((await lstat(join(destination, 'hooks'))).isSymbolicLink()).toBe(true); + + eventHub.publish({ + epochId: 'epoch-3', + payload: activePayload(epoch(projectRoot, 'epoch-3')), + type: 'artifact.available', + }); + await manager.settled(); + expect((await readdir(join(destination, '.agent-bundle-dev', 'generations'))).sort()).toEqual([ + 'epoch-2', + 'epoch-3', + ]); + + const failed: FailedBuildAttempt = Object.freeze({ + completedAt: '2026-09-02T12:00:01.000Z', + diagnostics: [{ code: 'TEST_BUILD_FAILED', message: 'broken source', severity: 'error' }] as const, + id: 'failed-build', + outcome: 'failed', + sourceRevision: 'broken-source', + startedAt: '2026-09-02T12:00:00.000Z', + }); + eventHub.publish({ payload: failed, type: 'build.failed' }); + await manager.settled(); + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('third skill\n'); + + await manager.close(); + await expect(readFile(join(destination, DEV_INSTALL_MARKER), 'utf8')).resolves.toContain('"epochId":"epoch-3"'); +}); + +it('publishes a diagnostic event and preserves the installed generation when re-sync fails', async () => { + const root = await createRoot(); + const home = join(root, 'home'); + const projectRoot = join(root, 'project'); + const destination = join(home, '.cursor', 'plugins', 'local', 'dev-proof'); + await mkdir(join(home, '.cursor'), { recursive: true }); + const firstEpochRoot = await writeEpoch(projectRoot, 'epoch-1', { hook: 'first hook\n', skill: 'first skill\n' }); + const eventHub = new ProjectEventHub(); + const events: unknown[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.host.sync') events.push(event.payload); + }); + const manager = new DevHostInstallManager({ + epochStore: { + acquireEpochReference: async (epochId) => ({ + close: async () => undefined, + epoch: epoch(projectRoot, epochId), + root: epochId === 'epoch-1' ? firstEpochRoot : join(root, 'missing-epoch'), + }), + }, + eventHub, + home, + hosts: ['cursor'], + installBundle: async (options) => { + await mkdir(join(destination, '..'), { recursive: true }); + await cp(options.from, destination, { recursive: true }); + return { + bundleRoot: options.from, + destination, + host: 'cursor', + plugin: 'dev-proof', + state: 'installed', + version: '1.0.0', + }; + }, + projectRoot, + }); + manager.start(); + eventHub.publish({ + epochId: 'epoch-1', + payload: activePayload(epoch(projectRoot, 'epoch-1')), + type: 'artifact.available', + }); + await manager.settled(); + eventHub.publish({ + epochId: 'epoch-2', + payload: activePayload(epoch(projectRoot, 'epoch-2')), + type: 'artifact.available', + }); + await manager.settled(); + + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('first skill\n'); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + diagnostics: [expect.objectContaining({ code: 'AB7202', severity: 'error' })], + epochId: 'epoch-2', + host: 'cursor', + state: 'failed', + }), + ])); + await manager.close(); +}); + +it('re-syncs the isolated Cursor install from coordinator epochs and ignores a failed rebuild', async () => { + const built = builtFixture(); + const projectRoot = join(built.artifactRoot, '..'); + const home = await createRoot(); + await mkdir(join(home, '.cursor'), { recursive: true }); + const eventHub = new ProjectEventHub(); + const epochStore = new EpochStore({ projectRoot }); + const manager = new DevHostInstallManager({ + epochStore, + eventHub, + home, + hosts: ['cursor'], + projectRoot, + }); + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => undefined }), + epochStore, + eventHub, + prepareCommand: 'dev', + projectService: new ProjectService({ root: projectRoot }), + root: projectRoot, + }); + const destination = join(home, '.cursor', 'plugins', 'local', 'host-install-proof'); + manager.start(); + try { + await coordinator.start(); + await manager.settled(); + const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + expect(mcpBefore).toContain(`"command":${JSON.stringify(process.execPath)}`); + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain( + 'host-install proof fixture', + ); + + await Promise.all([ + writeFile( + join(projectRoot, 'src', 'skills', 'probe', 'SKILL.md'), + '---\nname: probe\ndescription: Updated dev proof.\n---\n\n# Updated skill\n', + ), + writeFile( + join(projectRoot, 'src', 'hooks', 'session-start.ts'), + "export default () => ({ additionalContext: 'updated hook', outcome: 'continue' as const });\n", + ), + ]); + const rebuilt = await coordinator.rebuild({ + occurredAt: '2026-09-02T12:00:00.000Z', + paths: ['skills/probe/SKILL.md', 'src/hooks/session-start.ts'], + reason: 'source-change', + }); + expect(rebuilt.outcome).toBe('succeeded'); + await manager.settled(); + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain('# Updated skill'); + const hookFiles = await readdir(join(destination, 'hooks')); + const hookModule = hookFiles.find((name) => name.endsWith('.mjs')); + if (hookModule === undefined) throw new Error('Updated installed hooks contained no executable module.'); + expect(await readFile(join(destination, 'hooks', hookModule), 'utf8')).toContain('updated hook'); + expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + const markerBeforeFailure = await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'); + + await writeFile(join(projectRoot, 'src', 'hooks', 'session-start.ts'), 'export default () => ({;\n'); + const failed = await coordinator.rebuild({ + occurredAt: '2026-09-02T12:00:01.000Z', + paths: ['src/hooks/session-start.ts'], + reason: 'source-change', + }); + expect(failed.outcome).toBe('failed'); + await manager.settled(); + expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain('# Updated skill'); + expect(await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8')).toBe(markerBeforeFailure); + } finally { + await manager.close(); + await coordinator.close(); + } +}, 180_000); + +it('spawns the exact Cursor development proxy command without relying on PATH', async () => { + await expect(runDevHostInstallProof(builtFixture(), 'cursor', { environment: process.env })).resolves.toEqual({ + hookChanged: true, + host: 'cursor', + marker: expect.objectContaining({ epochId: 'epoch-2', host: 'cursor', schemaVersion: 1 }), + mcpUnchanged: true, + skillChanged: true, + spawn: { + exitCode: 1, + unavailableDiagnostic: '[AB8025] Development MCP server is unavailable.', + }, + status: 'passed', + }); +}, 180_000); + +claudeIt( + claudeAvailable + ? 'installs a Claude dev variant and re-syncs its host-owned cache without another CLI call' + : 'installs a Claude dev variant and re-syncs its host-owned cache [missing evidence: claude binary unavailable on PATH]', + async () => { + await expect(runDevHostInstallProof(builtFixture(), 'claude', { environment: process.env })).resolves.toEqual({ + hookChanged: true, + host: 'claude', + marker: expect.objectContaining({ epochId: 'epoch-2', host: 'claude', schemaVersion: 1 }), + mcpUnchanged: true, + skillChanged: true, + spawn: { + exitCode: 1, + unavailableDiagnostic: '[AB8025] Development MCP server is unavailable.', + }, + status: 'passed', + }); + }, + 180_000, +); + +codexIt( + codexAvailable + ? 'installs a Codex dev variant and re-syncs its host-owned cache without another CLI call' + : 'installs a Codex dev variant and re-syncs its host-owned cache [missing evidence: codex binary unavailable on PATH]', + async () => { + await expect(runDevHostInstallProof(builtFixture(), 'codex', { environment: process.env })).resolves.toEqual({ + hookChanged: true, + host: 'codex', + marker: expect.objectContaining({ epochId: 'epoch-2', host: 'codex', schemaVersion: 1 }), + mcpUnchanged: true, + skillChanged: true, + spawn: { + exitCode: 1, + unavailableDiagnostic: '[AB8025] Development MCP server is unavailable.', + }, + status: 'passed', + }); + }, + 180_000, +); diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 1fa4d3daa..12a9ef2ce 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1812,10 +1812,21 @@ it('retains sandbox startup and foreground cleanup failures structurally', async } }); -it('passes --no-open and the requested port from the CLI to the public dev API', async () => { +it('passes --no-open, the requested port, and repeatable dev host installs to the public dev API', async () => { const stdout: string[] = []; const received: unknown[] = []; - const exitCode = await runCli(['dev', '--root', '/project', '--no-open', '--port', '4100'], { + const exitCode = await runCli([ + 'dev', + '--root', + '/project', + '--no-open', + '--port', + '4100', + '--install-host', + 'cursor', + '--install-host', + 'claude', + ], { stdout: { write: (value) => stdout.push(value) }, }, { startDevServer: async (options) => { @@ -1830,7 +1841,12 @@ it('passes --no-open and the requested port from the CLI to the public dev API', }); expect(exitCode).toBe(0); - expect(received).toEqual([expect.objectContaining({ open: false, port: 4100, root: '/project' })]); + expect(received).toEqual([expect.objectContaining({ + installHosts: ['cursor', 'claude'], + open: false, + port: 4100, + root: '/project', + })]); expect(stdout.join('')).toBe('Development workbench at http://127.0.0.1:4100\n'); }); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index b82108a6e..05e7897b3 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink } from 'node:fs/promises'; +import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; @@ -27,6 +27,10 @@ import { type InstalledHostContractMatrixReport, } from '../../src/test/contract.ts'; import { openInstalledHostMcpServer } from '../../src/test/installed.ts'; +import { DEV_INSTALL_MARKER, DevHostInstallManager } from '../../src/dev/host-install-manager.ts'; +import { ProjectEventHub } from '../../src/dev/events.ts'; +import type { ArtifactEpoch } from '../../src/dev/types.ts'; +import { installBundle, type InstallHost } from '../../src/install/install.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -120,6 +124,23 @@ export interface BuiltPortableHostInstallFixture extends BuiltFixtureProject { readonly portableBundle: string; } +export interface DevHostInstallProofReport { + readonly host: InstallHost; + readonly hookChanged: true; + readonly marker: { + readonly epochId: 'epoch-2'; + readonly host: InstallHost; + readonly schemaVersion: 1; + }; + readonly mcpUnchanged: true; + readonly skillChanged: true; + readonly spawn: { + readonly exitCode: 1; + readonly unavailableDiagnostic: '[AB8025] Development MCP server is unavailable.'; + }; + readonly status: 'passed'; +} + export interface HostInstallCommand { readonly cwd?: string; readonly executable: string; @@ -506,6 +527,142 @@ export const disposeHostInstallFixture = async (fixture: BuiltFixtureProject): P await rm(fixture.root, { force: true, recursive: true }); }; +/** Proves initial host-owned installation followed by direct cache re-sync without another host CLI call. */ +export const runDevHostInstallProof = async ( + fixture: BuiltHostInstallFixture, + host: InstallHost, + options: { readonly environment: Readonly }, +): Promise => { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-dev-install-${host}-`)); + const home = join(root, 'home'); + const claudeConfig = join(root, 'claude'); + const codexHome = join(root, 'codex'); + const epoch2Root = join(root, 'epoch-2'); + await Promise.all([ + mkdir(home, { recursive: true }), + mkdir(claudeConfig, { recursive: true }), + mkdir(codexHome, { recursive: true }), + cp(fixture.artifactRoot, epoch2Root, { recursive: true }), + ]); + if (host === 'cursor') await mkdir(join(home, '.cursor'), { recursive: true }); + const environment = isolatedEnvironment(options.environment, { + CLAUDE_CONFIG_DIR: claudeConfig, + CODEX_HOME: codexHome, + HOME: home, + }); + const identity = (id: string, epochRoot: string): ArtifactEpoch => Object.freeze({ + configDigest: `${id}-config`, + createdAt: '2026-09-02T12:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, + id, + manifestPath: join(epochRoot, 'manifest.json'), + modelDigest: `${id}-model`, + projectRevision: `${id}-source`, + targetDigests: { [host]: `${id}-target` }, + }); + const roots = new Map([['epoch-1', fixture.artifactRoot], ['epoch-2', epoch2Root]]); + const eventHub = new ProjectEventHub(); + let hostCommandCalls = 0; + const manager = new DevHostInstallManager({ + environment, + epochStore: { + acquireEpochReference: async (epochId) => { + const epochRoot = roots.get(epochId); + if (epochRoot === undefined) throw new Error(`Unknown proof epoch ${epochId}.`); + return { close: async () => undefined, epoch: identity(epochId, epochRoot), root: epochRoot }; + }, + }, + eventHub, + home, + hosts: [host], + installBundle: async (installOptions) => installBundle({ + ...installOptions, + commandRunner: { + run: async (command, args, commandOptions) => { + hostCommandCalls += 1; + const result = await run(command, args, { + cwd: commandOptions.cwd, + environment, + timeout: 180_000, + }); + return { code: result.exitCode, stderr: result.stderr, stdout: result.stdout }; + }, + }, + }), + projectRoot: fixture.root, + }); + const marketplaceRoot = host === 'claude' ? claudeConfig : codexHome; + const destination = host === 'cursor' + ? join(home, '.cursor', 'plugins', 'local', plugin) + : join(marketplaceRoot, 'plugins', 'cache', marketplace, plugin, version); + const mcpPath = host === 'cursor' ? 'mcp.json' : '.mcp.json'; + try { + manager.start(); + const first = identity('epoch-1', fixture.artifactRoot); + eventHub.publish({ + epochId: first.id, + payload: { activeEpoch: first, currentSourceRevision: first.projectRevision, state: 'active' }, + type: 'artifact.available', + }); + await manager.settled(); + const mcpBefore = await readFile(join(destination, mcpPath), 'utf8'); + const mcpDocument = record(parseJson(mcpBefore, `${host} development MCP document`)); + const server = record(record(mcpDocument?.mcpServers)?.probe); + const command = server?.command; + const args = server?.args; + assertProof(typeof command === 'string', `${host} development MCP command was not a string.`); + assertProof(Array.isArray(args) && args.every((value) => typeof value === 'string'), `${host} development MCP args were not strings.`); + const spawned = await run(command, args as readonly string[], { + cwd: destination, + environment, + timeout: 30_000, + }); + assertProof(spawned.exitCode === 1, `${host} development proxy did not fail closed with exit code 1: ${commandDetail(spawned)}`); + assertProof( + spawned.stderr.includes('[AB8025] Development MCP server is unavailable.'), + `${host} development proxy did not report AB8025: ${commandDetail(spawned)}`, + ); + const skillBefore = await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8'); + const hookName = (await readdir(join(epoch2Root, host, 'hooks'))).find((name) => name.endsWith('.mjs')); + assertProof(hookName !== undefined, `${host} proof epoch contained no generated hook module.`); + await Promise.all([ + writeFile(join(epoch2Root, host, 'skills', 'probe', 'SKILL.md'), `${skillBefore}\nDev epoch two.\n`), + writeFile(join(epoch2Root, host, 'hooks', hookName), 'export default () => ({ outcome: "continue", additionalContext: "epoch two" });\n'), + ]); + const callsAfterInstall = hostCommandCalls; + const second = identity('epoch-2', epoch2Root); + eventHub.publish({ + epochId: second.id, + payload: { activeEpoch: second, currentSourceRevision: second.projectRevision, state: 'active' }, + type: 'artifact.available', + }); + await manager.settled(); + assertProof(hostCommandCalls === callsAfterInstall, `${host} re-sync invoked the host CLI.`); + assertProof(await readFile(join(destination, mcpPath), 'utf8') === mcpBefore, `${host} re-sync changed its proxy MCP document.`); + assertProof((await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).includes('Dev epoch two.'), `${host} skill did not re-sync.`); + assertProof((await readFile(join(destination, 'hooks', hookName), 'utf8')).includes('epoch two'), `${host} hook did not re-sync.`); + const markerDocument = parseJson<{ readonly epochId: 'epoch-2'; readonly host: InstallHost; readonly schemaVersion: 1 }>( + await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'), + `${host} dev marker`, + ); + return Object.freeze({ + hookChanged: true, + host, + marker: Object.freeze(markerDocument), + mcpUnchanged: true, + skillChanged: true, + spawn: Object.freeze({ + exitCode: 1, + unavailableDiagnostic: '[AB8025] Development MCP server is unavailable.' as const, + }), + status: 'passed', + }); + } finally { + await manager.close(); + await rm(root, { force: true, recursive: true }); + } +}; + /** * Stages one already-built target, opens its emitted MCP command from the * installed location, and runs the shared matrix in that same live session. diff --git a/packages/workbench/src/logs/log-client.ts b/packages/workbench/src/logs/log-client.ts index 449a8398f..6c468669e 100644 --- a/packages/workbench/src/logs/log-client.ts +++ b/packages/workbench/src/logs/log-client.ts @@ -54,15 +54,17 @@ const devLogKinds = deepFreeze({ build: ['artifact.available', 'build.failed', 'build.started'], diagnostic: [ 'artifact.available.diagnostic', 'artifact.status.diagnostic', 'build.failed.diagnostic', 'build.started.diagnostic', - 'invalidation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', 'source.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.shutdown.completed', 'dev.shutdown.started', 'invalidation', 'project.events.replay-gap', - 'project.invalid-source', 'project.load', 'project.prepared', 'runtime.event', 'source.changed', 'source.status', + 'artifact.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', ], }); const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/u; diff --git a/packages/workbench/tests/log-client.test.ts b/packages/workbench/tests/log-client.test.ts index fbcfa8c96..f58e8f755 100644 --- a/packages/workbench/tests/log-client.test.ts +++ b/packages/workbench/tests/log-client.test.ts @@ -37,6 +37,32 @@ const clientFor = (response: Response): LogClient => new LogClient({ foreground: foreground(async (input) => String(input).includes('/api/project/session') ? session() : response), }); +it('accepts development host sync project and diagnostic log kinds', async () => { + const records = [ + { + ...record, + context: { epochId: 'epoch-1' }, + details: { epochId: 'epoch-1', host: 'cursor', state: 'succeeded' }, + kind: 'dev.host.sync', + producer: 'project', + summary: 'Development host install was synchronized.', + }, + { + ...record, + context: { diagnosticCode: 'AB7202' }, + details: { code: 'AB7202', message: 'Host sync failed.', severity: 'error' }, + kind: 'dev.host.sync.diagnostic', + level: 'error', + producer: 'diagnostic', + sequence: 2, + summary: 'Project diagnostic was recorded.', + }, + ]; + await expect(clientFor(json({ + replay: { cursor: { afterSequence: 2 }, records }, + })).replay()).resolves.toMatchObject({ records }); +}); + it('rejects malformed or noncontiguous replay envelopes before exposing them to the page', async () => { await expect(clientFor(new Response('{')).replay()).rejects.toBeInstanceOf(LogClientError); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 280561adc..4e743f272 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -20,6 +20,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/cli-routes-build.test.ts', 'packages/agent-bundle/tests/cli.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-package-build.test.ts', 'packages/agent-bundle/tests/dev-workbench.test.ts', 'packages/agent-bundle/tests/eval-claude-harness.test.ts',