From 5245aba7c81ba049cb5af86d8663d84a9baa790c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 30 Aug 2026 22:36:30 +0200 Subject: [PATCH] feat: simplify plugin facet lifecycle Remove handshake-based facet delivery and host only the selected Session presentation branch. Simplify worker selection, startup options, package profiles, and serialized builds. --- .../plugins/pi-example-plugin/README.md | 2 +- .../src/experimental/client-runtime.ts | 11 +- .../src/experimental/client-tui.ts | 245 ++++++++---------- .../src/experimental/plugins/bundled.ts | 25 +- .../src/experimental/plugins/package.ts | 136 +++++----- .../coding-agent/src/experimental/server.ts | 130 ++++------ .../experimental/session-worker-manager.ts | 7 +- .../src/experimental/session-worker.ts | 8 +- .../test/experimental-client-tui.test.ts | 26 +- .../experimental-presentation-facets.test.ts | 62 +---- ...xperimental-session-worker-manager.test.ts | 10 +- packages/protocol/README.md | 4 +- packages/protocol/src/protocol.ts | 4 +- packages/protocol/test/protocol.test.ts | 15 +- packages/server/src/server.ts | 9 - packages/server/src/transports/unix/preset.ts | 1 - packages/server/src/types.ts | 3 - packages/server/test/conformance.test.ts | 24 +- 18 files changed, 251 insertions(+), 471 deletions(-) diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md index c71f7e3ea..ecff7f533 100644 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md @@ -22,7 +22,7 @@ Repeat `-e` to select multiple plugin packages. Client paths are resolved locall `server -e` establishes the server profile's default Session and TUI facets. Starting an explicit foreground server without `-e` clears that default. Client selection never changes the server's root facet generation. -Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and advertises the new artifact to future clients. +Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and serves the new artifact to future clients. Package metadata can override or disable conventional entries: diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts index 2986309d3..5cff58c72 100644 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ b/packages/coding-agent/src/experimental/client-runtime.ts @@ -1,11 +1,9 @@ import { basename } from "node:path"; -import type { FacetLoader } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Client, ServerError } from "@earendil-works/pi-client"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; -import { createPresentationFacetLoaders } from "./plugins/bundled.ts"; import { RadiusRelayAuthResolver } from "./radius-auth.ts"; import { createRadiusClientTransportFactory, RadiusClientReconnect } from "./radius-relay.ts"; import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts"; @@ -27,7 +25,6 @@ export type ClientRuntimeRoute = export interface ClientRuntimeServer { readonly route: ClientRuntimeRoute; readonly client: Client; - readonly presentationFacetLoaders: readonly FacetLoader[]; readonly server: ServerServiceSource; readonly session: SessionServiceSource; } @@ -157,13 +154,7 @@ export async function openClientRuntime( serviceSources.push(server); const session = createSessionServiceSource(client); serviceSources.push(session); - servers.push({ - route, - client, - presentationFacetLoaders: createPresentationFacetLoaders(client.hello?.data), - server, - session, - }); + servers.push({ route, client, server, session }); } return { servers, dispose }; } catch (error) { diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index 4d8201710..c09b4553d 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -58,7 +58,6 @@ export interface RunClientTuiOptions extends OpenClientRuntimeOptions { export interface ClientTuiServer { readonly serverId: string; readonly radius: boolean; - readonly presentationFacetLoaders?: readonly FacetLoader[]; readonly laneWatches: LaneWatchSource; readonly server: ServerServiceSource; readonly session: SessionServiceSource; @@ -66,24 +65,12 @@ export interface ClientTuiServer { interface SessionFeature { readonly serverId: string; - readonly directory: SessionDirectory; - readonly management: SessionManagement; readonly session: SessionServiceSource; readonly laneWatches: LaneWatchSource; } -interface SlashCommandFeature { - readonly serverId: string; - readonly commands: SlashCommands; -} - -interface AgentFeature { - readonly serverId: string; - readonly controller: AgentController; -} - interface PreparedClientSession { - readonly serverId: string; + readonly server: ClientTuiServer; readonly summary: SessionSummary; readonly presentationPlugins: JsonValue; } @@ -116,13 +103,13 @@ export class ExperimentalClientTui implements Component { readonly #footerComponent = new Text("", 1, 0); readonly #layoutRoot: Component; readonly #sharedFacets: LoadedFacets; - readonly #loadedFacetGenerations = new Set(); - readonly #facetReloads = new Set<{ tail: Promise }>(); - readonly #facetHosts: FacetHost[] = []; - readonly #sessions = new Map(); - readonly #slashCommands = new Map(); - readonly #agents = new Map(); readonly #keybindings = KeybindingsManager.create(); + #presentationFacets: LoadedFacets | undefined; + #facetHost: FacetHost | undefined; + #facetReloadTail = Promise.resolve(); + #session: SessionFeature | undefined; + #slashCommands: SlashCommands | undefined; + #controller: AgentController | undefined; readonly #chatInput: CustomEditor; #selectList: SelectList | undefined; #selection: PendingSelection | undefined; @@ -143,7 +130,6 @@ export class ExperimentalClientTui implements Component { this.#requestRender = requestRender; this.#finish = finish; this.#sharedFacets = loadedFacets; - this.#loadedFacetGenerations.add(loadedFacets); setKeybindings(this.#keybindings); this.#chatInput = new CustomEditor(ui, getEditorTheme(), this.#keybindings, { paddingX: 1 }); this.#chatInput.onSubmit = (message) => void this.#runPrompt(message); @@ -184,7 +170,7 @@ export class ExperimentalClientTui implements Component { ]).load(); const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets); try { - await component.#start(options.servers, prepared); + await component.#start(prepared); await component.#openPreparedSession(prepared); return component; } catch (error) { @@ -253,105 +239,96 @@ export class ExperimentalClientTui implements Component { return this.#closePromise; } - async #start(servers: readonly ClientTuiServer[], prepared: PreparedClientSession): Promise { - for (const server of servers) { - const presentationFacetLoaders = - server.serverId === prepared.serverId - ? createPresentationFacetLoaders(prepared.presentationPlugins) - : (server.presentationFacetLoaders ?? []); - let presentationFacets = await combineFacetLoaders(presentationFacetLoaders).load(); - this.#loadedFacetGenerations.add(presentationFacets); - let facetHost!: FacetHost; - const reloadState = { tail: Promise.resolve() }; - this.#facetReloads.add(reloadState); - const reloadPresentationPlugins = (data: JsonValue): Promise => { - const operation = reloadState.tail.then(async () => { - const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load(); + async #start(prepared: PreparedClientSession): Promise { + const server = prepared.server; + let presentationFacets = await combineFacetLoaders( + createPresentationFacetLoaders(prepared.presentationPlugins), + ).load(); + this.#presentationFacets = presentationFacets; + let facetHost!: FacetHost; + const reloadPresentationPlugins = (data: JsonValue): Promise => { + const operation = this.#facetReloadTail.then(async () => { + const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load(); + try { + await facetHost.reload(candidate.facets); + } catch (error) { try { - await facetHost.reload(candidate.facets); - } catch (error) { - try { - await candidate.dispose(); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed"); - } - throw error; + await candidate.dispose(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed"); } - const retired = presentationFacets; - presentationFacets = candidate; - this.#loadedFacetGenerations.delete(retired); - this.#loadedFacetGenerations.add(candidate); - await retired.dispose(); + throw error; + } + const retired = presentationFacets; + presentationFacets = candidate; + this.#presentationFacets = candidate; + await retired.dispose(); + }); + this.#facetReloadTail = operation.catch(() => {}); + return operation; + }; + const presentationBridgeFacet = defineFacet({ + id: "@pi/presentation-bridge", + setup: (env) => { + env.provide(PresentationUI, { + select: (title, items, selectedValue) => + this.#select( + title, + items.map((item) => ({ ...item })), + selectedValue, + ), + showStatus: (status) => { + this.#status = status; + this.#rebuild(); + }, }); - reloadState.tail = operation.catch(() => {}); - return operation; - }; - const presentationBridgeFacet = defineFacet({ - id: "@pi/presentation-bridge", - setup: (env) => { - env.provide(PresentationUI, { - select: (title, items, selectedValue) => - this.#select( - title, - items.map((item) => ({ ...item })), - selectedValue, - ), - showStatus: (status) => { - this.#status = status; - this.#rebuild(); - }, + const commands = env.use(SlashCommands); + const controller = env.use(AgentController); + const sessionFeature: SessionFeature = { + serverId: server.serverId, + session: server.session, + laneWatches: server.laneWatches, + }; + env.onActivate(() => { + if (this.#session !== undefined || this.#slashCommands !== undefined || this.#controller !== undefined) { + throw new Error("Presentation services are already active"); + } + this.#session = sessionFeature; + this.#slashCommands = commands; + this.#controller = controller; + env.own(() => { + if (this.#session === sessionFeature) this.#session = undefined; + if (this.#slashCommands === commands) this.#slashCommands = undefined; + if (this.#controller === controller) this.#controller = undefined; }); - const directory = env.use(SessionDirectory); - const management = env.use(SessionManagement); - const commands = env.use(SlashCommands); - const controller = env.use(AgentController); - const sessionFeature: SessionFeature = { - serverId: server.serverId, - directory, - management, - session: server.session, - laneWatches: server.laneWatches, - }; - env.onActivate(() => { - env.own(this.#register(this.#sessions, "Sessions", sessionFeature)); + env.own(commands.subscribe(() => this.#updateAutocomplete())); + if (server.radius) { env.own( - this.#register(this.#slashCommands, "Slash commands", { serverId: server.serverId, commands }), + server.server.connection.subscribe((state) => this.#handleConnectionState(server.serverId, state)), ); - env.own(this.#register(this.#agents, "Agent controller", { serverId: server.serverId, controller })); - env.own(directory.state.subscribe(() => this.#rebuild())); - env.own(commands.subscribe(() => this.#updateAutocomplete())); - if (server.radius) { - env.own( - server.server.connection.subscribe((state) => - this.#handleConnectionState(server.serverId, state), - ), - ); - env.own( - server.session.attachment.subscribe((state) => - this.#handleAttachmentState(sessionFeature, state), - ), - ); - } - }); - }, - }); - facetHost = await createFacetHost({ - facets: [ - createSlashCommandsRuntimeFacet(), - presentationBridgeFacet, - createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }), - ...this.#sharedFacets.facets, - ...presentationFacets.facets, - ], - serviceSources: [server.server, server.session], - }); - this.#facetHosts.push(facetHost); - } + env.own( + server.session.attachment.subscribe((state) => this.#handleAttachmentState(sessionFeature, state)), + ); + } + }); + }, + }); + facetHost = await createFacetHost({ + facets: [ + createSlashCommandsRuntimeFacet(), + presentationBridgeFacet, + createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }), + ...this.#sharedFacets.facets, + ...presentationFacets.facets, + ], + serviceSources: [server.server, server.session], + }); + this.#facetHost = facetHost; } async #openPreparedSession(prepared: PreparedClientSession): Promise { - const feature = this.#sessions.get(prepared.serverId); - if (feature === undefined) throw new Error(`No Session service is available for ${prepared.serverId}`); + const feature = this.#session; + if (feature === undefined) throw new Error(`No Session service is available for ${prepared.server.serverId}`); await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT); this.#selectedServerId = feature.serverId; this.#sessionId = prepared.summary.sessionId; @@ -369,37 +346,28 @@ export class ExperimentalClientTui implements Component { try { await this.#recoveryTransition; await this.#closeLane(); + await this.#facetReloadTail; } catch (error) { errors.push(error); } - const reloadResults = await Promise.allSettled([...this.#facetReloads].map(({ tail }) => tail)); - this.#facetReloads.clear(); - errors.push(...reloadResults.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); - const results = await Promise.allSettled( - this.#facetHosts - .splice(0) - .reverse() - .map((host) => host.dispose()), + if (this.#facetHost !== undefined) { + try { + await this.#facetHost.dispose(); + } catch (error) { + errors.push(error); + } + this.#facetHost = undefined; + } + const generations = [this.#presentationFacets, this.#sharedFacets].filter( + (generation): generation is LoadedFacets => generation !== undefined, ); + this.#presentationFacets = undefined; + const results = await Promise.allSettled(generations.map((generation) => generation.dispose())); errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); - const loaded = [...this.#loadedFacetGenerations].reverse(); - this.#loadedFacetGenerations.clear(); - const loadedResults = await Promise.allSettled(loaded.map((generation) => generation.dispose())); - errors.push(...loadedResults.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets"); } - #register(features: Map, label: string, feature: T): () => void { - if (features.has(feature.serverId)) - throw new Error(`${label} is already registered for server ${feature.serverId}`); - features.set(feature.serverId, feature); - return () => { - if (features.get(feature.serverId) !== feature) return; - features.delete(feature.serverId); - }; - } - #rebuild(): void { this.#sessionHeading.setText( this.#sessionId === undefined || this.#selectedServerId === undefined @@ -476,9 +444,7 @@ export class ExperimentalClientTui implements Component { } #selectedSlashCommands(): SlashCommands | undefined { - if (this.#selectedServerId !== undefined) return this.#slashCommands.get(this.#selectedServerId)?.commands; - if (this.#slashCommands.size === 1) return this.#slashCommands.values().next().value?.commands; - return undefined; + return this.#slashCommands; } #handleConnectionState(serverId: string, state: ServerConnectionState): void { @@ -650,7 +616,7 @@ export class ExperimentalClientTui implements Component { } #selectedController(): AgentController | undefined { - return this.#selectedServerId === undefined ? undefined : this.#agents.get(this.#selectedServerId)?.controller; + return this.#controller; } #footer(): string { @@ -749,7 +715,7 @@ async function prepareClientSession( await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT); await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT); return { - serverId: selected.server.serverId, + server: selected.server, summary: selected.summary, presentationPlugins, }; @@ -805,7 +771,6 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui servers: runtime.servers.map((server) => ({ serverId: server.route.serverId, radius: server.route.transport === "radius", - presentationFacetLoaders: server.presentationFacetLoaders, laneWatches: server.client, server: server.server, session: server.session, diff --git a/packages/coding-agent/src/experimental/plugins/bundled.ts b/packages/coding-agent/src/experimental/plugins/bundled.ts index 0c3503996..248cd201b 100644 --- a/packages/coding-agent/src/experimental/plugins/bundled.ts +++ b/packages/coding-agent/src/experimental/plugins/bundled.ts @@ -10,34 +10,26 @@ import type { JsonValue } from "@earendil-works/pi-protocol"; const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles"; const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin"; -export function createConfiguredPluginFacetLoader( - entry: "session", - manifestPaths: readonly string[], -): FacetLoader | undefined { +export function createSessionPluginFacetLoader(manifestPaths: readonly string[]): FacetLoader | undefined { if (manifestPaths.length === 0) return undefined; - return combineFacetLoaders(manifestPaths.map((manifestPath) => createOptionalFacetLoader(manifestPath, entry))); + return combineFacetLoaders(manifestPaths.map(createOptionalSessionFacetLoader)); } -function createOptionalFacetLoader(manifestPath: string, entry: "session"): FacetLoader { +function createOptionalSessionFacetLoader(manifestPath: string): FacetLoader { const loader = createFacetBundleLoader({ manifestPath, - entry, + entry: "session", resolveExternal: resolvePluginExternal, }); return { async load() { const manifest = await readFacetBundleManifest(manifestPath); - if (manifest.entries[entry] !== undefined) return loader.load(); + if (manifest.entries.session !== undefined) return loader.load(); return { facets: Object.freeze([]), async dispose() {} }; }, }; } -export function createPresentationFacetHelloData(artifacts: readonly FacetBundleArtifact[]): JsonValue | undefined { - if (artifacts.length === 0) return undefined; - return createPresentationFacetData(artifacts); -} - export function createPresentationFacetData(artifacts: readonly FacetBundleArtifact[]): JsonValue { return { [PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue), @@ -45,14 +37,13 @@ export function createPresentationFacetData(artifacts: readonly FacetBundleArtif } /** Create local loaders only from artifacts selected and sent by the connected server. */ -export function createPresentationFacetLoaders(data: JsonValue | undefined): readonly FacetLoader[] { - if (data === undefined) return []; +export function createPresentationFacetLoaders(data: JsonValue): readonly FacetLoader[] { if (data === null || Array.isArray(data) || typeof data !== "object") { - throw new Error("Invalid experimental server hello data"); + throw new Error("Invalid presentation plugin data"); } const artifacts = data[PRESENTATION_FACET_BUNDLES_KEY]; if (artifacts === undefined) return []; - if (!Array.isArray(artifacts)) throw new Error("Invalid presentation facet bundle list in server hello"); + if (!Array.isArray(artifacts)) throw new Error("Invalid presentation plugin bundle list"); return artifacts.map((artifact) => createFacetBundleArtifactLoader({ artifact, resolveExternal: resolvePluginExternal }), ); diff --git a/packages/coding-agent/src/experimental/plugins/package.ts b/packages/coding-agent/src/experimental/plugins/package.ts index 82191b5b9..57c353738 100644 --- a/packages/coding-agent/src/experimental/plugins/package.ts +++ b/packages/coding-agent/src/experimental/plugins/package.ts @@ -10,11 +10,9 @@ import { import type { ServerId } from "@earendil-works/pi-protocol"; const PLUGIN_PACKAGE_PROFILE_VERSION = 1; -const SESSION_PLUGIN_PACKAGE_PROFILE_VERSION = 1; const DEFAULT_PLUGIN_FACETS = Object.freeze({ session: "src/session.ts", tui: "src/tui.ts" }); export interface ConfiguredServerPluginPackage { - readonly packagePath: string; readonly manifestPath: string; build(): Promise; } @@ -26,73 +24,22 @@ export async function restoreServerPluginPackageProfile( configuredPackagePaths?: readonly string[], ): Promise { const path = join(directory, `plugin-packages-${serverId}.json`); - if (configuredPackagePaths !== undefined) { - const packagePaths = normalizePluginPackagePaths(configuredPackagePaths); - if (packagePaths.length === 0) { - await rm(path, { force: true }); - return packagePaths; - } - await writeFile(path, `${JSON.stringify({ version: PLUGIN_PACKAGE_PROFILE_VERSION, packagePaths }, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - return packagePaths; + if (configuredPackagePaths === undefined) { + return (await readPluginPackageProfile(path, false)) ?? []; } - - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(path, "utf8")); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; - throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error }); - } - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) || - Object.keys(parsed).some((key) => key !== "version" && key !== "packagePaths") || - !("version" in parsed) || - parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION || - !("packagePaths" in parsed) || - !Array.isArray(parsed.packagePaths) || - parsed.packagePaths.length === 0 || - parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0) - ) { - throw new Error(`Invalid experimental plugin package profile ${path}`); - } - return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]); + const packagePaths = normalizePluginPackagePaths(configuredPackagePaths); + if (packagePaths.length === 0) await rm(path, { force: true }); + else await writePluginPackageProfile(path, packagePaths); + return packagePaths; } /** Read the explicit plugin selection stored for one durable Session. */ -export async function readSessionPluginPackageProfile( +export function readSessionPluginPackageProfile( directory: string, serverId: ServerId, sessionPath: string, ): Promise { - const path = sessionPluginProfilePath(directory, serverId, sessionPath); - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(path, "utf8")); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; - throw new Error(`Could not read experimental Session plugin profile ${path}`, { cause: error }); - } - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) || - Object.keys(parsed).some((key) => key !== "version" && key !== "sessionPath" && key !== "packagePaths") || - !("version" in parsed) || - parsed.version !== SESSION_PLUGIN_PACKAGE_PROFILE_VERSION || - !("sessionPath" in parsed) || - parsed.sessionPath !== sessionPath || - !("packagePaths" in parsed) || - !Array.isArray(parsed.packagePaths) || - parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0) - ) { - throw new Error(`Invalid experimental Session plugin profile ${path}`); - } - return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]); + return readPluginPackageProfile(sessionPluginProfilePath(directory, serverId, sessionPath), true, sessionPath); } /** Remove the plugin selection stored for one deleted Session. */ @@ -105,22 +52,16 @@ export async function removeSessionPluginPackageProfile( } /** Persist the plugin selection for one durable Session. */ -export async function writeSessionPluginPackageProfile( +export function writeSessionPluginPackageProfile( directory: string, serverId: ServerId, sessionPath: string, packagePaths: readonly string[], ): Promise { - const normalized = normalizePluginPackagePaths(packagePaths); - const path = sessionPluginProfilePath(directory, serverId, sessionPath); - await writeFile( - path, - `${JSON.stringify( - { version: SESSION_PLUGIN_PACKAGE_PROFILE_VERSION, sessionPath, packagePaths: normalized }, - null, - 2, - )}\n`, - { encoding: "utf8", mode: 0o600 }, + return writePluginPackageProfile( + sessionPluginProfilePath(directory, serverId, sessionPath), + normalizePluginPackagePaths(packagePaths), + sessionPath, ); } @@ -135,7 +76,6 @@ export function createServerPluginPackage( const manifestPath = join(outdir, FACET_BUNDLE_MANIFEST_FILE); let buildTail = Promise.resolve(); return { - packagePath: normalizedPackagePath, manifestPath, build() { const operation = buildTail.then(async () => { @@ -156,6 +96,56 @@ export function createServerPluginPackage( }; } +async function readPluginPackageProfile( + path: string, + allowEmpty: boolean, + sessionPath?: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).some( + (key) => key !== "version" && key !== "packagePaths" && !(key === "sessionPath" && sessionPath !== undefined), + ) || + !("version" in parsed) || + parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION || + (sessionPath === undefined + ? "sessionPath" in parsed + : !("sessionPath" in parsed) || parsed.sessionPath !== sessionPath) || + !("packagePaths" in parsed) || + !Array.isArray(parsed.packagePaths) || + (!allowEmpty && parsed.packagePaths.length === 0) || + parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0) + ) { + throw new Error(`Invalid experimental plugin package profile ${path}`); + } + return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]); +} + +function writePluginPackageProfile(path: string, packagePaths: readonly string[], sessionPath?: string): Promise { + return writeFile( + path, + `${JSON.stringify( + { + version: PLUGIN_PACKAGE_PROFILE_VERSION, + ...(sessionPath === undefined ? {} : { sessionPath }), + packagePaths, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); +} + export function normalizePluginPackagePaths(packagePaths: readonly string[]): readonly string[] { const normalized = packagePaths.map((packagePath) => { if (packagePath.length === 0) throw new Error("Plugin package path must not be empty"); diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts index ad3821d63..2319786c2 100644 --- a/packages/coding-agent/src/experimental/server.ts +++ b/packages/coding-agent/src/experimental/server.ts @@ -13,7 +13,7 @@ import { import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client"; import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix"; -import { isServerId, type JsonValue, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol"; +import { isServerId, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol"; import { ServerError as RoutedServerError, type Server, @@ -27,7 +27,7 @@ import type { AuthInput } from "../cli/experimental/command-options.ts"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; import { CoordinatorConnection, type CoordinatorStartupLease, ensureCoordinator } from "./coordinator.ts"; -import { createPresentationFacetData, createPresentationFacetHelloData } from "./plugins/bundled.ts"; +import { createPresentationFacetData } from "./plugins/bundled.ts"; import { createServerPluginPackage, normalizePluginPackagePaths, @@ -161,17 +161,16 @@ export async function activateServer(options: ActivateServerOptions): Promise { spawnError = error; @@ -347,8 +346,6 @@ export interface StartServerOptions { readonly relayAuth?: AuthInput; /** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */ readonly pluginPackages?: readonly string[]; - /** Presentation facets selected by this server. Defaults to configured plugin packages' `tui` entries. */ - readonly presentationFacetBundles?: readonly FacetBundleArtifact[]; readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void; } @@ -362,16 +359,13 @@ interface StartServerBackendOptions { readonly path: string; readonly serverId: ServerId; readonly sessionDir?: string; - readonly helloData?: JsonValue; resolveSessionPlugins( metadata: JsonlSessionMetadata, packagePaths: readonly string[] | undefined, context: Context, ): Promise; removeSessionPlugins(metadata: JsonlSessionMetadata): Promise; - reloadPresentationFacetBundles( - packagePaths: readonly string[], - ): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }>; + reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise; } interface RunningServerBackend extends RunningServer { @@ -411,7 +405,6 @@ async function startServerBackend( if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Experimental session storage cleanup failed"); }; - let setHelloData = (_data: JsonValue): void => {}; const serverServices = await createExperimentalServerServices({ list: async (context) => (await listSessions(context)) @@ -446,10 +439,7 @@ async function startServerBackend( }; }, async reloadPresentationPlugins(packagePaths) { - const reloaded = await options.reloadPresentationFacetBundles(packagePaths); - const data = createPresentationFacetData(reloaded.artifacts); - if (reloaded.updateHello) setHelloData(data); - return data; + return createPresentationFacetData(await options.reloadPresentationFacetBundles(packagePaths)); }, }).catch(async (error: unknown) => { try { @@ -479,9 +469,7 @@ async function startServerBackend( path: socketPath, mode: 0o600, onConnectionCountChanged, - ...(options.helloData === undefined ? {} : { helloData: options.helloData }), }); - setHelloData = (data) => server.setHelloData(data); try { await server.start(); } catch (error) { @@ -544,16 +532,23 @@ export async function startServer(options: StartServerOptions = {}): Promise>(); + const getPluginPackage = (packagePath: string): ReturnType => { + let plugin = pluginPackages.get(packagePath); + if (plugin === undefined) { + plugin = createServerPluginPackage(directory, serverId, packagePath); + pluginPackages.set(packagePath, plugin); + } + return plugin; + }; const buildPluginSelection = async (packagePaths: readonly string[]): Promise => { const normalizedPackagePaths = normalizePluginPackagePaths(packagePaths); - const plugins = normalizedPackagePaths.map((packagePath) => - createServerPluginPackage(directory, serverId, packagePath), - ); + const plugins = normalizedPackagePaths.map(getPluginPackage); const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat(); return { packagePaths: normalizedPackagePaths, manifestPaths: plugins.map((plugin) => plugin.manifestPath), - presentationArtifacts: options.presentationFacetBundles ?? built, + presentationArtifacts: built, }; }; let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths); @@ -568,7 +563,7 @@ export async function startServer(options: StartServerOptions = {}): Promise createServerPluginPackage(directory, serverId, packagePath).manifestPath, + (packagePath) => getPluginPackage(packagePath).manifestPath, ); workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths); const candidate = await buildPluginSelection(normalizedPackagePaths); @@ -594,18 +589,18 @@ export async function startServer(options: StartServerOptions = {}): Promise => { + ): Promise => { const reloaded = await buildPluginSelection(packagePaths); - const updateHello = sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths); - if (updateHello) defaultPluginSelection = reloaded; + if (sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths)) { + defaultPluginSelection = reloaded; + } for (const [sessionPath, selected] of sessionPluginSelections) { if (sameStrings(selected.packagePaths, reloaded.packagePaths)) { sessionPluginSelections.set(sessionPath, reloaded); } } - return { artifacts: reloaded.presentationArtifacts, updateHello }; + return reloaded.presentationArtifacts; }; - const helloData = createPresentationFacetHelloData(defaultPluginSelection.presentationArtifacts); const socketPath = getUnixSocketPath(serverId, directory); const controlPath = join(directory, `control-${serverId}.sock`); const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12); @@ -613,12 +608,8 @@ export async function startServer(options: StartServerOptions = {}): Promise lifetime.setWorkerCount(count), - defaultPluginSelection.manifestPaths, + workers = new SessionWorkerManager(coordinator, sessionDir, workerModel, (count) => + lifetime.setWorkerCount(count), ); backend = await startServerBackend( { @@ -628,7 +619,6 @@ export async function startServer(options: StartServerOptions = {}): Promise lifetime.setConnectionCount(count), @@ -732,71 +722,47 @@ export async function startForegroundServer( } } -function parseServerStartupOptions(value: string | undefined): { - readonly provider?: string; - readonly model?: string; -} { - if (value === undefined) return {}; +function parseServerModelOptions(value: string | undefined): { provider?: string; model: string } | undefined { + if (value === undefined) return undefined; let parsed: unknown; try { parsed = JSON.parse(value); } catch (error) { - throw new Error("Internal server received invalid startup options", { cause: error }); + throw new Error("Internal server received invalid model options", { cause: error }); } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Internal server received invalid model options"); + } + const keys = Object.keys(parsed); + const model = "model" in parsed ? parsed.model : undefined; + const provider = "provider" in parsed ? parsed.provider : undefined; if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) || - Object.keys(parsed).some((key) => key !== "model") + keys.some((key) => key !== "provider" && key !== "model") || + typeof model !== "string" || + model.length === 0 || + (provider !== undefined && (typeof provider !== "string" || provider.length === 0)) ) { - throw new Error("Internal server received invalid startup options"); + throw new Error("Internal server received invalid model options"); } - const modelOptions = "model" in parsed ? parsed.model : undefined; - let model: string | undefined; - let provider: string | undefined; - if (modelOptions !== undefined) { - if ( - typeof modelOptions !== "object" || - modelOptions === null || - Array.isArray(modelOptions) || - Object.keys(modelOptions).some((key) => key !== "provider" && key !== "model") - ) { - throw new Error("Internal server received invalid startup options"); - } - const modelCandidate = "model" in modelOptions ? modelOptions.model : undefined; - const providerCandidate = "provider" in modelOptions ? modelOptions.provider : undefined; - if ( - typeof modelCandidate !== "string" || - modelCandidate.length === 0 || - (providerCandidate !== undefined && (typeof providerCandidate !== "string" || providerCandidate.length === 0)) - ) { - throw new Error("Internal server received invalid startup options"); - } - model = modelCandidate; - provider = providerCandidate; - } - return { - ...(provider === undefined ? {} : { provider }), - ...(model === undefined ? {} : { model }), - }; + return provider === undefined ? { model } : { provider, model }; } /** Run an automatically activated server until its client and Session demand disappears. */ export async function runServerProcess(args: readonly string[]): Promise { - const [directory, serverId, sessionDir, serializedStartupOptions] = args; + const [directory, serverId, sessionDir, serializedModel] = args; if (args.length > 4) throw new Error("Internal server received unexpected arguments"); if (!directory || !isAbsolute(directory)) throw new Error("Internal server requires an absolute server directory"); if (!isServerId(serverId)) throw new Error("Internal server requires a canonical server ID"); if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Internal server requires an absolute Session directory"); - const startupOptions = parseServerStartupOptions(serializedStartupOptions); + const workerModel = parseServerModelOptions(serializedModel); const runtime = await startServer({ directory, serverId, sessionDir, keepAlive: false, - ...startupOptions, + ...workerModel, }); const close = (): void => { void runtime.close().catch(() => {}); diff --git a/packages/coding-agent/src/experimental/session-worker-manager.ts b/packages/coding-agent/src/experimental/session-worker-manager.ts index 71e8172b8..550c4c079 100644 --- a/packages/coding-agent/src/experimental/session-worker-manager.ts +++ b/packages/coding-agent/src/experimental/session-worker-manager.ts @@ -128,7 +128,6 @@ export class SessionWorkerManager { readonly #serviceSubscriptions = new Map(); readonly #removeListener: () => void; readonly #onWorkerCountChanged: ((count: number) => void) | undefined; - readonly #pluginManifestPaths: readonly string[]; #discoveryPeers?: Set; #resolveDiscovery?: () => void; #detached = false; @@ -142,13 +141,11 @@ export class SessionWorkerManager { sessionDir: string, model?: { readonly provider?: string; readonly model: string }, onWorkerCountChanged?: (count: number) => void, - pluginManifestPaths: readonly string[] = [], ) { this.#coordinator = coordinator; this.#sessionDir = sessionDir; this.#model = model; this.#onWorkerCountChanged = onWorkerCountChanged; - this.#pluginManifestPaths = Object.freeze([...pluginManifestPaths]); this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event)); } @@ -198,7 +195,7 @@ export class SessionWorkerManager { async openSession( metadata: JsonlSessionMetadata, context: Context, - pluginManifestPaths: readonly string[] = this.#pluginManifestPaths, + pluginManifestPaths: readonly string[], ): Promise { if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down"); this.assertSessionPluginManifestPaths(metadata, pluginManifestPaths); @@ -540,8 +537,8 @@ export class SessionWorkerManager { const options: SessionWorkerOptions = { sessionDir: this.#sessionDir, metadata, + pluginManifestPaths: [...pluginManifestPaths], ...(this.#model ?? {}), - ...(pluginManifestPaths.length === 0 ? {} : { pluginManifestPaths: [...pluginManifestPaths] }), }; child = spawnInternalProcess("session-worker", [JSON.stringify(options)], { env: { diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 035ffbf61..0923f37a3 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -47,7 +47,7 @@ import { toWireLaneSnapshot, toWireRunResult, } from "./harness-wire-adapter.ts"; -import { createConfiguredPluginFacetLoader } from "./plugins/bundled.ts"; +import { createSessionPluginFacetLoader } from "./plugins/bundled.ts"; import { consumeInternalProcessRole, encodeControlLine, @@ -87,7 +87,7 @@ export const SessionWorkerOptionsSchema = StrictObject({ metadata: SessionWorkerMetadataSchema, provider: Type.Optional(Type.String({ minLength: 1 })), model: Type.Optional(Type.String({ minLength: 1 })), - pluginManifestPaths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })), }); export type SessionWorkerOptions = Static; @@ -570,7 +570,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!; const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString(); failureControl = control; - const pluginManifestPaths = options.pluginManifestPaths ?? []; + const pluginManifestPaths = options.pluginManifestPaths; const releaseOwnership = await lockfile.lock(metadata.path, { realpath: true, stale: 2_000, @@ -958,7 +958,7 @@ async function createCodingAgentHarness( lane, modelRuntime, settingsManager, - facetLoader: createConfiguredPluginFacetLoader("session", options.pluginManifestPaths ?? []), + facetLoader: createSessionPluginFacetLoader(options.pluginManifestPaths), }; } catch (error) { try { diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 2557db3f8..8800f0b97 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -2,8 +2,6 @@ import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { createRemoteServiceBinding, - defineFacet, - type FacetLoader, RemoteServiceProvider, type RemoteServiceTransport, replicatedState, @@ -19,7 +17,7 @@ import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts"; -import { createPresentationFacetHelloData } from "../src/experimental/plugins/bundled.ts"; +import { createPresentationFacetData } from "../src/experimental/plugins/bundled.ts"; import { AgentController } from "../src/experimental/services/agent-controller.ts"; import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts"; import type { @@ -204,8 +202,7 @@ describe("experimental client TUI", () => { }, source: reloadSource, }; - const reloadData = createPresentationFacetHelloData([reloadArtifact]); - if (reloadData === undefined) throw new Error("Expected presentation reload data"); + const reloadData = createPresentationFacetData([reloadArtifact]); const prepareSessionPlugins = vi.fn(async () => reloadData); const reloadPresentationPlugins = vi.fn(async () => reloadData); const reloadSessionPlugins = vi.fn(async () => {}); @@ -296,27 +293,9 @@ describe("experimental client TUI", () => { attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); }, }); - const disposeLoadedFacets = vi.fn(async () => {}); - const facetLoader: FacetLoader = { - async load() { - return { - facets: [ - defineFacet({ - id: "test-tui-facet", - setup(env) { - const models = env.use(Models); - env.onActivate(() => env.own(models.state.subscribe(() => {}))); - }, - }), - ], - dispose: disposeLoadedFacets, - }; - }, - }; const server: ClientTuiServer = { serverId, radius: true, - presentationFacetLoaders: [facetLoader], laneWatches: { watchSession }, server: serverServices, session: sessionServices, @@ -423,7 +402,6 @@ describe("experimental client TUI", () => { await vi.waitFor(() => expect(finished).toBe(true)); await component.close(); - expect(disposeLoadedFacets).not.toHaveBeenCalled(); const rendersAfterClose = requestRender.mock.calls.length; directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT); attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); diff --git a/packages/coding-agent/test/experimental-presentation-facets.test.ts b/packages/coding-agent/test/experimental-presentation-facets.test.ts index a1bd540b0..37f007772 100644 --- a/packages/coding-agent/test/experimental-presentation-facets.test.ts +++ b/packages/coding-agent/test/experimental-presentation-facets.test.ts @@ -1,44 +1,30 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { - FACET_BUNDLE_ARTIFACT_FORMAT, - FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, - type FacetBundleArtifact, - readFacetBundleManifest, -} from "@earendil-works/chord/node"; -import { TestServerHost } from "@earendil-works/pi-server/testing"; -import { createUnixServer } from "@earendil-works/pi-server/unix"; +import { readFacetBundleManifest } from "@earendil-works/chord/node"; import { afterEach, describe, expect, test } from "vitest"; import { activateBuiltinClientServices, type ClientRuntime, openClientRuntime, } from "../src/experimental/client-runtime.ts"; -import { - createPresentationFacetData, - createPresentationFacetHelloData, - createPresentationFacetLoaders, -} from "../src/experimental/plugins/bundled.ts"; +import { createPresentationFacetData, createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts"; import { createServerPluginPackage, restoreServerPluginPackageProfile } from "../src/experimental/plugins/package.ts"; import { type RunningServer, startServer } from "../src/experimental/server.ts"; import { PresentationPlugins } from "../src/experimental/services/plugins.ts"; const runtimes = new Set(); const runningServers = new Set(); -const servers = new Set>(); const directories = new Set(); afterEach(async () => { await Promise.allSettled([...runtimes].map((runtime) => runtime.dispose())); await Promise.allSettled([...runningServers].map((server) => server.close())); - await Promise.allSettled([...servers].map((server) => server.close())); await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true }))); runtimes.clear(); runningServers.clear(); - servers.clear(); directories.clear(); }); @@ -184,46 +170,4 @@ describe("server-selected presentation facets", () => { expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]); await loaded.dispose(); }); - - test("receives and loads a TUI facet without client-side plugin configuration", async () => { - const source = - 'import { defineFacet } from "@earendil-works/chord";\nexport default defineFacet({ id: "server-selected-tui", setup() {} });\n'; - const artifact: FacetBundleArtifact = { - format: FACET_BUNDLE_ARTIFACT_FORMAT, - formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, - plugin: { id: "server-selected-plugin" }, - entryName: "tui", - entry: { - file: "tui.js", - integrity: `sha256-${createHash("sha256").update(source).digest("base64")}`, - externalImports: ["@earendil-works/chord"], - }, - source, - }; - const directory = await mkdtemp("/tmp/pi-presentation-facet-"); - directories.add(directory); - const serverId = randomUUID(); - const socketPath = join(directory, `${serverId}.sock`); - const server = createUnixServer(new TestServerHost(), { - path: socketPath, - serverId, - helloData: createPresentationFacetHelloData([artifact]), - }); - servers.add(server); - await server.start(); - - const runtime = await openClientRuntime({ - command: "client", - connect: { transport: "unix", path: socketPath }, - }); - runtimes.add(runtime); - expect(runtime.servers[0]?.presentationFacetLoaders).toHaveLength(1); - - const loaded = await runtime.servers[0]!.presentationFacetLoaders[0]!.load(); - try { - expect(loaded.facets.map(({ id }) => id)).toEqual(["server-selected-tui"]); - } finally { - await loaded.dispose(); - } - }); }); diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts index 92d367418..be30b54f7 100644 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-manager.test.ts @@ -67,7 +67,7 @@ async function createAttachedWorker(): Promise<{ const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); coordinator.onSend = (peerId, payload) => { if (payload.type !== "session_demand") return; queueMicrotask(() => @@ -98,9 +98,7 @@ async function createAttachedWorker(): Promise<{ describe("Session worker lifecycle failures", () => { test("adopts a discovered worker with its existing Session plugin selection", async () => { const coordinator = new FakeCoordinator(); - const workers = new SessionWorkerManager(coordinator, "/tmp", undefined, undefined, [ - "/tmp/plugin/chord-facets.json", - ]); + const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); expect(coordinator.sent).not.toContainEqual({ peerId: "worker-1", payload: { type: "shutdown" } }); expect(workers.workerPids.size).toBe(1); @@ -122,7 +120,7 @@ describe("Session worker lifecycle failures", () => { const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); const demands: { attachmentId: string; attached: boolean }[] = []; coordinator.onSend = (peerId, payload) => { if ( @@ -165,7 +163,7 @@ describe("Session worker lifecycle failures", () => { const coordinator = new FakeCoordinator(); const workers = new SessionWorkerManager(coordinator, "/tmp"); await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT); + const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); coordinator.onSend = () => {}; const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("worker was terminated"); diff --git a/packages/protocol/README.md b/packages/protocol/README.md index cf265ce6d..b219a03b8 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -2,9 +2,9 @@ Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol. -Protocol version `5` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation: +Protocol version `6` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation: -- a version handshake that identifies the logical `serverId` and can carry application bootstrap data; +- a version handshake that identifies the logical `serverId`; - explicit server and Session request targets; - contract-agnostic service calls with optional keyed-instance identity; - singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index 79ef05f05..570d6a2e4 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -13,7 +13,7 @@ import { type RpcResultUnion, } from "./rpc.ts"; -export const PROTOCOL_VERSION = 5 as const; +export const PROTOCOL_VERSION = 6 as const; const IdSchema = Type.String({ minLength: 1 }); const TimestampSchema = Type.Integer({ minimum: 0 }); @@ -352,8 +352,6 @@ export const ServerHelloSchema = StrictObject({ type: Type.Literal("hello"), version: Type.Literal(PROTOCOL_VERSION), serverId: ServerIdSchema, - /** Application-owned connection bootstrap data, such as presentation facet artifacts. */ - data: Type.Optional(JsonValueSchema), }); export const ServerHelloErrorSchema = StrictObject({ type: Type.Literal("hello_error"), diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 619c6ba4d..84ac19b62 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -207,11 +207,11 @@ describe("RPC manifest", () => { }); describe("protocol validation", () => { - test("negotiates protocol version 5", () => { - expect(PROTOCOL_VERSION).toBe(5); - expect(isSupportedProtocolVersion(5)).toBe(true); - expect(isSupportedProtocolVersion(4)).toBe(false); - expect(isSupportedProtocolVersion(5.5)).toBe(false); + test("negotiates protocol version 6", () => { + expect(PROTOCOL_VERSION).toBe(6); + expect(isSupportedProtocolVersion(6)).toBe(true); + expect(isSupportedProtocolVersion(5)).toBe(false); + expect(isSupportedProtocolVersion(6.5)).toBe(false); }); test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])( @@ -617,11 +617,6 @@ describe("protocol validation", () => { }); }); - test("accepts application bootstrap data in the server hello", () => { - const message: ServerHello = { ...serverHello, data: { presentationFacets: [{ id: "tui" }] } }; - expect(parseServerMessage(message)).toEqual(message); - }); - test.each([ ["invalid server id", { ...serverHello, serverId: "server-1" }], ["extra response field", { type: "response", id: "request-1", ok: true, result: [], extra: true }], diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 4d2a17d33..8983c48a4 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -10,7 +10,6 @@ import { encodeServerMessage, isServerId, isSupportedProtocolVersion, - type JsonValue, PROTOCOL_VERSION, type ProtocolError, type ProtocolRpcResult, @@ -44,7 +43,6 @@ export class Server { private readonly host: ServerHost; private readonly listeners: readonly ServerListener[]; - private helloData: JsonValue | undefined; private readonly maxFrameLength: number; private readonly handshakeTimeoutMs: number; private readonly onConnectionCountChanged: ((count: number) => void) | undefined; @@ -64,7 +62,6 @@ export class Server { this.host = host; this.listeners = options.listeners; this.serverId = options.serverId; - this.helloData = options.helloData; this.maxFrameLength = resolved.maxFrameLength; this.handshakeTimeoutMs = resolved.handshakeTimeoutMs; this.onConnectionCountChanged = options.onConnectionCountChanged; @@ -88,11 +85,6 @@ export class Server { void this.closed.catch(() => {}); } - /** Replace application bootstrap data used by subsequent handshakes. */ - setHelloData(data: JsonValue | undefined): void { - this.helloData = data; - } - start(): Promise { if (this.started) return Promise.reject(new Error("Server is already started")); if (this.startPromise) return Promise.reject(new Error("Server is already starting")); @@ -291,7 +283,6 @@ export class Server { type: "hello", version: PROTOCOL_VERSION, serverId: this.serverId, - ...(this.helloData === undefined ? {} : { data: this.helloData }), } satisfies ServerHello); if (sent && !state.disconnected && state.stage === "handshaking") { state.stage = "ready"; diff --git a/packages/server/src/transports/unix/preset.ts b/packages/server/src/transports/unix/preset.ts index 52daae976..2915f7cd2 100644 --- a/packages/server/src/transports/unix/preset.ts +++ b/packages/server/src/transports/unix/preset.ts @@ -23,7 +23,6 @@ export function createUnixServer( handshakeTimeoutMs: options.handshakeTimeoutMs, onConnectionCountChanged: options.onConnectionCountChanged, serverId: options.serverId, - helloData: options.helloData, onError: options.onError, }); } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 5060757a1..2a85e509e 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,6 +1,5 @@ import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core"; import type { - JsonValue, LaneEvent, LaneSnapshot, PromptArguments, @@ -16,8 +15,6 @@ export interface ServerOptions { listeners: readonly ServerListener[]; /** Stable logical server identity supplied by the installation or profile. */ serverId: string; - /** Application-owned bootstrap data included in every successful server handshake. */ - helloData?: JsonValue; maxFrameLength?: number; handshakeTimeoutMs?: number; onConnectionCountChanged?: (count: number) => void; diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index fe864db7b..be95fbd7c 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -3,19 +3,14 @@ import { afterEach, describe, expect, test } from "vitest"; import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; import { Server } from "../src/server.ts"; import { Deferred, ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts"; -import type { ServerHost, ServerOptions } from "../src/types.ts"; +import type { ServerHost } from "../src/types.ts"; const servers = new Set(); -function createServer( - host: ServerHost, - serverId = "00000000-0000-4000-8000-000000000001", - helloData?: ServerOptions["helloData"], -): Server { +function createServer(host: ServerHost, serverId = "00000000-0000-4000-8000-000000000001"): Server { const server = new Server(host, { listeners: [], serverId, - ...(helloData === undefined ? {} : { helloData }), }); servers.add(server); return server; @@ -73,21 +68,6 @@ describe("Session protocol", () => { expect(host.harnesses.size).toBe(0); }); - test("handshake includes current application bootstrap data", async () => { - const host = new TestServerHost(); - const server = createServer(host, "00000000-0000-4000-8000-000000000001", { - presentationFacetBundles: [{ id: "tui-1" }], - }); - await expect(connect(server).hello()).resolves.toMatchObject({ - data: { presentationFacetBundles: [{ id: "tui-1" }] }, - }); - - server.setHelloData({ presentationFacetBundles: [{ id: "tui-2" }] }); - await expect(connect(server).hello()).resolves.toMatchObject({ - data: { presentationFacetBundles: [{ id: "tui-2" }] }, - }); - }); - test("list returns presentation-safe summaries without opening a Session", async () => { const host = new TestServerHost(); const metadata = await host.seed("session-1", "parent-1");