mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
429f4e756c
commit
5245aba7c8
18 changed files with 251 additions and 471 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<LoadedFacets>();
|
||||
readonly #facetReloads = new Set<{ tail: Promise<void> }>();
|
||||
readonly #facetHosts: FacetHost[] = [];
|
||||
readonly #sessions = new Map<string, SessionFeature>();
|
||||
readonly #slashCommands = new Map<string, SlashCommandFeature>();
|
||||
readonly #agents = new Map<string, AgentFeature>();
|
||||
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<void> {
|
||||
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<void> => {
|
||||
const operation = reloadState.tail.then(async () => {
|
||||
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
|
||||
async #start(prepared: PreparedClientSession): Promise<void> {
|
||||
const server = prepared.server;
|
||||
let presentationFacets = await combineFacetLoaders(
|
||||
createPresentationFacetLoaders(prepared.presentationPlugins),
|
||||
).load();
|
||||
this.#presentationFacets = presentationFacets;
|
||||
let facetHost!: FacetHost;
|
||||
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
|
||||
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<void> {
|
||||
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<T extends { readonly serverId: string }>(features: Map<string, T>, 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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<readonly FacetBundleArtifact[]>;
|
||||
}
|
||||
|
|
@ -26,73 +24,22 @@ export async function restoreServerPluginPackageProfile(
|
|||
configuredPackagePaths?: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
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<readonly string[] | undefined> {
|
||||
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<void> {
|
||||
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<readonly string[] | undefined> {
|
||||
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<void> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<Ac
|
|||
}
|
||||
return { client: existing, route };
|
||||
}
|
||||
const startupOptions =
|
||||
const modelArgs =
|
||||
options.model === undefined
|
||||
? {}
|
||||
: {
|
||||
model: {
|
||||
? []
|
||||
: [
|
||||
JSON.stringify({
|
||||
...(options.provider === undefined ? {} : { provider: options.provider }),
|
||||
model: options.model,
|
||||
},
|
||||
};
|
||||
const startupArgs = Object.keys(startupOptions).length === 0 ? [] : [JSON.stringify(startupOptions)];
|
||||
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...startupArgs]);
|
||||
}),
|
||||
];
|
||||
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...modelArgs]);
|
||||
let spawnError: Error | undefined;
|
||||
child.once("error", (error) => {
|
||||
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<ResolvedSessionPlugins>;
|
||||
removeSessionPlugins(metadata: JsonlSessionMetadata): Promise<void>;
|
||||
reloadPresentationFacetBundles(
|
||||
packagePaths: readonly string[],
|
||||
): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }>;
|
||||
reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise<readonly FacetBundleArtifact[]>;
|
||||
}
|
||||
|
||||
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<Run
|
|||
try {
|
||||
await ensurePrivateServerDirectory(directory);
|
||||
const pluginPackagePaths = await restoreServerPluginPackageProfile(directory, serverId, options.pluginPackages);
|
||||
const pluginPackages = new Map<string, ReturnType<typeof createServerPluginPackage>>();
|
||||
const getPluginPackage = (packagePath: string): ReturnType<typeof createServerPluginPackage> => {
|
||||
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<ResolvedSessionPlugins> => {
|
||||
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<Run
|
|||
const current = sessionPluginSelections.get(metadata.path);
|
||||
if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current;
|
||||
const requestedManifestPaths = normalizedPackagePaths.map(
|
||||
(packagePath) => 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<Run
|
|||
};
|
||||
const reloadPresentationFacetBundles = async (
|
||||
packagePaths: readonly string[],
|
||||
): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }> => {
|
||||
): Promise<readonly FacetBundleArtifact[]> => {
|
||||
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<Run
|
|||
startupLease = await ensureCoordinator(socketPath, controlPath);
|
||||
coordinator = new CoordinatorConnection({ controlPath, endpoint: serverPath });
|
||||
const sessionDir = resolveSessionDirectory(options.sessionDir);
|
||||
workers = new SessionWorkerManager(
|
||||
coordinator,
|
||||
sessionDir,
|
||||
workerModel,
|
||||
(count) => 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<Run
|
|||
resolveSessionPlugins,
|
||||
removeSessionPlugins,
|
||||
reloadPresentationFacetBundles,
|
||||
...(helloData === undefined ? {} : { helloData }),
|
||||
},
|
||||
workers,
|
||||
(count) => 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<void> {
|
||||
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(() => {});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ export class SessionWorkerManager {
|
|||
readonly #serviceSubscriptions = new Map<string, WorkerServiceSubscription>();
|
||||
readonly #removeListener: () => void;
|
||||
readonly #onWorkerCountChanged: ((count: number) => void) | undefined;
|
||||
readonly #pluginManifestPaths: readonly string[];
|
||||
#discoveryPeers?: Set<string>;
|
||||
#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<RoutedSessionHandle> {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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<typeof SessionWorkerOptionsSchema>;
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ClientRuntime>();
|
||||
const runningServers = new Set<RunningServer>();
|
||||
const servers = new Set<ReturnType<typeof createUnixServer>>();
|
||||
const directories = new Set<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
encodeServerMessage,
|
||||
isServerId,
|
||||
isSupportedProtocolVersion,
|
||||
type JsonValue,
|
||||
PROTOCOL_VERSION,
|
||||
type ProtocolError,
|
||||
type ProtocolRpcResult,
|
||||
|
|
@ -44,7 +43,6 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
|
||||
private readonly host: ServerHost<TMetadata>;
|
||||
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<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
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<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
void this.closed.catch(() => {});
|
||||
}
|
||||
|
||||
/** Replace application bootstrap data used by subsequent handshakes. */
|
||||
setHelloData(data: JsonValue | undefined): void {
|
||||
this.helloData = data;
|
||||
}
|
||||
|
||||
start(): Promise<this> {
|
||||
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<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
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";
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export function createUnixServer<TMetadata extends SessionMetadata>(
|
|||
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
||||
onConnectionCountChanged: options.onConnectionCountChanged,
|
||||
serverId: options.serverId,
|
||||
helloData: options.helloData,
|
||||
onError: options.onError,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Server>();
|
||||
|
||||
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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue