Gateway: finalize WS control plane
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user