Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/protocol/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export const RpcRequestSchema = z.object({
export type RpcRequest = z.infer<typeof RpcRequestSchema>;

export const RpcResponseSchema = z.object({
kind: z.literal("rpc_response"),
rpc: z.string(),
requestId: z.string(),
ok: z.boolean(),
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AgentManager } from "./server/agent/agent-manager.js";
import { HandleStore } from "./server/agent/handle-store.js";
import { ClaudeAgentClient } from "./server/agent/providers/claude/claude-provider.js";
import { CommandDispatcher } from "./server/command-dispatcher.js";
import { RpcRouter } from "./server/rpc-router.js";
import { WorkspaceRegistry } from "./server/workspace-registry.js";
import { SupaplaneWebsocketServer } from "./websocket-server.js";

Expand Down Expand Up @@ -97,6 +98,9 @@ export async function startDaemon(args?: {
}),
);

const rpcRouter = new RpcRouter({ workspaces, agents: agentManager, logger });
wsServer.setRpcHandler((req) => rpcRouter.handle(req));

await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/server/agent/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export class AgentManager {
return provider;
}

listModels(providerId: string) {
return this.getProvider(providerId).listModels();
}

listModes(providerId: string) {
return this.getProvider(providerId).listModes();
}

async getDiagnostic(providerId: string): Promise<{ diagnostic: string }> {
const provider = this.getProvider(providerId);
if (!provider.getDiagnostic) {
return { diagnostic: "no diagnostic available" };
}
return provider.getDiagnostic();
}

async startSession(args: StartSessionArgs): Promise<SessionState> {
const provider = this.getProvider(args.providerId);
const sessionId = newSessionId();
Expand Down
58 changes: 58 additions & 0 deletions packages/server/src/server/daemon-e2e/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { mkdtemp } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { SupaplaneClient } from "@echohello/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { startDaemon, type DaemonHandle } from "../../daemon.js";

describe("daemon e2e: rpc", () => {
let daemon: DaemonHandle;
let client: SupaplaneClient;

beforeEach(async () => {
const supaplaneHome = await mkdtemp(join(tmpdir(), "supaplane-e2e-rpc-"));
daemon = await startDaemon({
config: { listenPort: 0, logLevel: "error" },
supaplaneHome,
});
const { port } = daemon.httpServer.address() as AddressInfo;
client = new SupaplaneClient({
endpoint: `ws://127.0.0.1:${port}`,
clientId: "e2e-rpc-client",
clientType: "cli",
reconnect: false,
});
await client.connect();
});

afterEach(async () => {
client.close();
await daemon.stop();
});

it("provider.list round-trips", async () => {
const result = await client.rpc<never, { providers: string[] }>("provider.list");
expect(result.providers).toContain("claude");
});

it("provider.models returns the claude model list", async () => {
const result = await client.rpc<{ providerId: string }, { models: { id: string }[] }>(
"provider.models",
{ providerId: "claude" },
);
expect(result.models.map((m) => m.id)).toContain("sonnet");
});

it("unknown rpc rejects with an error", async () => {
await expect(client.rpc("no.such.rpc")).rejects.toThrow("Unknown rpc");
});

it("unknown provider rejects with an error", async () => {
await expect(client.rpc("provider.models", { providerId: "does-not-exist" })).rejects.toThrow(
"Unknown provider",
);
});
});
75 changes: 75 additions & 0 deletions packages/server/src/server/rpc-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { z } from "zod";
import type { Logger } from "pino";
import { SupaplaneError, type RpcRequest, type RpcResponse } from "@echohello/protocol";

import type { AgentManager } from "./agent/agent-manager.js";
import type { WorkspaceRegistry } from "./workspace-registry.js";

export interface RpcRouterOptions {
workspaces: WorkspaceRegistry;
agents: AgentManager;
logger: Logger;
}

const ProviderArgsSchema = z.object({ providerId: z.string().min(1) });
const SessionListArgsSchema = z.object({ workspaceId: z.string().optional() });

/**
* Answers one-shot client RPCs (`SupaplaneClient.rpc()`). Read-only views
* over the provider registry, workspace registry, and session table.
* Unknown RPCs get an `ok: false` response, never silence.
*/
export class RpcRouter {
readonly #workspaces: WorkspaceRegistry;
readonly #agents: AgentManager;
readonly #logger: Logger;

constructor(options: RpcRouterOptions) {
this.#workspaces = options.workspaces;
this.#agents = options.agents;
this.#logger = options.logger.child({ module: "rpc-router" });
}

async handle(req: RpcRequest): Promise<RpcResponse> {
try {
const result = await this.#route(req);
return { kind: "rpc_response", rpc: req.rpc, requestId: req.requestId, ok: true, result };
} catch (err) {
const code = err instanceof SupaplaneError ? err.code : "internal";
const message = err instanceof Error ? err.message : String(err);
this.#logger.warn({ rpc: req.rpc, code, err: message }, "rpc failed");
return {
kind: "rpc_response",
rpc: req.rpc,
requestId: req.requestId,
ok: false,
error: { code, message },
};
}
}

async #route(req: RpcRequest): Promise<unknown> {
switch (req.rpc) {
case "provider.list":
return { providers: this.#agents.providerIds() };
case "provider.models":
return { models: await this.#agents.listModels(this.#providerId(req)) };
case "provider.modes":
return { modes: await this.#agents.listModes(this.#providerId(req)) };
case "provider.diagnostic":
return this.#agents.getDiagnostic(this.#providerId(req));
case "workspace.list":
return { workspaces: this.#workspaces.list() };
case "session.list": {
const args = SessionListArgsSchema.parse(req.args ?? {});
return { sessions: this.#agents.listSessions(args.workspaceId) };
}
default:
throw new SupaplaneError({ code: "not_found", message: `Unknown rpc: ${req.rpc}` });
}
}

#providerId(req: RpcRequest): string {
return ProviderArgsSchema.parse(req.args).providerId;
}
}
19 changes: 18 additions & 1 deletion packages/server/src/websocket-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type ClientType,
EnvelopeSchema,
type HelloAckMessage,
type RpcRequest,
type RpcResponse,
type ServerEvent,
} from "@echohello/protocol";

Expand All @@ -29,6 +31,7 @@ export interface WebsocketServerOptions {
daemonLabel?: string;
serverVersion: string;
onCommand?: (cmd: ClientCommand, session: SessionRecord) => void;
onRpc?: (req: RpcRequest, session: SessionRecord) => Promise<RpcResponse>;
/** Provider ids advertised in the `hello_ack` capabilities. */
providers?: readonly string[];
}
Expand All @@ -51,6 +54,7 @@ export class SupaplaneWebsocketServer {
#serverVersion: string;
#daemonLabel: string | undefined;
#onCommand?: (cmd: ClientCommand, session: SessionRecord) => void;
#onRpc?: (req: RpcRequest, session: SessionRecord) => Promise<RpcResponse>;
#providers: readonly string[];

constructor(options: WebsocketServerOptions) {
Expand Down Expand Up @@ -86,6 +90,11 @@ export class SupaplaneWebsocketServer {
this.#onCommand = handler;
}

/** Install the RPC handler after construction. */
setRpcHandler(handler: (req: RpcRequest, session: SessionRecord) => Promise<RpcResponse>): void {
this.#onRpc = handler;
}

/** Broadcast a server event to all connected sessions that have subscribed to its topic. */
broadcast(event: ServerEvent): void {
const payload = JSON.stringify({ event });
Expand Down Expand Up @@ -175,8 +184,16 @@ export class SupaplaneWebsocketServer {
return;
}
this.#onCommand?.(cmd, session);
return;
}
if ("rpc" in envelope && !("kind" in envelope) && this.#onRpc) {
void this.#onRpc(envelope, session)
.then((response) => this.sendTo(socket, response))
.catch((err: unknown) => {
this.#logger.warn({ err, rpc: envelope.rpc }, "rpc handler failed");
});
return;
}
// RPC request/response envelopes are routed via SupaplaneClient.rpc(), not here.
// Server events flow outbound via broadcast(). `hello`/`hello_ack` are
// handshake-only and are caught at the connection boundary above.
} catch (err) {
Expand Down
Loading