fix: expose heartbeat controls and harden mac CLI

This commit is contained in:
Peter Steinberger
2025-12-12 23:33:12 +00:00
parent 3b72ed6e1a
commit 8846ffec64
7 changed files with 171 additions and 12 deletions

View File

@@ -8,6 +8,7 @@ import { WebSocket } from "ws";
import { agentCommand } from "../commands/agent.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { startGatewayServer } from "./server.js";
@@ -172,6 +173,86 @@ async function connectOk(
}
describe("gateway server", () => {
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
type HeartbeatPayload = {
ts: number;
status: string;
to?: string;
preview?: string;
durationMs?: number;
hasMedia?: boolean;
reason?: string;
};
type EventFrame = {
type: "event";
event: string;
payload?: HeartbeatPayload | null;
};
type ResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
};
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
const waitHeartbeat = onceMessage<EventFrame>(
ws,
(o) => o.type === "event" && o.event === "heartbeat",
);
emitHeartbeatEvent({ status: "sent", to: "+123", preview: "ping" });
const evt = await waitHeartbeat;
expect(evt.payload?.status).toBe("sent");
expect(typeof evt.payload?.ts).toBe("number");
ws.send(
JSON.stringify({
type: "req",
id: "hb-last",
method: "last-heartbeat",
}),
);
const last = await onceMessage<ResFrame>(
ws,
(o) => o.type === "res" && o.id === "hb-last",
);
expect(last.ok).toBe(true);
const lastPayload = last.payload as HeartbeatPayload | null | undefined;
expect(lastPayload?.status).toBe("sent");
expect(lastPayload?.ts).toBe(evt.payload?.ts);
ws.send(
JSON.stringify({
type: "req",
id: "hb-toggle-off",
method: "set-heartbeats",
params: { enabled: false },
}),
);
const toggle = await onceMessage<ResFrame>(
ws,
(o) => o.type === "res" && o.id === "hb-toggle-off",
);
expect(toggle.ok).toBe(true);
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(
false,
);
ws.close();
await server.close();
});
test("agent falls back to allowFrom when lastTo is stale", async () => {
testAllowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));

View File

@@ -22,6 +22,10 @@ import {
import { isVerbose } from "../globals.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import {
getLastHeartbeatEvent,
onHeartbeatEvent,
} from "../infra/heartbeat-events.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
listSystemPresence,
@@ -35,6 +39,7 @@ import { defaultRuntime } from "../runtime.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { normalizeE164 } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
@@ -65,6 +70,8 @@ type Client = {
const METHODS = [
"health",
"status",
"last-heartbeat",
"set-heartbeats",
"system-presence",
"system-event",
"send",
@@ -74,7 +81,15 @@ const METHODS = [
"chat.send",
];
const EVENTS = ["agent", "chat", "presence", "tick", "shutdown", "health"];
const EVENTS = [
"agent",
"chat",
"presence",
"tick",
"shutdown",
"health",
"heartbeat",
];
export type GatewayServer = {
close: () => Promise<void>;
@@ -494,6 +509,10 @@ export async function startGatewayServer(
}
});
const heartbeatUnsub = onHeartbeatEvent((evt) => {
broadcast("heartbeat", evt, { dropIfSlow: true });
});
wss.on("connection", (socket) => {
let client: Client | null = null;
let closed = false;
@@ -974,6 +993,28 @@ export async function startGatewayServer(
respond(true, status, undefined);
break;
}
case "last-heartbeat": {
respond(true, getLastHeartbeatEvent(), undefined);
break;
}
case "set-heartbeats": {
const params = (req.params ?? {}) as Record<string, unknown>;
const enabled = params.enabled;
if (typeof enabled !== "boolean") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid set-heartbeats params: enabled (boolean) required",
),
);
break;
}
setHeartbeatsEnabled(enabled);
respond(true, { ok: true, enabled }, undefined);
break;
}
case "system-presence": {
const presence = listSystemPresence();
respond(true, presence, undefined);
@@ -1399,6 +1440,13 @@ export async function startGatewayServer(
/* ignore */
}
}
if (heartbeatUnsub) {
try {
heartbeatUnsub();
} catch {
/* ignore */
}
}
chatRunSessions.clear();
chatRunBuffers.clear();
for (const c of clients) {