mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { basename, join } from "node:path";
|
|
import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
|
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
import { PiClient } 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 { HostedHarnessHandle, PiServer, PiServerHost } from "@earendil-works/pi-server";
|
|
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
|
import { getAgentDir } from "../../config.ts";
|
|
import { resolvePath } from "../../utils/paths.ts";
|
|
import type { ClientCommand } from "./commands/client.ts";
|
|
import { CoordinatedSessionWorkers } from "./coordinated-session-workers.ts";
|
|
import {
|
|
CoordinatorServer,
|
|
type CoordinatorStartupLease,
|
|
ensureExperimentalCoordinator,
|
|
} from "./coordinator-client.ts";
|
|
import { ENV_SERVER_ID, ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts";
|
|
import { acquireExperimentalServerProfile } from "./server-profile.ts";
|
|
import { startExperimentalSessionWorker } from "./session-worker.ts";
|
|
|
|
export interface ExperimentalServer {
|
|
readonly serverId: string;
|
|
readonly sessionDir: string;
|
|
readonly socketPath: string;
|
|
readonly server: PiServer;
|
|
readonly workerPids: ReadonlyMap<string, number>;
|
|
readonly closed: Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export type ExperimentalClientResult =
|
|
| {
|
|
readonly kind: "list";
|
|
readonly sessions: readonly { serverId: string; sessionId: string }[];
|
|
}
|
|
| { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string };
|
|
|
|
export interface StartExperimentalServerOptions {
|
|
/** Directory for server sockets. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
|
readonly directory?: string;
|
|
readonly path?: string;
|
|
readonly serverId?: ServerId;
|
|
/** Durable session directory. Defaults to the experimental directory under the configured agent directory. */
|
|
readonly sessionDir?: string;
|
|
}
|
|
|
|
export interface StartExperimentalCoordinatedServerOptions {
|
|
/** 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;
|
|
}
|
|
|
|
export interface RunExperimentalClientOptions {
|
|
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
|
readonly directory?: string;
|
|
}
|
|
|
|
export function resolveExperimentalSessionDirectory(sessionDir?: string): string {
|
|
return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions"));
|
|
}
|
|
|
|
interface ExperimentalWorkerController {
|
|
readonly workerPids: ReadonlyMap<string, number>;
|
|
readonly trackedSessions: readonly JsonlSessionMetadata[];
|
|
createHarness(metadata: JsonlSessionMetadata): Promise<HostedHarnessHandle>;
|
|
}
|
|
|
|
/** Start the experimental durable list-and-attach server composition. */
|
|
export async function startExperimentalServer(
|
|
options: StartExperimentalServerOptions = {},
|
|
): Promise<ExperimentalServer> {
|
|
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
|
|
const workerPids = new Map<string, number>();
|
|
const controller: ExperimentalWorkerController = {
|
|
workerPids,
|
|
trackedSessions: [],
|
|
createHarness: async (metadata) => {
|
|
const sessionId = metadata.id;
|
|
const worker = await startExperimentalSessionWorker(metadata, {
|
|
sessionDir,
|
|
controlDirectory: options.directory,
|
|
});
|
|
workerPids.set(sessionId, worker.pid);
|
|
return {
|
|
terminated: worker.terminated.then((error) => {
|
|
if (workerPids.get(sessionId) === worker.pid) workerPids.delete(sessionId);
|
|
return error;
|
|
}),
|
|
close: async () => {
|
|
try {
|
|
await worker.close();
|
|
} finally {
|
|
if (workerPids.get(sessionId) === worker.pid) workerPids.delete(sessionId);
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
return startExperimentalServerBackend(options, controller);
|
|
}
|
|
|
|
async function startExperimentalServerBackend(
|
|
options: StartExperimentalServerOptions,
|
|
workers: ExperimentalWorkerController,
|
|
): Promise<ExperimentalServer> {
|
|
const serverId = options.serverId ?? randomUUID();
|
|
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
|
|
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
|
|
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
|
|
const host: PiServerHost<JsonlSessionMetadata> = {
|
|
sessions: {
|
|
list: async () => {
|
|
const sessions = new Map((await repo.list()).map((metadata) => [metadata.path, metadata]));
|
|
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
|
|
return [...sessions.values()];
|
|
},
|
|
},
|
|
createHarness: (metadata) => workers.createHarness(metadata),
|
|
};
|
|
let socketPath = options.path;
|
|
if (socketPath === undefined) {
|
|
const serverDirectory = resolveExperimentalServerDirectory(options.directory);
|
|
await ensurePrivateServerDirectory(serverDirectory);
|
|
socketPath = getUnixSocketPath(serverId, serverDirectory);
|
|
}
|
|
const closeCatalog = async (): Promise<void> => {
|
|
const cleanup = await Promise.allSettled([repo.close(), executionEnv.cleanup()]);
|
|
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 });
|
|
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,
|
|
close() {
|
|
closePromise ??= server.close().then(
|
|
() => closed,
|
|
() => closed,
|
|
);
|
|
return closePromise;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Start a replaceable experimental server behind the stable coordinator endpoint. */
|
|
export async function startExperimentalCoordinatedServer(
|
|
options: StartExperimentalCoordinatedServerOptions = {},
|
|
): Promise<ExperimentalServer> {
|
|
const directory = resolveExperimentalServerDirectory(options.directory);
|
|
const { serverId, release } = await acquireExperimentalServerProfile(
|
|
directory,
|
|
options.serverId ?? process.env[ENV_SERVER_ID],
|
|
);
|
|
let backend: ExperimentalServer | undefined;
|
|
let coordinator: CoordinatorServer | undefined;
|
|
let startupLease: CoordinatorStartupLease | undefined;
|
|
let workers: CoordinatedSessionWorkers | undefined;
|
|
let released = false;
|
|
try {
|
|
await ensurePrivateServerDirectory(directory);
|
|
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 ensureExperimentalCoordinator(socketPath, controlPath);
|
|
coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath });
|
|
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
|
|
workers = new CoordinatedSessionWorkers(coordinator, sessionDir);
|
|
backend = await startExperimentalServerBackend(
|
|
{ path: serverPath, serverId, sessionDir: options.sessionDir },
|
|
workers,
|
|
);
|
|
await coordinator.connect();
|
|
startupLease.close();
|
|
startupLease = undefined;
|
|
await workers.discover(coordinator.peerIds);
|
|
|
|
const activeBackend = backend;
|
|
const activeCoordinator = coordinator;
|
|
const activeWorkers = workers;
|
|
void activeCoordinator.replaced
|
|
.then(async () => {
|
|
activeWorkers.detach();
|
|
await activeBackend.close();
|
|
})
|
|
.finally(() => activeCoordinator.close())
|
|
.catch(() => {});
|
|
let closePromise: Promise<void> | undefined;
|
|
const runtime: ExperimentalServer = {
|
|
serverId,
|
|
sessionDir: activeBackend.sessionDir,
|
|
socketPath,
|
|
server: activeBackend.server,
|
|
workerPids: activeWorkers.workerPids,
|
|
closed: activeBackend.closed,
|
|
close() {
|
|
closePromise ??= (async () => {
|
|
try {
|
|
await activeBackend.close();
|
|
} finally {
|
|
try {
|
|
if (activeCoordinator.wasReplaced) activeWorkers.detach();
|
|
else await activeWorkers.shutdown();
|
|
} finally {
|
|
activeCoordinator.close();
|
|
}
|
|
}
|
|
})();
|
|
return closePromise;
|
|
},
|
|
};
|
|
released = true;
|
|
await release();
|
|
return runtime;
|
|
} catch (error) {
|
|
startupLease?.close();
|
|
if (coordinator?.wasReplaced) workers?.detach();
|
|
const cleanup = await Promise.allSettled([
|
|
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], "Coordinated server startup and cleanup failed");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Discover local servers, then list sessions or attach to one selected session. */
|
|
export async function runExperimentalClient(
|
|
command: ClientCommand,
|
|
options: RunExperimentalClientOptions = {},
|
|
): Promise<ExperimentalClientResult> {
|
|
if (command.auth !== undefined) throw new Error("Authentication is not supported by the experimental local server");
|
|
const routes = command.connect
|
|
? [routeFromExplicitPath(command.connect.path)]
|
|
: await discoverUnixServers({ directory: resolveExperimentalServerDirectory(options.directory) });
|
|
const discovered: { route: UnixServerRoute; sessionIds: string[] }[] = [];
|
|
|
|
for (const route of routes) {
|
|
const client = await PiClient.connect({
|
|
serverId: route.serverId,
|
|
transportFactory: createUnixTransportFactory({ path: route.path }),
|
|
});
|
|
try {
|
|
const sessions = await client.listSessions();
|
|
discovered.push({ route, sessionIds: sessions.map(({ id }) => id) });
|
|
} finally {
|
|
await client.dispose();
|
|
}
|
|
}
|
|
|
|
const sessionId = command.sessionId;
|
|
if (sessionId === undefined) {
|
|
return {
|
|
kind: "list",
|
|
sessions: discovered
|
|
.flatMap(({ route, sessionIds }) =>
|
|
sessionIds.map((sessionId) => ({ serverId: route.serverId, sessionId })),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
left.serverId.localeCompare(right.serverId) || left.sessionId.localeCompare(right.sessionId),
|
|
),
|
|
};
|
|
}
|
|
|
|
const matches = discovered.filter((candidate) => candidate.sessionIds.includes(sessionId));
|
|
if (matches.length === 0) throw new Error(`No discovered server contains session ${sessionId}`);
|
|
if (matches.length > 1) throw new Error(`Session ${sessionId} is available from more than one server`);
|
|
const route = matches[0]!.route;
|
|
const client = await PiClient.connect({
|
|
serverId: route.serverId,
|
|
transportFactory: createUnixTransportFactory({ path: route.path }),
|
|
});
|
|
try {
|
|
const attached = await client.attachSession(sessionId);
|
|
return { kind: "attached", serverId: route.serverId, sessionId: attached.sessionId };
|
|
} finally {
|
|
await client.dispose();
|
|
}
|
|
}
|
|
|
|
function routeFromExplicitPath(path: string): UnixServerRoute {
|
|
const name = basename(path);
|
|
const serverId = name.endsWith(".sock") ? name.slice(0, -".sock".length) : "";
|
|
if (!isServerId(serverId)) {
|
|
throw new Error("--connect path must end with <uuidv4-server-id>.sock");
|
|
}
|
|
return { serverId, path };
|
|
}
|