chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
798
third_party/pi/packages/coding-agent/src/experimental/server.ts
vendored
Normal file
798
third_party/pi/packages/coding-agent/src/experimental/server.ts
vendored
Normal file
@@ -0,0 +1,798 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { FacetBundleArtifact } from "@earendil-works/chord/node";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type JsonlSessionMetadata,
|
||||
JsonlSessionRepo,
|
||||
TODO_CONTEXT,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
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 ServerId } from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
ServerError as RoutedServerError,
|
||||
type Server,
|
||||
type ServerHost,
|
||||
SessionAmbiguousError,
|
||||
SessionNotFoundError,
|
||||
} from "@earendil-works/pi-server";
|
||||
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
||||
import lockfile from "proper-lockfile";
|
||||
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 } from "./plugins/bundled.ts";
|
||||
import {
|
||||
createServerPluginPackage,
|
||||
normalizePluginPackagePaths,
|
||||
readSessionPluginPackageProfile,
|
||||
removeSessionPluginPackageProfile,
|
||||
restoreServerPluginPackageProfile,
|
||||
writeSessionPluginPackageProfile,
|
||||
} from "./plugins/package.ts";
|
||||
import {
|
||||
consumeInternalProcessRole,
|
||||
isDirectInternalProcessEntry,
|
||||
spawnInternalProcess,
|
||||
terminateInternalProcess,
|
||||
} from "./process.ts";
|
||||
import { RadiusRelayAuthResolver } from "./radius-auth.ts";
|
||||
import { RadiusRelayHost, type RadiusRelayHostStatus } from "./radius-relay.ts";
|
||||
import { createExperimentalServerServices } from "./services/server.ts";
|
||||
import type { SessionCreateOptions, SessionSummary } from "./services/sessions.ts";
|
||||
import { SessionPluginSelectionConflictError, SessionWorkerManager } from "./session-worker-manager.ts";
|
||||
|
||||
export const ENV_SERVER_DIR = "PI_SERVER_DIR";
|
||||
export const ENV_SERVER_ID = "PI_SERVER_ID";
|
||||
|
||||
export function resolveServerDirectory(directory?: string): string {
|
||||
return resolvePath(directory ?? process.env[ENV_SERVER_DIR] ?? join(homedir(), ".pi", "server"));
|
||||
}
|
||||
|
||||
export async function ensurePrivateServerDirectory(directory: string): Promise<void> {
|
||||
if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID");
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stats = await lstat(directory);
|
||||
if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`);
|
||||
if (stats.uid !== process.getuid()) {
|
||||
throw new Error(`Unix socket directory is not owned by the current user: ${directory}`);
|
||||
}
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
export function resolveSessionDirectory(sessionDir?: string): string {
|
||||
return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions"));
|
||||
}
|
||||
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_RETRY_MS = 25;
|
||||
const LOCK_WAIT_MS = 30_000;
|
||||
const DEFAULT_SERVER_ID_FILE = "default-server-id";
|
||||
|
||||
export interface ServerProfile {
|
||||
readonly serverId: ServerId;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Lock one logical server ID in a shared experimental server directory. */
|
||||
export async function acquireServerProfile(directory: string, requestedServerId?: string): Promise<ServerProfile> {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
let serverId: ServerId;
|
||||
if (requestedServerId !== undefined) {
|
||||
if (!isServerId(requestedServerId)) throw new Error(`Invalid experimental server ID: ${requestedServerId}`);
|
||||
serverId = requestedServerId;
|
||||
} else {
|
||||
const path = join(directory, DEFAULT_SERVER_ID_FILE);
|
||||
try {
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
|
||||
serverId = value;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
if (code !== "ENOENT") throw error;
|
||||
const candidate = randomUUID();
|
||||
try {
|
||||
await writeFile(path, candidate, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
serverId = candidate;
|
||||
} catch (writeError) {
|
||||
const writeCode = writeError instanceof Error && "code" in writeError ? writeError.code : undefined;
|
||||
if (writeCode !== "EEXIST") throw writeError;
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
|
||||
serverId = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const release = await lockfile.lock(join(directory, `launcher-${serverId}`), {
|
||||
realpath: false,
|
||||
stale: LOCK_STALE_MS,
|
||||
update: LOCK_STALE_MS / 3,
|
||||
retries: {
|
||||
retries: Math.ceil(LOCK_WAIT_MS / LOCK_RETRY_MS),
|
||||
factor: 1,
|
||||
minTimeout: LOCK_RETRY_MS,
|
||||
maxTimeout: LOCK_RETRY_MS,
|
||||
maxRetryTime: LOCK_WAIT_MS,
|
||||
},
|
||||
});
|
||||
return { serverId, release };
|
||||
}
|
||||
|
||||
const ACTIVATION_TIMEOUT_MS = 10_000;
|
||||
const ACTIVATION_RETRY_MS = 10;
|
||||
|
||||
export interface ActivatedServer {
|
||||
readonly client: Client;
|
||||
readonly route: UnixServerRoute;
|
||||
}
|
||||
|
||||
export interface ActivateServerOptions {
|
||||
readonly directory: string;
|
||||
readonly requestedServerId?: ServerId | string;
|
||||
readonly sessionDir: string;
|
||||
readonly provider?: string;
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
/** Ensure the selected logical server is reachable, launching the current Pi installation if needed. */
|
||||
export async function activateServer(options: ActivateServerOptions): Promise<ActivatedServer> {
|
||||
if (options.provider !== undefined && options.model === undefined) {
|
||||
throw new Error("Server model provider requires a model");
|
||||
}
|
||||
await ensurePrivateServerDirectory(options.directory);
|
||||
const profile = await acquireServerProfile(options.directory, options.requestedServerId);
|
||||
const serverId = profile.serverId;
|
||||
await profile.release();
|
||||
const route = { serverId, path: getUnixSocketPath(serverId, options.directory) };
|
||||
const release = await acquireServerActivation(options.directory, serverId);
|
||||
try {
|
||||
const existing = await connect(route);
|
||||
if (existing) {
|
||||
// Another activator won the race, so startup-only selections can no longer be applied.
|
||||
if (options.model !== undefined) {
|
||||
await existing.dispose();
|
||||
throw new Error("Model selection is only valid when automatically activating a new server");
|
||||
}
|
||||
return { client: existing, route };
|
||||
}
|
||||
const modelArgs =
|
||||
options.model === undefined
|
||||
? []
|
||||
: [
|
||||
JSON.stringify({
|
||||
...(options.provider === undefined ? {} : { provider: options.provider }),
|
||||
model: options.model,
|
||||
}),
|
||||
];
|
||||
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...modelArgs]);
|
||||
let spawnError: Error | undefined;
|
||||
child.once("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
try {
|
||||
const deadline = Date.now() + ACTIVATION_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const client = await connect(route);
|
||||
if (client) return { client, route };
|
||||
if (spawnError) throw new Error("Failed to automatically activate server", { cause: spawnError });
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error("Automatically activated server exited during startup");
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for automatically activated server");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ACTIVATION_RETRY_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
await terminateInternalProcess(child);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireServerActivation(directory: string, serverId: ServerId): Promise<() => Promise<void>> {
|
||||
return lockfile.lock(join(directory, `activation-${serverId}`), {
|
||||
realpath: false,
|
||||
stale: ACTIVATION_TIMEOUT_MS * 2,
|
||||
update: ACTIVATION_TIMEOUT_MS,
|
||||
retries: {
|
||||
retries: Math.ceil(ACTIVATION_TIMEOUT_MS / 25),
|
||||
factor: 1,
|
||||
minTimeout: 25,
|
||||
maxTimeout: 25,
|
||||
maxRetryTime: ACTIVATION_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(route: UnixServerRoute): Promise<Client | undefined> {
|
||||
const client = new Client({
|
||||
serverId: route.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: route.path }),
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose();
|
||||
if (error instanceof DisconnectedError || (error instanceof ClientServerError && error.code === "version")) {
|
||||
return undefined;
|
||||
}
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
if (
|
||||
"code" in current &&
|
||||
["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT"].includes(String(current.code))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SERVER_STARTUP_GRACE_MS = 10_000;
|
||||
const AUTO_SERVER_IDLE_GRACE_MS = 1_000;
|
||||
|
||||
/** Reconcile operator, startup, client, and worker holds for one server generation. */
|
||||
export class ServerLifetime {
|
||||
readonly #keepAlive: boolean;
|
||||
#connectionCount = 0;
|
||||
#workerCount = 0;
|
||||
#startupHeld: boolean;
|
||||
#startupTimer: NodeJS.Timeout | undefined;
|
||||
#retirementTimer: NodeJS.Timeout | undefined;
|
||||
#retire: (() => void) | undefined;
|
||||
#stopped = false;
|
||||
|
||||
constructor(keepAlive: boolean) {
|
||||
this.#keepAlive = keepAlive;
|
||||
this.#startupHeld = !keepAlive;
|
||||
}
|
||||
|
||||
start(retire: () => void): void {
|
||||
this.#retire = retire;
|
||||
if (this.#startupHeld) {
|
||||
this.#startupTimer = setTimeout(() => {
|
||||
this.#startupTimer = undefined;
|
||||
this.#startupHeld = false;
|
||||
this.#reconcile();
|
||||
}, AUTO_SERVER_STARTUP_GRACE_MS);
|
||||
this.#startupTimer.unref();
|
||||
}
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
setConnectionCount(count: number): void {
|
||||
this.#connectionCount = count;
|
||||
if (count > 0 && this.#startupHeld) {
|
||||
this.#startupHeld = false;
|
||||
if (this.#startupTimer) clearTimeout(this.#startupTimer);
|
||||
this.#startupTimer = undefined;
|
||||
}
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
setWorkerCount(count: number): void {
|
||||
this.#workerCount = count;
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#stopped = true;
|
||||
if (this.#startupTimer) clearTimeout(this.#startupTimer);
|
||||
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
|
||||
this.#startupTimer = undefined;
|
||||
this.#retirementTimer = undefined;
|
||||
}
|
||||
|
||||
#reconcile(): void {
|
||||
if (
|
||||
this.#stopped ||
|
||||
this.#keepAlive ||
|
||||
this.#startupHeld ||
|
||||
this.#connectionCount !== 0 ||
|
||||
this.#workerCount !== 0
|
||||
) {
|
||||
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
|
||||
this.#retirementTimer = undefined;
|
||||
return;
|
||||
}
|
||||
const retire = this.#retire;
|
||||
if (this.#retirementTimer || !retire) return;
|
||||
this.#retirementTimer = setTimeout(() => {
|
||||
this.#retirementTimer = undefined;
|
||||
if (!this.#stopped && !this.#startupHeld && this.#connectionCount === 0 && this.#workerCount === 0) {
|
||||
retire();
|
||||
}
|
||||
}, AUTO_SERVER_IDLE_GRACE_MS);
|
||||
this.#retirementTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
readonly serverId: string;
|
||||
readonly sessionDir: string;
|
||||
readonly socketPath: string;
|
||||
readonly server: Server;
|
||||
readonly workerPids: ReadonlyMap<string, number>;
|
||||
readonly closed: Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartServerOptions {
|
||||
/** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
||||
readonly directory?: string;
|
||||
/** Logical service ID. Defaults to PI_SERVER_ID or the directory's default-server-id. */
|
||||
readonly serverId?: ServerId;
|
||||
/** Durable session directory. Defaults to the experimental directory under the configured agent directory. */
|
||||
readonly sessionDir?: string;
|
||||
/** Optional provider for an explicitly selected Session worker model. */
|
||||
readonly provider?: string;
|
||||
/** Optional model override for newly started Session workers. */
|
||||
readonly model?: string;
|
||||
/** Hold the server open without client or Session demand. Defaults to true for foreground servers. */
|
||||
readonly keepAlive?: boolean;
|
||||
/** Optional explicit Radius credential. Stored Radius auth is used when omitted. */
|
||||
readonly relayAuth?: AuthInput;
|
||||
/** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */
|
||||
readonly pluginPackages?: readonly string[];
|
||||
readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void;
|
||||
}
|
||||
|
||||
interface ResolvedSessionPlugins {
|
||||
readonly packagePaths: readonly string[];
|
||||
readonly manifestPaths: readonly string[];
|
||||
readonly presentationArtifacts: readonly FacetBundleArtifact[];
|
||||
}
|
||||
|
||||
interface StartServerBackendOptions {
|
||||
readonly path: string;
|
||||
readonly serverId: ServerId;
|
||||
readonly sessionDir?: string;
|
||||
resolveSessionPlugins(
|
||||
metadata: JsonlSessionMetadata,
|
||||
packagePaths: readonly string[] | undefined,
|
||||
context: Context,
|
||||
): Promise<ResolvedSessionPlugins>;
|
||||
removeSessionPlugins(metadata: JsonlSessionMetadata): Promise<void>;
|
||||
reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise<readonly FacetBundleArtifact[]>;
|
||||
}
|
||||
|
||||
interface RunningServerBackend extends RunningServer {
|
||||
refreshSessions(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startServerBackend(
|
||||
options: StartServerBackendOptions,
|
||||
workers: SessionWorkerManager,
|
||||
onConnectionCountChanged?: (count: number) => void,
|
||||
): Promise<RunningServerBackend> {
|
||||
const serverId = options.serverId;
|
||||
const sessionDir = resolveSessionDirectory(options.sessionDir);
|
||||
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
|
||||
const listSessions = async (context: Context): Promise<JsonlSessionMetadata[]> => {
|
||||
const sessions = new Map((await repo.list(undefined, context)).map((metadata) => [metadata.path, metadata]));
|
||||
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
|
||||
return [...sessions.values()];
|
||||
};
|
||||
const resolveSession = async (sessionId: string, context: Context): Promise<JsonlSessionMetadata> => {
|
||||
const matches = (await listSessions(context)).filter((metadata) => metadata.id === sessionId);
|
||||
if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
|
||||
if (matches.length > 1) throw new SessionAmbiguousError();
|
||||
return matches[0]!;
|
||||
};
|
||||
const createSession = async (
|
||||
createOptions: SessionCreateOptions,
|
||||
context: Context,
|
||||
): Promise<JsonlSessionMetadata> => {
|
||||
const session = await repo.create({ ...createOptions, cwd: process.cwd() }, context);
|
||||
try {
|
||||
return session.metadata;
|
||||
} finally {
|
||||
await session.close(context);
|
||||
}
|
||||
};
|
||||
const summarize = (metadata: JsonlSessionMetadata): SessionSummary => ({
|
||||
serverId,
|
||||
sessionId: metadata.id,
|
||||
createdAt: metadata.createdAt,
|
||||
});
|
||||
const closeStorage = async (): Promise<void> => {
|
||||
const cleanup = await Promise.allSettled([repo.close(TODO_CONTEXT), executionEnv.cleanup(TODO_CONTEXT)]);
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Experimental session storage cleanup failed");
|
||||
};
|
||||
const serverServices = await createExperimentalServerServices({
|
||||
list: async (context) =>
|
||||
(await listSessions(context))
|
||||
.map(summarize)
|
||||
.sort((left, right) => left.sessionId.localeCompare(right.sessionId) || left.createdAt - right.createdAt),
|
||||
create: async (createOptions, context) => summarize(await createSession(createOptions, context)),
|
||||
remove: async (sessionId, context) => {
|
||||
const metadata = await resolveSession(sessionId, context);
|
||||
await workers.closeSession(metadata, context);
|
||||
await repo.delete(metadata, context);
|
||||
await options.removeSessionPlugins(metadata);
|
||||
},
|
||||
async prepareSessionPlugins(sessionId, packagePaths, context) {
|
||||
const metadata = await resolveSession(sessionId, context);
|
||||
let selected: ResolvedSessionPlugins;
|
||||
try {
|
||||
selected = await options.resolveSessionPlugins(metadata, packagePaths, context);
|
||||
workers.assertSessionPluginManifestPaths(metadata, selected.manifestPaths);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionPluginSelectionConflictError) {
|
||||
throw new RoutedServerError("service_invalid_value", error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
packagePaths: selected.packagePaths,
|
||||
presentationPlugins: createPresentationFacetData(selected.presentationArtifacts),
|
||||
};
|
||||
},
|
||||
async reloadPresentationPlugins(packagePaths) {
|
||||
return createPresentationFacetData(await options.reloadPresentationFacetBundles(packagePaths));
|
||||
},
|
||||
}).catch(async (error: unknown) => {
|
||||
try {
|
||||
await closeStorage();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Server service startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const host: ServerHost<JsonlSessionMetadata> = {
|
||||
serverServices: serverServices.host,
|
||||
resolveSession,
|
||||
openSession: async (metadata, context) => {
|
||||
const selected = await options.resolveSessionPlugins(metadata, undefined, context);
|
||||
return workers.openSession(metadata, context, selected.manifestPaths);
|
||||
},
|
||||
};
|
||||
const socketPath = options.path;
|
||||
const closeCatalog = async (): Promise<void> => {
|
||||
const cleanup = await Promise.allSettled([serverServices.dispose(), closeStorage()]);
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Experimental session catalog cleanup failed");
|
||||
};
|
||||
const server = createUnixServer(host, {
|
||||
serverId,
|
||||
path: socketPath,
|
||||
mode: 0o600,
|
||||
onConnectionCountChanged,
|
||||
});
|
||||
try {
|
||||
await server.start();
|
||||
} catch (error) {
|
||||
const cleanup = await Promise.allSettled([server.close(), closeCatalog()]);
|
||||
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Experimental server startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const closed = server.closed.then(
|
||||
() => closeCatalog(),
|
||||
async (serverError: unknown) => {
|
||||
try {
|
||||
await closeCatalog();
|
||||
} catch (repoError) {
|
||||
throw new AggregateError([serverError, repoError], "Server and repository shutdown failed");
|
||||
}
|
||||
throw serverError;
|
||||
},
|
||||
);
|
||||
return {
|
||||
serverId,
|
||||
sessionDir,
|
||||
socketPath,
|
||||
server,
|
||||
workerPids: workers.workerPids,
|
||||
closed,
|
||||
refreshSessions: () => serverServices.refresh(BACKGROUND_CONTEXT),
|
||||
close() {
|
||||
closePromise ??= server.close().then(
|
||||
() => closed,
|
||||
() => closed,
|
||||
);
|
||||
return closePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Start a replaceable experimental server behind the stable coordinator endpoint. */
|
||||
export async function startServer(options: StartServerOptions = {}): Promise<RunningServer> {
|
||||
if (options.provider !== undefined && options.model === undefined) {
|
||||
throw new Error("Server model provider requires a model");
|
||||
}
|
||||
const workerModel =
|
||||
options.model === undefined
|
||||
? undefined
|
||||
: { ...(options.provider === undefined ? {} : { provider: options.provider }), model: options.model };
|
||||
const directory = resolveServerDirectory(options.directory);
|
||||
const { serverId, release } = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
|
||||
const lifetime = new ServerLifetime(options.keepAlive ?? true);
|
||||
let backend: RunningServerBackend | undefined;
|
||||
let coordinator: CoordinatorConnection | undefined;
|
||||
let startupLease: CoordinatorStartupLease | undefined;
|
||||
let workers: SessionWorkerManager | undefined;
|
||||
let relay: RadiusRelayHost | undefined;
|
||||
let released = false;
|
||||
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(getPluginPackage);
|
||||
const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat();
|
||||
return {
|
||||
packagePaths: normalizedPackagePaths,
|
||||
manifestPaths: plugins.map((plugin) => plugin.manifestPath),
|
||||
presentationArtifacts: built,
|
||||
};
|
||||
};
|
||||
let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths);
|
||||
const sessionPluginSelections = new Map<string, ResolvedSessionPlugins>();
|
||||
const resolveSessionPlugins = async (
|
||||
metadata: JsonlSessionMetadata,
|
||||
requestedPackagePaths: readonly string[] | undefined,
|
||||
_context: Context,
|
||||
): Promise<ResolvedSessionPlugins> => {
|
||||
if (requestedPackagePaths !== undefined) {
|
||||
const normalizedPackagePaths = normalizePluginPackagePaths(requestedPackagePaths);
|
||||
const current = sessionPluginSelections.get(metadata.path);
|
||||
if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current;
|
||||
const requestedManifestPaths = normalizedPackagePaths.map(
|
||||
(packagePath) => getPluginPackage(packagePath).manifestPath,
|
||||
);
|
||||
workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths);
|
||||
const candidate = await buildPluginSelection(normalizedPackagePaths);
|
||||
workers?.assertSessionPluginManifestPaths(metadata, candidate.manifestPaths);
|
||||
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, candidate.packagePaths);
|
||||
sessionPluginSelections.set(metadata.path, candidate);
|
||||
return candidate;
|
||||
}
|
||||
const cached = sessionPluginSelections.get(metadata.path);
|
||||
if (cached !== undefined) return cached;
|
||||
const storedPackagePaths = await readSessionPluginPackageProfile(directory, serverId, metadata.path);
|
||||
const selected =
|
||||
storedPackagePaths === undefined ? defaultPluginSelection : await buildPluginSelection(storedPackagePaths);
|
||||
if (storedPackagePaths === undefined) {
|
||||
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, selected.packagePaths);
|
||||
}
|
||||
sessionPluginSelections.set(metadata.path, selected);
|
||||
return selected;
|
||||
};
|
||||
const removeSessionPlugins = async (metadata: JsonlSessionMetadata): Promise<void> => {
|
||||
sessionPluginSelections.delete(metadata.path);
|
||||
await removeSessionPluginPackageProfile(directory, serverId, metadata.path);
|
||||
};
|
||||
const reloadPresentationFacetBundles = async (
|
||||
packagePaths: readonly string[],
|
||||
): Promise<readonly FacetBundleArtifact[]> => {
|
||||
const reloaded = await buildPluginSelection(packagePaths);
|
||||
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 reloaded.presentationArtifacts;
|
||||
};
|
||||
const socketPath = getUnixSocketPath(serverId, directory);
|
||||
const controlPath = join(directory, `control-${serverId}.sock`);
|
||||
const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12);
|
||||
const serverPath = join(directory, `server-${serverId}-${serverNonce}.sock`);
|
||||
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),
|
||||
);
|
||||
backend = await startServerBackend(
|
||||
{
|
||||
path: serverPath,
|
||||
serverId,
|
||||
sessionDir: options.sessionDir,
|
||||
resolveSessionPlugins,
|
||||
removeSessionPlugins,
|
||||
reloadPresentationFacetBundles,
|
||||
},
|
||||
workers,
|
||||
(count) => lifetime.setConnectionCount(count),
|
||||
);
|
||||
await coordinator.connect();
|
||||
startupLease.close();
|
||||
startupLease = undefined;
|
||||
await workers.discover(coordinator.peerIds);
|
||||
await backend.refreshSessions();
|
||||
relay = new RadiusRelayHost({
|
||||
serverId,
|
||||
server: backend.server,
|
||||
auth: new RadiusRelayAuthResolver(options.relayAuth),
|
||||
onStatus: options.onRelayStatus,
|
||||
});
|
||||
relay.start();
|
||||
|
||||
const activeBackend = backend;
|
||||
const activeCoordinator = coordinator;
|
||||
const activeWorkers = workers;
|
||||
const activeRelay = relay;
|
||||
void activeCoordinator.replaced
|
||||
.then(async () => {
|
||||
lifetime.stop();
|
||||
activeWorkers.detach();
|
||||
await activeRelay.close();
|
||||
await activeBackend.close();
|
||||
})
|
||||
.finally(() => activeCoordinator.close())
|
||||
.catch(() => {});
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const runtime: RunningServer = {
|
||||
serverId,
|
||||
sessionDir: activeBackend.sessionDir,
|
||||
socketPath,
|
||||
server: activeBackend.server,
|
||||
workerPids: activeWorkers.workerPids,
|
||||
closed: activeBackend.closed.finally(() => activeRelay.close()),
|
||||
close() {
|
||||
lifetime.stop();
|
||||
closePromise ??= (async () => {
|
||||
try {
|
||||
await activeRelay.close();
|
||||
await activeBackend.close();
|
||||
} finally {
|
||||
try {
|
||||
if (activeCoordinator.wasReplaced) activeWorkers.detach();
|
||||
else await activeWorkers.shutdown();
|
||||
} finally {
|
||||
activeCoordinator.close();
|
||||
}
|
||||
}
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
};
|
||||
lifetime.start(() => {
|
||||
void runtime.close().catch(() => {});
|
||||
});
|
||||
released = true;
|
||||
await release();
|
||||
return runtime;
|
||||
} catch (error) {
|
||||
lifetime.stop();
|
||||
startupLease?.close();
|
||||
if (coordinator?.wasReplaced) workers?.detach();
|
||||
const cleanup = await Promise.allSettled([
|
||||
relay?.close(),
|
||||
backend?.close(),
|
||||
coordinator?.wasReplaced ? undefined : workers?.shutdown(),
|
||||
Promise.resolve(coordinator?.close()),
|
||||
released ? undefined : release(),
|
||||
]);
|
||||
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Server runtime startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start an operator-held server while serializing against automatic cold activation. */
|
||||
export async function startForegroundServer(
|
||||
options: Omit<StartServerOptions, "keepAlive"> = {},
|
||||
): Promise<RunningServer> {
|
||||
const directory = resolveServerDirectory(options.directory);
|
||||
await ensurePrivateServerDirectory(directory);
|
||||
const profile = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
|
||||
const serverId = profile.serverId;
|
||||
await profile.release();
|
||||
const release = await acquireServerActivation(directory, serverId);
|
||||
try {
|
||||
return await startServer({
|
||||
...options,
|
||||
directory,
|
||||
serverId,
|
||||
keepAlive: true,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
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 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 (
|
||||
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 model options");
|
||||
}
|
||||
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, 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 workerModel = parseServerModelOptions(serializedModel);
|
||||
|
||||
const runtime = await startServer({
|
||||
directory,
|
||||
serverId,
|
||||
sessionDir,
|
||||
keepAlive: false,
|
||||
...workerModel,
|
||||
});
|
||||
const close = (): void => {
|
||||
void runtime.close().catch(() => {});
|
||||
};
|
||||
process.once("SIGINT", close);
|
||||
process.once("SIGTERM", close);
|
||||
try {
|
||||
await runtime.closed;
|
||||
} finally {
|
||||
process.off("SIGINT", close);
|
||||
process.off("SIGTERM", close);
|
||||
await runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
if (isDirectInternalProcessEntry(import.meta.url)) {
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "server") throw new Error("Server entrypoint requires an internal server invocation");
|
||||
void runServerProcess(process.argv.slice(2)).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user