Gateway: finalize WS control plane

This commit is contained in:
Peter Steinberger
2025-12-09 14:41:41 +01:00
parent 9ef1545d06
commit b2e7fb01a9
23 changed files with 5209 additions and 2495 deletions

View File

@@ -1,107 +0,0 @@
import crypto from "node:crypto";
import net from "node:net";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { startControlChannel } from "./control-channel.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js";
// Mock health/status to avoid hitting real services
vi.mock("../commands/health.js", () => ({
getHealthSnapshot: vi.fn(async () => ({
ts: Date.now(),
durationMs: 10,
web: {
linked: true,
authAgeMs: 1000,
connect: { ok: true, status: 200, error: null, elapsedMs: 5 },
},
heartbeatSeconds: 60,
sessions: { path: "/tmp/sessions.json", count: 1, recent: [] },
ipc: { path: "/tmp/clawdis.sock", exists: true },
})),
}));
vi.mock("../commands/status.js", () => ({
getStatusSummary: vi.fn(async () => ({
web: { linked: true, authAgeMs: 1000 },
heartbeatSeconds: 60,
sessions: {
path: "/tmp/sessions.json",
count: 1,
defaults: { model: "claude-opus-4-5", contextTokens: 200_000 },
recent: [],
},
})),
}));
describe("control channel", () => {
let server: Awaited<ReturnType<typeof startControlChannel>>;
let client: net.Socket;
beforeAll(async () => {
server = await startControlChannel({}, { port: 19999 });
client = net.createConnection({ host: "127.0.0.1", port: 19999 });
});
afterAll(async () => {
client.destroy();
await server.close();
});
const sendRequest = (method: string, params?: unknown) =>
new Promise<Record<string, unknown>>((resolve, reject) => {
const id = crypto.randomUUID();
const frame = { type: "request", id, method, params };
client.write(`${JSON.stringify(frame)}\n`);
const onData = (chunk: Buffer) => {
const lines = chunk.toString("utf8").trim().split(/\n/);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { id?: string };
if (parsed.id === id) {
client.off("data", onData);
resolve(parsed as Record<string, unknown>);
return;
}
} catch {
/* ignore non-JSON noise */
}
}
};
client.on("data", onData);
client.on("error", reject);
});
it("responds to ping", async () => {
const res = await sendRequest("ping");
expect(res.ok).toBe(true);
});
it("returns health snapshot", async () => {
const res = await sendRequest("health");
expect(res.ok).toBe(true);
const payload = res.payload as { web?: { linked?: boolean } };
expect(payload.web?.linked).toBe(true);
});
it("emits heartbeat events", async () => {
const evtPromise = new Promise<Record<string, unknown>>((resolve) => {
const handler = (chunk: Buffer) => {
const lines = chunk.toString("utf8").trim().split(/\n/);
for (const line of lines) {
const parsed = JSON.parse(line) as { type?: string; event?: string };
if (parsed.type === "event" && parsed.event === "heartbeat") {
client.off("data", handler);
resolve(parsed as Record<string, unknown>);
}
}
};
client.on("data", handler);
});
emitHeartbeatEvent({ status: "sent", to: "+1", preview: "hi" });
const evt = await evtPromise;
expect(evt.event).toBe("heartbeat");
});
});

View File

@@ -1,235 +0,0 @@
import net from "node:net";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
import { getStatusSummary, type StatusSummary } from "../commands/status.js";
import { logDebug, logError } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { type AgentEventPayload, onAgentEvent } from "./agent-events.js";
import {
emitHeartbeatEvent,
getLastHeartbeatEvent,
type HeartbeatEventPayload,
onHeartbeatEvent,
} from "./heartbeat-events.js";
import { enqueueSystemEvent } from "./system-events.js";
import { listSystemPresence, updateSystemPresence } from "./system-presence.js";
type ControlRequest = {
type: "request";
id: string;
method: string;
params?: Record<string, unknown>;
};
type ControlResponse = {
type: "response";
id: string;
ok: boolean;
payload?: unknown;
error?: string;
};
type ControlEvent = {
type: "event";
event: string;
payload: unknown;
};
type Handlers = {
setHeartbeats?: (enabled: boolean) => Promise<void> | void;
};
type ControlServer = {
close: () => Promise<void>;
broadcastHeartbeat: (evt: HeartbeatEventPayload) => void;
broadcastAgentEvent: (evt: AgentEventPayload) => void;
};
const DEFAULT_PORT = 18789;
export async function startControlChannel(
handlers: Handlers = {},
opts: { port?: number; runtime?: RuntimeEnv } = {},
): Promise<ControlServer> {
const port = opts.port ?? DEFAULT_PORT;
const runtime = opts.runtime ?? defaultRuntime;
const clients = new Set<net.Socket>();
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
clients.add(socket);
// Seed relay status + last heartbeat for new clients.
write(socket, {
type: "event",
event: "relay-status",
payload: { state: "running" },
});
const last = getLastHeartbeatEvent();
if (last)
write(socket, { type: "event", event: "heartbeat", payload: last });
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
logDebug(`control: line ${line.slice(0, 200)}`);
handleLine(socket, line.trim());
}
});
socket.on("error", () => {
/* ignore */
});
socket.on("close", () => {
clients.delete(socket);
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
const stopHeartbeat = onHeartbeatEvent((evt) => broadcast("heartbeat", evt));
const stopAgent = onAgentEvent((evt) => broadcast("agent", evt));
const handleLine = async (socket: net.Socket, line: string) => {
if (!line) return;
const started = Date.now();
let parsed: ControlRequest;
try {
parsed = JSON.parse(line) as ControlRequest;
} catch (err) {
logError(
`control: parse error (${String(err)}) on line: ${line.slice(0, 200)}`,
);
return write(socket, {
type: "response",
id: "",
ok: false,
error: `parse error: ${String(err)}`,
});
}
if (parsed.type !== "request" || !parsed.id) {
return write(socket, {
type: "response",
id: parsed.id ?? "",
ok: false,
error: "unsupported frame",
});
}
const respond = (payload: unknown, ok = true, error?: string) =>
write(socket, {
type: "response",
id: parsed.id,
ok,
payload: ok ? payload : undefined,
error: ok ? undefined : error,
});
try {
logDebug(`control: recv ${parsed.method}`);
switch (parsed.method) {
case "ping": {
respond({ pong: true, ts: Date.now() });
break;
}
case "health": {
const summary = await getHealthSnapshot();
respond(summary satisfies HealthSummary);
break;
}
case "status": {
const summary = await getStatusSummary();
respond(summary satisfies StatusSummary);
break;
}
case "last-heartbeat": {
respond(getLastHeartbeatEvent());
break;
}
case "set-heartbeats": {
const enabled = Boolean(parsed.params?.enabled);
if (handlers.setHeartbeats) await handlers.setHeartbeats(enabled);
respond({ ok: true });
break;
}
case "system-event": {
const text = String(parsed.params?.text ?? "").trim();
if (text) {
enqueueSystemEvent(text);
updateSystemPresence(text);
}
respond({ ok: true });
break;
}
case "system-presence": {
const pres = listSystemPresence();
logDebug?.(`control: system-presence count=${pres.length}`);
respond(pres);
break;
}
default:
respond(undefined, false, `unknown method: ${parsed.method}`);
break;
}
logDebug(
`control: ${parsed.method} responded in ${Date.now() - started}ms`,
);
} catch (err) {
logError(
`control: ${parsed.method} failed in ${Date.now() - started}ms: ${String(err)}`,
);
respond(undefined, false, String(err));
}
};
const write = (socket: net.Socket, frame: ControlResponse | ControlEvent) => {
try {
socket.write(`${JSON.stringify(frame)}\n`);
} catch {
// ignore
}
};
const broadcast = (event: string, payload: unknown) => {
const frame: ControlEvent = { type: "event", event, payload };
const line = `${JSON.stringify(frame)}\n`;
for (const client of [...clients]) {
try {
client.write(line);
} catch {
clients.delete(client);
}
}
};
runtime.log?.(`control channel listening on 127.0.0.1:${port}`);
return {
close: async () => {
stopHeartbeat();
stopAgent();
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const client of [...clients]) {
client.destroy();
}
clients.clear();
},
broadcastHeartbeat: (evt: HeartbeatEventPayload) => {
emitHeartbeatEvent(evt);
broadcast("heartbeat", evt);
},
broadcastAgentEvent: (evt: AgentEventPayload) => {
broadcast("agent", evt);
},
};
}

View File

@@ -7,11 +7,14 @@ export type SystemPresence = {
lastInputSeconds?: number;
mode?: string;
reason?: string;
instanceId?: string;
text: string;
ts: number;
};
const entries = new Map<string, SystemPresence>();
const TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_ENTRIES = 200;
function resolvePrimaryIPv4(): string | undefined {
const nets = os.networkInterfaces();
@@ -36,12 +39,12 @@ function initSelfPresence() {
const ip = resolvePrimaryIPv4() ?? undefined;
const version =
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown";
const text = `Relay: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode relay · reason self`;
const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`;
const selfEntry: SystemPresence = {
host,
ip,
version,
mode: "relay",
mode: "gateway",
reason: "self",
text,
ts: Date.now(),
@@ -105,8 +108,41 @@ export function updateSystemPresence(text: string) {
entries.set(key, parsed);
}
export function upsertPresence(
key: string,
presence: Partial<SystemPresence>,
) {
ensureSelfPresence();
const existing = entries.get(key) ?? ({} as SystemPresence);
const merged: SystemPresence = {
...existing,
...presence,
ts: Date.now(),
text:
presence.text ||
existing.text ||
`Node: ${presence.host ?? existing.host ?? "unknown"} · mode ${
presence.mode ?? existing.mode ?? "unknown"
}`,
};
entries.set(key, merged);
}
export function listSystemPresence(): SystemPresence[] {
ensureSelfPresence();
// prune expired
const now = Date.now();
for (const [k, v] of [...entries]) {
if (now - v.ts > TTL_MS) entries.delete(k);
}
// enforce max size (LRU by ts)
if (entries.size > MAX_ENTRIES) {
const sorted = [...entries.entries()].sort((a, b) => a[1].ts - b[1].ts);
const toDrop = entries.size - MAX_ENTRIES;
for (let i = 0; i < toDrop; i++) {
entries.delete(sorted[i][0]);
}
}
touchSelfPresence();
return [...entries.values()].sort((a, b) => b.ts - a.ts);
}