mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 13:54:30 +00:00
refactor(gateway): split server runtime
This commit is contained in:
197
src/gateway/server-bridge-events.ts
Normal file
197
src/gateway/server-bridge-events.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
|
import { agentCommand } from "../commands/agent.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { saveSessionStore } from "../config/sessions.js";
|
||||||
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import type {
|
||||||
|
BridgeEvent,
|
||||||
|
BridgeHandlersContext,
|
||||||
|
} from "./server-bridge-types.js";
|
||||||
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
|
export const handleBridgeEvent = async (
|
||||||
|
ctx: BridgeHandlersContext,
|
||||||
|
nodeId: string,
|
||||||
|
evt: BridgeEvent,
|
||||||
|
) => {
|
||||||
|
switch (evt.event) {
|
||||||
|
case "voice.transcript": {
|
||||||
|
if (!evt.payloadJSON) return;
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj =
|
||||||
|
typeof payload === "object" && payload !== null
|
||||||
|
? (payload as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const text = typeof obj.text === "string" ? obj.text.trim() : "";
|
||||||
|
if (!text) return;
|
||||||
|
if (text.length > 20_000) return;
|
||||||
|
const sessionKeyRaw =
|
||||||
|
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||||
|
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey;
|
||||||
|
const { storePath, store, entry, canonicalKey } =
|
||||||
|
loadSessionEntry(sessionKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
|
store[canonicalKey] = {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
reasoningLevel: entry?.reasoningLevel,
|
||||||
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
|
lastChannel: entry?.lastChannel,
|
||||||
|
lastTo: entry?.lastTo,
|
||||||
|
};
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
||||||
|
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
|
||||||
|
ctx.addChatRun(sessionId, {
|
||||||
|
sessionKey,
|
||||||
|
clientRunId: `voice-${randomUUID()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void agentCommand(
|
||||||
|
{
|
||||||
|
message: text,
|
||||||
|
sessionId,
|
||||||
|
sessionKey,
|
||||||
|
thinking: "low",
|
||||||
|
deliver: false,
|
||||||
|
messageChannel: "node",
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
ctx.deps,
|
||||||
|
).catch((err) => {
|
||||||
|
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "agent.request": {
|
||||||
|
if (!evt.payloadJSON) return;
|
||||||
|
type AgentDeepLink = {
|
||||||
|
message?: string;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
thinking?: string | null;
|
||||||
|
deliver?: boolean;
|
||||||
|
to?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
timeoutSeconds?: number | null;
|
||||||
|
key?: string | null;
|
||||||
|
};
|
||||||
|
let link: AgentDeepLink | null = null;
|
||||||
|
try {
|
||||||
|
link = JSON.parse(evt.payloadJSON) as AgentDeepLink;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = (link?.message ?? "").trim();
|
||||||
|
if (!message) return;
|
||||||
|
if (message.length > 20_000) return;
|
||||||
|
|
||||||
|
const channelRaw =
|
||||||
|
typeof link?.channel === "string" ? link.channel.trim() : "";
|
||||||
|
const channel = normalizeChannelId(channelRaw) ?? undefined;
|
||||||
|
const to =
|
||||||
|
typeof link?.to === "string" && link.to.trim()
|
||||||
|
? link.to.trim()
|
||||||
|
: undefined;
|
||||||
|
const deliver = Boolean(link?.deliver) && Boolean(channel);
|
||||||
|
|
||||||
|
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
||||||
|
const sessionKey =
|
||||||
|
sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
|
||||||
|
const { storePath, store, entry, canonicalKey } =
|
||||||
|
loadSessionEntry(sessionKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
|
store[canonicalKey] = {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
reasoningLevel: entry?.reasoningLevel,
|
||||||
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
|
lastChannel: entry?.lastChannel,
|
||||||
|
lastTo: entry?.lastTo,
|
||||||
|
};
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
void agentCommand(
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
sessionKey,
|
||||||
|
thinking: link?.thinking ?? undefined,
|
||||||
|
deliver,
|
||||||
|
to,
|
||||||
|
channel,
|
||||||
|
timeout:
|
||||||
|
typeof link?.timeoutSeconds === "number"
|
||||||
|
? link.timeoutSeconds.toString()
|
||||||
|
: undefined,
|
||||||
|
messageChannel: "node",
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
ctx.deps,
|
||||||
|
).catch((err) => {
|
||||||
|
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "chat.subscribe": {
|
||||||
|
if (!evt.payloadJSON) return;
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj =
|
||||||
|
typeof payload === "object" && payload !== null
|
||||||
|
? (payload as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const sessionKey =
|
||||||
|
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||||
|
if (!sessionKey) return;
|
||||||
|
ctx.bridgeSubscribe(nodeId, sessionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "chat.unsubscribe": {
|
||||||
|
if (!evt.payloadJSON) return;
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj =
|
||||||
|
typeof payload === "object" && payload !== null
|
||||||
|
? (payload as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const sessionKey =
|
||||||
|
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||||
|
if (!sessionKey) return;
|
||||||
|
ctx.bridgeUnsubscribe(nodeId, sessionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
391
src/gateway/server-bridge-methods-chat.ts
Normal file
391
src/gateway/server-bridge-methods-chat.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
|
import { agentCommand } from "../commands/agent.js";
|
||||||
|
import { mergeSessionEntry, saveSessionStore } from "../config/sessions.js";
|
||||||
|
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import {
|
||||||
|
abortChatRunById,
|
||||||
|
abortChatRunsForSessionKey,
|
||||||
|
isChatStopCommandText,
|
||||||
|
resolveChatRunExpiresAtMs,
|
||||||
|
} from "./chat-abort.js";
|
||||||
|
import {
|
||||||
|
type ChatImageContent,
|
||||||
|
parseMessageWithAttachments,
|
||||||
|
} from "./chat-attachments.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
errorShape,
|
||||||
|
formatValidationErrors,
|
||||||
|
validateChatAbortParams,
|
||||||
|
validateChatHistoryParams,
|
||||||
|
validateChatSendParams,
|
||||||
|
} from "./protocol/index.js";
|
||||||
|
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||||
|
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "./server-constants.js";
|
||||||
|
import {
|
||||||
|
capArrayByJsonBytes,
|
||||||
|
loadSessionEntry,
|
||||||
|
readSessionMessages,
|
||||||
|
resolveSessionModelRef,
|
||||||
|
} from "./session-utils.js";
|
||||||
|
|
||||||
|
export const handleChatBridgeMethods: BridgeMethodHandler = async (
|
||||||
|
ctx,
|
||||||
|
nodeId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
switch (method) {
|
||||||
|
case "chat.history": {
|
||||||
|
if (!validateChatHistoryParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { sessionKey, limit } = params as {
|
||||||
|
sessionKey: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
||||||
|
const sessionId = entry?.sessionId;
|
||||||
|
const rawMessages =
|
||||||
|
sessionId && storePath
|
||||||
|
? readSessionMessages(sessionId, storePath, entry?.sessionFile)
|
||||||
|
: [];
|
||||||
|
const max = typeof limit === "number" ? limit : 200;
|
||||||
|
const sliced =
|
||||||
|
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
|
const capped = capArrayByJsonBytes(
|
||||||
|
sliced,
|
||||||
|
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
||||||
|
).items;
|
||||||
|
let thinkingLevel = entry?.thinkingLevel;
|
||||||
|
if (!thinkingLevel) {
|
||||||
|
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||||
|
if (configured) {
|
||||||
|
thinkingLevel = configured;
|
||||||
|
} else {
|
||||||
|
const { provider, model } = resolveSessionModelRef(cfg, entry);
|
||||||
|
const catalog = await ctx.loadGatewayModelCatalog();
|
||||||
|
thinkingLevel = resolveThinkingDefault({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
catalog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
sessionKey,
|
||||||
|
sessionId,
|
||||||
|
messages: capped,
|
||||||
|
thinkingLevel,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "chat.abort": {
|
||||||
|
if (!validateChatAbortParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionKey, runId } = params as {
|
||||||
|
sessionKey: string;
|
||||||
|
runId?: string;
|
||||||
|
};
|
||||||
|
const ops = {
|
||||||
|
chatAbortControllers: ctx.chatAbortControllers,
|
||||||
|
chatRunBuffers: ctx.chatRunBuffers,
|
||||||
|
chatDeltaSentAt: ctx.chatDeltaSentAt,
|
||||||
|
chatAbortedRuns: ctx.chatAbortedRuns,
|
||||||
|
removeChatRun: ctx.removeChatRun,
|
||||||
|
agentRunSeq: ctx.agentRunSeq,
|
||||||
|
broadcast: ctx.broadcast,
|
||||||
|
bridgeSendToSession: ctx.bridgeSendToSession,
|
||||||
|
};
|
||||||
|
if (!runId) {
|
||||||
|
const res = abortChatRunsForSessionKey(ops, {
|
||||||
|
sessionKey,
|
||||||
|
stopReason: "rpc",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
aborted: res.aborted,
|
||||||
|
runIds: res.runIds,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const active = ctx.chatAbortControllers.get(runId);
|
||||||
|
if (!active) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
aborted: false,
|
||||||
|
runIds: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (active.sessionKey !== sessionKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "runId does not match sessionKey",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const res = abortChatRunById(ops, {
|
||||||
|
runId,
|
||||||
|
sessionKey,
|
||||||
|
stopReason: "rpc",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
aborted: res.aborted,
|
||||||
|
runIds: res.aborted ? [runId] : [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "chat.send": {
|
||||||
|
if (!validateChatSendParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as {
|
||||||
|
sessionKey: string;
|
||||||
|
message: string;
|
||||||
|
thinking?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
attachments?: Array<{
|
||||||
|
type?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
content?: unknown;
|
||||||
|
}>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
const stopCommand = isChatStopCommandText(p.message);
|
||||||
|
const normalizedAttachments =
|
||||||
|
p.attachments
|
||||||
|
?.map((a) => ({
|
||||||
|
type: typeof a?.type === "string" ? a.type : undefined,
|
||||||
|
mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined,
|
||||||
|
fileName: typeof a?.fileName === "string" ? a.fileName : undefined,
|
||||||
|
content:
|
||||||
|
typeof a?.content === "string"
|
||||||
|
? a.content
|
||||||
|
: ArrayBuffer.isView(a?.content)
|
||||||
|
? Buffer.from(
|
||||||
|
a.content.buffer,
|
||||||
|
a.content.byteOffset,
|
||||||
|
a.content.byteLength,
|
||||||
|
).toString("base64")
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
.filter((a) => a.content) ?? [];
|
||||||
|
|
||||||
|
let parsedMessage = p.message;
|
||||||
|
let parsedImages: ChatImageContent[] = [];
|
||||||
|
if (normalizedAttachments.length > 0) {
|
||||||
|
try {
|
||||||
|
const parsed = await parseMessageWithAttachments(
|
||||||
|
p.message,
|
||||||
|
normalizedAttachments,
|
||||||
|
{ maxBytes: 5_000_000, log: ctx.logBridge },
|
||||||
|
);
|
||||||
|
parsedMessage = parsed.message;
|
||||||
|
parsedImages = parsed.images;
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: String(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(
|
||||||
|
p.sessionKey,
|
||||||
|
);
|
||||||
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
|
cfg,
|
||||||
|
overrideMs: p.timeoutMs,
|
||||||
|
});
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
|
const sessionEntry = mergeSessionEntry(entry, {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
const clientRunId = p.idempotencyKey;
|
||||||
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
|
if (stopCommand) {
|
||||||
|
const res = abortChatRunsForSessionKey(
|
||||||
|
{
|
||||||
|
chatAbortControllers: ctx.chatAbortControllers,
|
||||||
|
chatRunBuffers: ctx.chatRunBuffers,
|
||||||
|
chatDeltaSentAt: ctx.chatDeltaSentAt,
|
||||||
|
chatAbortedRuns: ctx.chatAbortedRuns,
|
||||||
|
removeChatRun: ctx.removeChatRun,
|
||||||
|
agentRunSeq: ctx.agentRunSeq,
|
||||||
|
broadcast: ctx.broadcast,
|
||||||
|
bridgeSendToSession: ctx.bridgeSendToSession,
|
||||||
|
},
|
||||||
|
{ sessionKey: p.sessionKey, stopReason: "stop" },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
aborted: res.aborted,
|
||||||
|
runIds: res.runIds,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
|
||||||
|
if (cached) {
|
||||||
|
if (cached.ok) {
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(cached.payload) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: cached.error ?? {
|
||||||
|
code: ErrorCodes.UNAVAILABLE,
|
||||||
|
message: "request failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeExisting = ctx.chatAbortControllers.get(clientRunId);
|
||||||
|
if (activeExisting) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
runId: clientRunId,
|
||||||
|
status: "in_flight",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
ctx.chatAbortControllers.set(clientRunId, {
|
||||||
|
controller: abortController,
|
||||||
|
sessionId,
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
startedAtMs: now,
|
||||||
|
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||||
|
});
|
||||||
|
ctx.addChatRun(clientRunId, {
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
clientRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (store) {
|
||||||
|
store[canonicalKey] = sessionEntry;
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ackPayload = {
|
||||||
|
runId: clientRunId,
|
||||||
|
status: "started" as const,
|
||||||
|
};
|
||||||
|
void agentCommand(
|
||||||
|
{
|
||||||
|
message: parsedMessage,
|
||||||
|
images: parsedImages.length > 0 ? parsedImages : undefined,
|
||||||
|
sessionId,
|
||||||
|
sessionKey: p.sessionKey,
|
||||||
|
runId: clientRunId,
|
||||||
|
thinking: p.thinking,
|
||||||
|
deliver: p.deliver,
|
||||||
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
|
messageChannel: `node(${nodeId})`,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
ctx.deps,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: true,
|
||||||
|
payload: { runId: clientRunId, status: "ok" as const },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||||
|
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
payload: {
|
||||||
|
runId: clientRunId,
|
||||||
|
status: "error" as const,
|
||||||
|
summary: String(err),
|
||||||
|
},
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ctx.chatAbortControllers.delete(clientRunId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(ackPayload) };
|
||||||
|
} catch (err) {
|
||||||
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||||
|
const payload = {
|
||||||
|
runId: clientRunId,
|
||||||
|
status: "error" as const,
|
||||||
|
summary: String(err),
|
||||||
|
};
|
||||||
|
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
payload,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error ?? {
|
||||||
|
code: ErrorCodes.UNAVAILABLE,
|
||||||
|
message: String(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
133
src/gateway/server-bridge-methods-config.ts
Normal file
133
src/gateway/server-bridge-methods-config.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
|
import {
|
||||||
|
CONFIG_PATH_CLAWDBOT,
|
||||||
|
loadConfig,
|
||||||
|
parseConfigJson5,
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
validateConfigObject,
|
||||||
|
writeConfigFile,
|
||||||
|
} from "../config/config.js";
|
||||||
|
import { buildConfigSchema } from "../config/schema.js";
|
||||||
|
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
formatValidationErrors,
|
||||||
|
validateConfigGetParams,
|
||||||
|
validateConfigSchemaParams,
|
||||||
|
validateConfigSetParams,
|
||||||
|
} from "./protocol/index.js";
|
||||||
|
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||||
|
|
||||||
|
export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||||
|
_ctx,
|
||||||
|
_nodeId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
switch (method) {
|
||||||
|
case "config.get": {
|
||||||
|
if (!validateConfigGetParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
|
||||||
|
}
|
||||||
|
case "config.schema": {
|
||||||
|
if (!validateConfigSchemaParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(
|
||||||
|
cfg,
|
||||||
|
resolveDefaultAgentId(cfg),
|
||||||
|
);
|
||||||
|
const pluginRegistry = loadClawdbotPlugins({
|
||||||
|
config: cfg,
|
||||||
|
workspaceDir,
|
||||||
|
logger: {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const schema = buildConfigSchema({
|
||||||
|
plugins: pluginRegistry.plugins.map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
configUiHints: plugin.configUiHints,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(schema) };
|
||||||
|
}
|
||||||
|
case "config.set": {
|
||||||
|
if (!validateConfigSetParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rawValue = (params as { raw?: unknown }).raw;
|
||||||
|
if (typeof rawValue !== "string") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "invalid config.set params: raw (string) required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsedRes = parseConfigJson5(rawValue);
|
||||||
|
if (!parsedRes.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: parsedRes.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const validated = validateConfigObject(parsedRes.parsed);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "invalid config",
|
||||||
|
details: { issues: validated.issues },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await writeConfigFile(validated.config);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
path: CONFIG_PATH_CLAWDBOT,
|
||||||
|
config: validated.config,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
401
src/gateway/server-bridge-methods-sessions.ts
Normal file
401
src/gateway/server-bridge-methods-sessions.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import {
|
||||||
|
abortEmbeddedPiRun,
|
||||||
|
isEmbeddedPiRunActive,
|
||||||
|
resolveEmbeddedSessionLane,
|
||||||
|
waitForEmbeddedPiRunEnd,
|
||||||
|
} from "../agents/pi-embedded.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveMainSessionKeyFromConfig,
|
||||||
|
type SessionEntry,
|
||||||
|
saveSessionStore,
|
||||||
|
} from "../config/sessions.js";
|
||||||
|
import { clearCommandLane } from "../process/command-queue.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
formatValidationErrors,
|
||||||
|
type SessionsCompactParams,
|
||||||
|
type SessionsDeleteParams,
|
||||||
|
type SessionsListParams,
|
||||||
|
type SessionsPatchParams,
|
||||||
|
type SessionsResetParams,
|
||||||
|
type SessionsResolveParams,
|
||||||
|
validateSessionsCompactParams,
|
||||||
|
validateSessionsDeleteParams,
|
||||||
|
validateSessionsListParams,
|
||||||
|
validateSessionsPatchParams,
|
||||||
|
validateSessionsResetParams,
|
||||||
|
validateSessionsResolveParams,
|
||||||
|
} from "./protocol/index.js";
|
||||||
|
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||||
|
import {
|
||||||
|
archiveFileOnDisk,
|
||||||
|
listSessionsFromStore,
|
||||||
|
loadCombinedSessionStoreForGateway,
|
||||||
|
loadSessionEntry,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
|
resolveSessionTranscriptCandidates,
|
||||||
|
type SessionsPatchResult,
|
||||||
|
} from "./session-utils.js";
|
||||||
|
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
||||||
|
import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js";
|
||||||
|
|
||||||
|
export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
|
||||||
|
ctx,
|
||||||
|
_nodeId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
switch (method) {
|
||||||
|
case "sessions.list": {
|
||||||
|
if (!validateSessionsListParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const p = params as SessionsListParams;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
|
const result = listSessionsFromStore({
|
||||||
|
cfg,
|
||||||
|
storePath,
|
||||||
|
store,
|
||||||
|
opts: p,
|
||||||
|
});
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(result) };
|
||||||
|
}
|
||||||
|
case "sessions.resolve": {
|
||||||
|
if (!validateSessionsResolveParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsResolveParams;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: resolved.error.code,
|
||||||
|
message: resolved.error.message,
|
||||||
|
details: resolved.error.details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ ok: true, key: resolved.key }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "sessions.patch": {
|
||||||
|
if (!validateSessionsPatchParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsPatchParams;
|
||||||
|
const key = String(p.key ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "key required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const storePath = target.storePath;
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const primaryKey = target.storeKeys[0] ?? key;
|
||||||
|
const existingKey = target.storeKeys.find(
|
||||||
|
(candidate) => store[candidate],
|
||||||
|
);
|
||||||
|
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||||
|
store[primaryKey] = store[existingKey];
|
||||||
|
delete store[existingKey];
|
||||||
|
}
|
||||||
|
const applied = await applySessionsPatchToStore({
|
||||||
|
cfg,
|
||||||
|
store,
|
||||||
|
storeKey: primaryKey,
|
||||||
|
patch: p,
|
||||||
|
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||||
|
});
|
||||||
|
if (!applied.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: applied.error.code,
|
||||||
|
message: applied.error.message,
|
||||||
|
details: applied.error.details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
const payload: SessionsPatchResult = {
|
||||||
|
ok: true,
|
||||||
|
path: storePath,
|
||||||
|
key: target.canonicalKey,
|
||||||
|
entry: applied.entry,
|
||||||
|
};
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
case "sessions.reset": {
|
||||||
|
if (!validateSessionsResetParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsResetParams;
|
||||||
|
const key = String(p.key ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "key required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storePath, store, entry } = loadSessionEntry(key);
|
||||||
|
const now = Date.now();
|
||||||
|
const next: SessionEntry = {
|
||||||
|
sessionId: randomUUID(),
|
||||||
|
updatedAt: now,
|
||||||
|
systemSent: false,
|
||||||
|
abortedLastRun: false,
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
reasoningLevel: entry?.reasoningLevel,
|
||||||
|
model: entry?.model,
|
||||||
|
contextTokens: entry?.contextTokens,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
|
label: entry?.label,
|
||||||
|
displayName: entry?.displayName,
|
||||||
|
chatType: entry?.chatType,
|
||||||
|
channel: entry?.channel,
|
||||||
|
subject: entry?.subject,
|
||||||
|
room: entry?.room,
|
||||||
|
space: entry?.space,
|
||||||
|
lastChannel: entry?.lastChannel,
|
||||||
|
lastTo: entry?.lastTo,
|
||||||
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
|
};
|
||||||
|
store[key] = next;
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ ok: true, key, entry: next }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "sessions.delete": {
|
||||||
|
if (!validateSessionsDeleteParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsDeleteParams;
|
||||||
|
const key = String(p.key ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "key required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainKey = resolveMainSessionKeyFromConfig();
|
||||||
|
if (key === mainKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `Cannot delete the main session (${mainKey}).`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTranscript =
|
||||||
|
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
||||||
|
|
||||||
|
const { storePath, store, entry } = loadSessionEntry(key);
|
||||||
|
const sessionId = entry?.sessionId;
|
||||||
|
const existed = Boolean(store[key]);
|
||||||
|
clearCommandLane(resolveEmbeddedSessionLane(key));
|
||||||
|
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
|
||||||
|
abortEmbeddedPiRun(sessionId);
|
||||||
|
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
||||||
|
if (!ended) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.UNAVAILABLE,
|
||||||
|
message: `Session ${key} is still active; try again in a moment.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existed) delete store[key];
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
|
||||||
|
const archived: string[] = [];
|
||||||
|
if (deleteTranscript && sessionId) {
|
||||||
|
for (const candidate of resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
entry?.sessionFile,
|
||||||
|
)) {
|
||||||
|
if (!fs.existsSync(candidate)) continue;
|
||||||
|
try {
|
||||||
|
archived.push(archiveFileOnDisk(candidate, "deleted"));
|
||||||
|
} catch {
|
||||||
|
// Best-effort; deleting the store entry is the main operation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
deleted: existed,
|
||||||
|
archived,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "sessions.compact": {
|
||||||
|
if (!validateSessionsCompactParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsCompactParams;
|
||||||
|
const key = String(p.key ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "key required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines =
|
||||||
|
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
|
||||||
|
? Math.max(1, Math.floor(p.maxLines))
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
const { storePath, store, entry } = loadSessionEntry(key);
|
||||||
|
const sessionId = entry?.sessionId;
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
compacted: false,
|
||||||
|
reason: "no sessionId",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
entry?.sessionFile,
|
||||||
|
).find((candidate) => fs.existsSync(candidate));
|
||||||
|
if (!filePath) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
compacted: false,
|
||||||
|
reason: "no transcript",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length <= maxLines) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
compacted: false,
|
||||||
|
kept: lines.length,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archived = archiveFileOnDisk(filePath, "bak");
|
||||||
|
const keptLines = lines.slice(-maxLines);
|
||||||
|
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
||||||
|
|
||||||
|
// Token counts no longer match; clear so status + UI reflect reality after the next turn.
|
||||||
|
if (store[key]) {
|
||||||
|
delete store[key].inputTokens;
|
||||||
|
delete store[key].outputTokens;
|
||||||
|
delete store[key].totalTokens;
|
||||||
|
store[key].updatedAt = Date.now();
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key,
|
||||||
|
compacted: true,
|
||||||
|
archived,
|
||||||
|
kept: keptLines.length,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
81
src/gateway/server-bridge-methods-system.ts
Normal file
81
src/gateway/server-bridge-methods-system.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
loadVoiceWakeConfig,
|
||||||
|
setVoiceWakeTriggers,
|
||||||
|
} from "../infra/voicewake.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
formatValidationErrors,
|
||||||
|
validateModelsListParams,
|
||||||
|
validateTalkModeParams,
|
||||||
|
} from "./protocol/index.js";
|
||||||
|
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||||
|
import { HEALTH_REFRESH_INTERVAL_MS } from "./server-constants.js";
|
||||||
|
import { normalizeVoiceWakeTriggers } from "./server-utils.js";
|
||||||
|
|
||||||
|
export const handleSystemBridgeMethods: BridgeMethodHandler = async (
|
||||||
|
ctx,
|
||||||
|
_nodeId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
) => {
|
||||||
|
switch (method) {
|
||||||
|
case "voicewake.get": {
|
||||||
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "voicewake.set": {
|
||||||
|
const triggers = normalizeVoiceWakeTriggers(params.triggers);
|
||||||
|
const cfg = await setVoiceWakeTriggers(triggers);
|
||||||
|
ctx.broadcastVoiceWakeChanged(cfg.triggers);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "health": {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = ctx.getHealthCache();
|
||||||
|
if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(cached) };
|
||||||
|
}
|
||||||
|
const snap = await ctx.refreshHealthSnapshot({ probe: false });
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(snap) };
|
||||||
|
}
|
||||||
|
case "talk.mode": {
|
||||||
|
if (!validateTalkModeParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
enabled: (params as { enabled: boolean }).enabled,
|
||||||
|
phase: (params as { phase?: string }).phase ?? null,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
ctx.broadcast("talk.mode", payload, { dropIfSlow: true });
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
case "models.list": {
|
||||||
|
if (!validateModelsListParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const models = await ctx.loadGatewayModelCatalog();
|
||||||
|
return { ok: true, payloadJSON: JSON.stringify({ models }) };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
281
src/gateway/server-bridge-runtime.ts
Normal file
281
src/gateway/server-bridge-runtime.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||||
|
import type {
|
||||||
|
CanvasHostHandler,
|
||||||
|
CanvasHostServer,
|
||||||
|
} from "../canvas-host/server.js";
|
||||||
|
import { startCanvasHost } from "../canvas-host/server.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
|
import {
|
||||||
|
deriveDefaultBridgePort,
|
||||||
|
deriveDefaultCanvasHostPort,
|
||||||
|
} from "../config/port-defaults.js";
|
||||||
|
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
|
import {
|
||||||
|
pickPrimaryTailnetIPv4,
|
||||||
|
pickPrimaryTailnetIPv6,
|
||||||
|
} from "../infra/tailnet.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||||
|
import { createBridgeHandlers } from "./server-bridge.js";
|
||||||
|
import {
|
||||||
|
type BridgeListConnectedFn,
|
||||||
|
type BridgeSendEventFn,
|
||||||
|
createBridgeSubscriptionManager,
|
||||||
|
} from "./server-bridge-subscriptions.js";
|
||||||
|
import type { ChatRunEntry } from "./server-chat.js";
|
||||||
|
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
||||||
|
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||||
|
import { startGatewayNodeBridge } from "./server-node-bridge.js";
|
||||||
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
|
|
||||||
|
export type GatewayBridgeRuntime = {
|
||||||
|
bridge: import("../infra/bridge/server.js").NodeBridgeServer | null;
|
||||||
|
bridgeHost: string | null;
|
||||||
|
bridgePort: number;
|
||||||
|
canvasHostServer: CanvasHostServer | null;
|
||||||
|
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
|
bonjourStop: (() => Promise<void>) | null;
|
||||||
|
bridgeSendToSession: (
|
||||||
|
sessionKey: string,
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
) => void;
|
||||||
|
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||||
|
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function startGatewayBridgeRuntime(params: {
|
||||||
|
cfg: {
|
||||||
|
bridge?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
port?: number;
|
||||||
|
bind?: "loopback" | "lan" | "auto" | "custom";
|
||||||
|
};
|
||||||
|
canvasHost?: { port?: number; root?: string; liveReload?: boolean };
|
||||||
|
discovery?: { wideArea?: { enabled?: boolean } };
|
||||||
|
};
|
||||||
|
port: number;
|
||||||
|
canvasHostEnabled: boolean;
|
||||||
|
canvasHost: CanvasHostHandler | null;
|
||||||
|
canvasRuntime: RuntimeEnv;
|
||||||
|
allowCanvasHostInTests?: boolean;
|
||||||
|
machineDisplayName: string;
|
||||||
|
deps: CliDeps;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
dedupe: Map<string, DedupeEntry>;
|
||||||
|
agentRunSeq: Map<string, number>;
|
||||||
|
chatRunState: { abortedRuns: Map<string, number> };
|
||||||
|
chatRunBuffers: Map<string, string>;
|
||||||
|
chatDeltaSentAt: Map<string, number>;
|
||||||
|
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||||
|
removeChatRun: (
|
||||||
|
sessionId: string,
|
||||||
|
clientRunId: string,
|
||||||
|
sessionKey?: string,
|
||||||
|
) => ChatRunEntry | undefined;
|
||||||
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||||
|
getHealthCache: () => HealthSummary | null;
|
||||||
|
refreshGatewayHealthSnapshot: (opts?: {
|
||||||
|
probe?: boolean;
|
||||||
|
}) => Promise<HealthSummary>;
|
||||||
|
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||||
|
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
logCanvas: { warn: (msg: string) => void };
|
||||||
|
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}): Promise<GatewayBridgeRuntime> {
|
||||||
|
const wideAreaDiscoveryEnabled =
|
||||||
|
params.cfg.discovery?.wideArea?.enabled === true;
|
||||||
|
|
||||||
|
const bridgeEnabled = (() => {
|
||||||
|
if (params.cfg.bridge?.enabled !== undefined)
|
||||||
|
return params.cfg.bridge.enabled === true;
|
||||||
|
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const bridgePort = (() => {
|
||||||
|
if (
|
||||||
|
typeof params.cfg.bridge?.port === "number" &&
|
||||||
|
params.cfg.bridge.port > 0
|
||||||
|
) {
|
||||||
|
return params.cfg.bridge.port;
|
||||||
|
}
|
||||||
|
if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
|
||||||
|
const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: deriveDefaultBridgePort(params.port);
|
||||||
|
}
|
||||||
|
return deriveDefaultBridgePort(params.port);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const bridgeHost = (() => {
|
||||||
|
// Back-compat: allow an env var override when no bind policy is configured.
|
||||||
|
if (params.cfg.bridge?.bind === undefined) {
|
||||||
|
const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim();
|
||||||
|
if (env) return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bind =
|
||||||
|
params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
|
||||||
|
if (bind === "loopback") return "127.0.0.1";
|
||||||
|
if (bind === "lan") return "0.0.0.0";
|
||||||
|
|
||||||
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||||
|
if (bind === "auto") {
|
||||||
|
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0";
|
||||||
|
}
|
||||||
|
if (bind === "custom") {
|
||||||
|
// For bridge, customBindHost is not currently supported on GatewayConfig.
|
||||||
|
// This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig.
|
||||||
|
return "0.0.0.0";
|
||||||
|
}
|
||||||
|
return "0.0.0.0";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const canvasHostPort = (() => {
|
||||||
|
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
|
||||||
|
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
return deriveDefaultCanvasHostPort(params.port);
|
||||||
|
}
|
||||||
|
const configured = params.cfg.canvasHost?.port;
|
||||||
|
if (typeof configured === "number" && configured > 0) return configured;
|
||||||
|
return deriveDefaultCanvasHostPort(params.port);
|
||||||
|
})();
|
||||||
|
|
||||||
|
let canvasHostServer: CanvasHostServer | null = null;
|
||||||
|
if (params.canvasHostEnabled && bridgeEnabled && bridgeHost) {
|
||||||
|
try {
|
||||||
|
const started = await startCanvasHost({
|
||||||
|
runtime: params.canvasRuntime,
|
||||||
|
rootDir: params.cfg.canvasHost?.root,
|
||||||
|
port: canvasHostPort,
|
||||||
|
listenHost: bridgeHost,
|
||||||
|
allowInTests: params.allowCanvasHostInTests,
|
||||||
|
liveReload: params.cfg.canvasHost?.liveReload,
|
||||||
|
handler: params.canvasHost ?? undefined,
|
||||||
|
ownsHandler: params.canvasHost ? false : undefined,
|
||||||
|
});
|
||||||
|
if (started.port > 0) {
|
||||||
|
canvasHostServer = started;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logCanvas.warn(
|
||||||
|
`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bridge: NodeBridgeServer | null = null;
|
||||||
|
const bridgeSubscriptions = createBridgeSubscriptionManager();
|
||||||
|
const bridgeSubscribe = bridgeSubscriptions.subscribe;
|
||||||
|
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
|
||||||
|
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
|
||||||
|
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
|
||||||
|
bridge?.sendEvent(opts);
|
||||||
|
};
|
||||||
|
const bridgeListConnected: BridgeListConnectedFn = () =>
|
||||||
|
bridge?.listConnected() ?? [];
|
||||||
|
const bridgeSendToSession = (
|
||||||
|
sessionKey: string,
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
) =>
|
||||||
|
bridgeSubscriptions.sendToSession(
|
||||||
|
sessionKey,
|
||||||
|
event,
|
||||||
|
payload,
|
||||||
|
bridgeSendEvent,
|
||||||
|
);
|
||||||
|
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
|
||||||
|
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
|
||||||
|
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
|
||||||
|
bridgeSubscriptions.sendToAllConnected(
|
||||||
|
event,
|
||||||
|
payload,
|
||||||
|
bridgeListConnected,
|
||||||
|
bridgeSendEvent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const broadcastVoiceWakeChanged = (triggers: string[]) => {
|
||||||
|
const payload = { triggers };
|
||||||
|
params.broadcast("voicewake.changed", payload, { dropIfSlow: true });
|
||||||
|
bridgeSendToAllConnected("voicewake.changed", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({
|
||||||
|
deps: params.deps,
|
||||||
|
broadcast: params.broadcast,
|
||||||
|
bridgeSendToSession,
|
||||||
|
bridgeSubscribe,
|
||||||
|
bridgeUnsubscribe,
|
||||||
|
broadcastVoiceWakeChanged,
|
||||||
|
addChatRun: params.addChatRun,
|
||||||
|
removeChatRun: params.removeChatRun,
|
||||||
|
chatAbortControllers: params.chatAbortControllers,
|
||||||
|
chatAbortedRuns: params.chatRunState.abortedRuns,
|
||||||
|
chatRunBuffers: params.chatRunBuffers,
|
||||||
|
chatDeltaSentAt: params.chatDeltaSentAt,
|
||||||
|
dedupe: params.dedupe,
|
||||||
|
agentRunSeq: params.agentRunSeq,
|
||||||
|
getHealthCache: params.getHealthCache,
|
||||||
|
refreshHealthSnapshot: params.refreshGatewayHealthSnapshot,
|
||||||
|
loadGatewayModelCatalog:
|
||||||
|
params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
|
||||||
|
logBridge: params.logBridge,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasHostPortForBridge = canvasHostServer?.port;
|
||||||
|
const canvasHostHostForBridge =
|
||||||
|
canvasHostServer &&
|
||||||
|
bridgeHost &&
|
||||||
|
bridgeHost !== "0.0.0.0" &&
|
||||||
|
bridgeHost !== "::"
|
||||||
|
? bridgeHost
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const bridgeRuntime = await startGatewayNodeBridge({
|
||||||
|
bridgeEnabled,
|
||||||
|
bridgePort,
|
||||||
|
bridgeHost,
|
||||||
|
machineDisplayName: params.machineDisplayName,
|
||||||
|
canvasHostPort: canvasHostPortForBridge,
|
||||||
|
canvasHostHost: canvasHostHostForBridge,
|
||||||
|
broadcast: params.broadcast,
|
||||||
|
bridgeUnsubscribeAll,
|
||||||
|
handleBridgeRequest,
|
||||||
|
handleBridgeEvent,
|
||||||
|
logBridge: params.logBridge,
|
||||||
|
});
|
||||||
|
bridge = bridgeRuntime.bridge;
|
||||||
|
|
||||||
|
const discovery = await startGatewayDiscovery({
|
||||||
|
machineDisplayName: params.machineDisplayName,
|
||||||
|
port: params.port,
|
||||||
|
bridgePort: bridge?.port,
|
||||||
|
canvasPort: canvasHostPortForBridge,
|
||||||
|
wideAreaDiscoveryEnabled,
|
||||||
|
logDiscovery: params.logDiscovery,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bridge,
|
||||||
|
bridgeHost,
|
||||||
|
bridgePort,
|
||||||
|
canvasHostServer,
|
||||||
|
nodePresenceTimers: bridgeRuntime.nodePresenceTimers,
|
||||||
|
bonjourStop: discovery.bonjourStop,
|
||||||
|
bridgeSendToSession,
|
||||||
|
bridgeSendToAllSubscribed,
|
||||||
|
broadcastVoiceWakeChanged,
|
||||||
|
};
|
||||||
|
}
|
||||||
66
src/gateway/server-bridge-types.ts
Normal file
66
src/gateway/server-bridge-types.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
|
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||||
|
import type { ChatRunEntry } from "./server-chat.js";
|
||||||
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
|
|
||||||
|
export type BridgeHandlersContext = {
|
||||||
|
deps: CliDeps;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: { dropIfSlow?: boolean },
|
||||||
|
) => void;
|
||||||
|
bridgeSendToSession: (
|
||||||
|
sessionKey: string,
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
) => void;
|
||||||
|
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
|
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
|
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
||||||
|
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||||
|
removeChatRun: (
|
||||||
|
sessionId: string,
|
||||||
|
clientRunId: string,
|
||||||
|
sessionKey?: string,
|
||||||
|
) => ChatRunEntry | undefined;
|
||||||
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||||
|
chatAbortedRuns: Map<string, number>;
|
||||||
|
chatRunBuffers: Map<string, string>;
|
||||||
|
chatDeltaSentAt: Map<string, number>;
|
||||||
|
dedupe: Map<string, DedupeEntry>;
|
||||||
|
agentRunSeq: Map<string, number>;
|
||||||
|
getHealthCache: () => HealthSummary | null;
|
||||||
|
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||||
|
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
|
||||||
|
logBridge: { warn: (msg: string) => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BridgeRequest = {
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
paramsJSON?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BridgeEvent = {
|
||||||
|
event: string;
|
||||||
|
payloadJSON?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BridgeResponse =
|
||||||
|
| { ok: true; payloadJSON?: string | null }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: { code: string; message: string; details?: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BridgeRequestParams = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type BridgeMethodHandler = (
|
||||||
|
ctx: BridgeHandlersContext,
|
||||||
|
nodeId: string,
|
||||||
|
method: string,
|
||||||
|
params: BridgeRequestParams,
|
||||||
|
) => Promise<BridgeResponse | null>;
|
||||||
File diff suppressed because it is too large
Load Diff
56
src/gateway/server-broadcast.ts
Normal file
56
src/gateway/server-broadcast.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
|
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||||
|
import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
|
||||||
|
|
||||||
|
export function createGatewayBroadcaster(params: {
|
||||||
|
clients: Set<GatewayWsClient>;
|
||||||
|
}) {
|
||||||
|
let seq = 0;
|
||||||
|
const broadcast = (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const eventSeq = ++seq;
|
||||||
|
const frame = JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event,
|
||||||
|
payload,
|
||||||
|
seq: eventSeq,
|
||||||
|
stateVersion: opts?.stateVersion,
|
||||||
|
});
|
||||||
|
const logMeta: Record<string, unknown> = {
|
||||||
|
event,
|
||||||
|
seq: eventSeq,
|
||||||
|
clients: params.clients.size,
|
||||||
|
dropIfSlow: opts?.dropIfSlow,
|
||||||
|
presenceVersion: opts?.stateVersion?.presence,
|
||||||
|
healthVersion: opts?.stateVersion?.health,
|
||||||
|
};
|
||||||
|
if (event === "agent") {
|
||||||
|
Object.assign(logMeta, summarizeAgentEventForWsLog(payload));
|
||||||
|
}
|
||||||
|
logWs("out", "event", logMeta);
|
||||||
|
for (const c of params.clients) {
|
||||||
|
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
|
||||||
|
if (slow && opts?.dropIfSlow) continue;
|
||||||
|
if (slow) {
|
||||||
|
try {
|
||||||
|
c.socket.close(1008, "slow consumer");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
c.socket.send(frame);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { broadcast };
|
||||||
|
}
|
||||||
138
src/gateway/server-close.ts
Normal file
138
src/gateway/server-close.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { Server as HttpServer } from "node:http";
|
||||||
|
import type { WebSocketServer } from "ws";
|
||||||
|
import type {
|
||||||
|
CanvasHostHandler,
|
||||||
|
CanvasHostServer,
|
||||||
|
} from "../canvas-host/server.js";
|
||||||
|
import {
|
||||||
|
type ChannelId,
|
||||||
|
listChannelPlugins,
|
||||||
|
} from "../channels/plugins/index.js";
|
||||||
|
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
|
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
|
|
||||||
|
export function createGatewayCloseHandler(params: {
|
||||||
|
bonjourStop: (() => Promise<void>) | null;
|
||||||
|
tailscaleCleanup: (() => Promise<void>) | null;
|
||||||
|
canvasHost: CanvasHostHandler | null;
|
||||||
|
canvasHostServer: CanvasHostServer | null;
|
||||||
|
bridge: NodeBridgeServer | null;
|
||||||
|
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
||||||
|
pluginServices: PluginServicesHandle | null;
|
||||||
|
cron: { stop: () => void };
|
||||||
|
heartbeatRunner: { stop: () => void };
|
||||||
|
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: { dropIfSlow?: boolean },
|
||||||
|
) => void;
|
||||||
|
tickInterval: ReturnType<typeof setInterval>;
|
||||||
|
healthInterval: ReturnType<typeof setInterval>;
|
||||||
|
dedupeCleanup: ReturnType<typeof setInterval>;
|
||||||
|
agentUnsub: (() => void) | null;
|
||||||
|
heartbeatUnsub: (() => void) | null;
|
||||||
|
chatRunState: { clear: () => void };
|
||||||
|
clients: Set<{ socket: { close: (code: number, reason: string) => void } }>;
|
||||||
|
configReloader: { stop: () => Promise<void> };
|
||||||
|
browserControl: { stop: () => Promise<void> } | null;
|
||||||
|
wss: WebSocketServer;
|
||||||
|
httpServer: HttpServer;
|
||||||
|
}) {
|
||||||
|
return async (opts?: {
|
||||||
|
reason?: string;
|
||||||
|
restartExpectedMs?: number | null;
|
||||||
|
}) => {
|
||||||
|
const reasonRaw =
|
||||||
|
typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
||||||
|
const reason = reasonRaw || "gateway stopping";
|
||||||
|
const restartExpectedMs =
|
||||||
|
typeof opts?.restartExpectedMs === "number" &&
|
||||||
|
Number.isFinite(opts.restartExpectedMs)
|
||||||
|
? Math.max(0, Math.floor(opts.restartExpectedMs))
|
||||||
|
: null;
|
||||||
|
if (params.bonjourStop) {
|
||||||
|
try {
|
||||||
|
await params.bonjourStop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.tailscaleCleanup) {
|
||||||
|
await params.tailscaleCleanup();
|
||||||
|
}
|
||||||
|
if (params.canvasHost) {
|
||||||
|
try {
|
||||||
|
await params.canvasHost.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.canvasHostServer) {
|
||||||
|
try {
|
||||||
|
await params.canvasHostServer.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.bridge) {
|
||||||
|
try {
|
||||||
|
await params.bridge.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const plugin of listChannelPlugins()) {
|
||||||
|
await params.stopChannel(plugin.id);
|
||||||
|
}
|
||||||
|
if (params.pluginServices) {
|
||||||
|
await params.pluginServices.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
await stopGmailWatcher();
|
||||||
|
params.cron.stop();
|
||||||
|
params.heartbeatRunner.stop();
|
||||||
|
for (const timer of params.nodePresenceTimers.values()) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
params.nodePresenceTimers.clear();
|
||||||
|
params.broadcast("shutdown", {
|
||||||
|
reason,
|
||||||
|
restartExpectedMs,
|
||||||
|
});
|
||||||
|
clearInterval(params.tickInterval);
|
||||||
|
clearInterval(params.healthInterval);
|
||||||
|
clearInterval(params.dedupeCleanup);
|
||||||
|
if (params.agentUnsub) {
|
||||||
|
try {
|
||||||
|
params.agentUnsub();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.heartbeatUnsub) {
|
||||||
|
try {
|
||||||
|
params.heartbeatUnsub();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.chatRunState.clear();
|
||||||
|
for (const c of params.clients) {
|
||||||
|
try {
|
||||||
|
c.socket.close(1012, "service restart");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.clients.clear();
|
||||||
|
await params.configReloader.stop().catch(() => {});
|
||||||
|
if (params.browserControl) {
|
||||||
|
await params.browserControl.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
params.httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
119
src/gateway/server-cron.ts
Normal file
119
src/gateway/server-cron.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { resolveAgentMainSessionKey } from "../config/sessions.js";
|
||||||
|
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
|
||||||
|
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
|
||||||
|
import { CronService } from "../cron/service.js";
|
||||||
|
import { resolveCronStorePath } from "../cron/store.js";
|
||||||
|
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
|
||||||
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
|
||||||
|
export type GatewayCronState = {
|
||||||
|
cron: CronService;
|
||||||
|
storePath: string;
|
||||||
|
cronEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildGatewayCronService(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
deps: CliDeps;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: { dropIfSlow?: boolean },
|
||||||
|
) => void;
|
||||||
|
}): GatewayCronState {
|
||||||
|
const cronLogger = getChildLogger({ module: "cron" });
|
||||||
|
const storePath = resolveCronStorePath(params.cfg.cron?.store);
|
||||||
|
const cronEnabled =
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON !== "1" &&
|
||||||
|
params.cfg.cron?.enabled !== false;
|
||||||
|
|
||||||
|
const resolveCronAgent = (requested?: string | null) => {
|
||||||
|
const runtimeConfig = loadConfig();
|
||||||
|
const normalized =
|
||||||
|
typeof requested === "string" && requested.trim()
|
||||||
|
? normalizeAgentId(requested)
|
||||||
|
: undefined;
|
||||||
|
const hasAgent =
|
||||||
|
normalized !== undefined &&
|
||||||
|
Array.isArray(runtimeConfig.agents?.list) &&
|
||||||
|
runtimeConfig.agents.list.some(
|
||||||
|
(entry) =>
|
||||||
|
entry &&
|
||||||
|
typeof entry.id === "string" &&
|
||||||
|
normalizeAgentId(entry.id) === normalized,
|
||||||
|
);
|
||||||
|
const agentId = hasAgent
|
||||||
|
? normalized
|
||||||
|
: resolveDefaultAgentId(runtimeConfig);
|
||||||
|
return { agentId, cfg: runtimeConfig };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cron = new CronService({
|
||||||
|
storePath,
|
||||||
|
cronEnabled,
|
||||||
|
enqueueSystemEvent: (text, opts) => {
|
||||||
|
const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId);
|
||||||
|
const sessionKey = resolveAgentMainSessionKey({
|
||||||
|
cfg: runtimeConfig,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
enqueueSystemEvent(text, { sessionKey });
|
||||||
|
},
|
||||||
|
requestHeartbeatNow,
|
||||||
|
runHeartbeatOnce: async (opts) => {
|
||||||
|
const runtimeConfig = loadConfig();
|
||||||
|
return await runHeartbeatOnce({
|
||||||
|
cfg: runtimeConfig,
|
||||||
|
reason: opts?.reason,
|
||||||
|
deps: { ...params.deps, runtime: defaultRuntime },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
runIsolatedAgentJob: async ({ job, message }) => {
|
||||||
|
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
|
||||||
|
return await runCronIsolatedAgentTurn({
|
||||||
|
cfg: runtimeConfig,
|
||||||
|
deps: params.deps,
|
||||||
|
job,
|
||||||
|
message,
|
||||||
|
agentId,
|
||||||
|
sessionKey: `cron:${job.id}`,
|
||||||
|
lane: "cron",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
log: getChildLogger({ module: "cron", storePath }),
|
||||||
|
onEvent: (evt) => {
|
||||||
|
params.broadcast("cron", evt, { dropIfSlow: true });
|
||||||
|
if (evt.action === "finished") {
|
||||||
|
const logPath = resolveCronRunLogPath({
|
||||||
|
storePath,
|
||||||
|
jobId: evt.jobId,
|
||||||
|
});
|
||||||
|
void appendCronRunLog(logPath, {
|
||||||
|
ts: Date.now(),
|
||||||
|
jobId: evt.jobId,
|
||||||
|
action: "finished",
|
||||||
|
status: evt.status,
|
||||||
|
error: evt.error,
|
||||||
|
summary: evt.summary,
|
||||||
|
runAtMs: evt.runAtMs,
|
||||||
|
durationMs: evt.durationMs,
|
||||||
|
nextRunAtMs: evt.nextRunAtMs,
|
||||||
|
}).catch((err) => {
|
||||||
|
cronLogger.warn(
|
||||||
|
{ err: String(err), logPath },
|
||||||
|
"cron: run log append failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cron, storePath, cronEnabled };
|
||||||
|
}
|
||||||
79
src/gateway/server-discovery-runtime.ts
Normal file
79
src/gateway/server-discovery-runtime.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||||
|
import {
|
||||||
|
pickPrimaryTailnetIPv4,
|
||||||
|
pickPrimaryTailnetIPv6,
|
||||||
|
} from "../infra/tailnet.js";
|
||||||
|
import {
|
||||||
|
WIDE_AREA_DISCOVERY_DOMAIN,
|
||||||
|
writeWideAreaBridgeZone,
|
||||||
|
} from "../infra/widearea-dns.js";
|
||||||
|
import {
|
||||||
|
formatBonjourInstanceName,
|
||||||
|
resolveBonjourCliPath,
|
||||||
|
resolveTailnetDnsHint,
|
||||||
|
} from "./server-discovery.js";
|
||||||
|
|
||||||
|
export async function startGatewayDiscovery(params: {
|
||||||
|
machineDisplayName: string;
|
||||||
|
port: number;
|
||||||
|
bridgePort?: number;
|
||||||
|
canvasPort?: number;
|
||||||
|
wideAreaDiscoveryEnabled: boolean;
|
||||||
|
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}) {
|
||||||
|
let bonjourStop: (() => Promise<void>) | null = null;
|
||||||
|
const tailnetDns = await resolveTailnetDnsHint();
|
||||||
|
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
|
||||||
|
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
|
||||||
|
const sshPort =
|
||||||
|
Number.isFinite(sshPortParsed) && sshPortParsed > 0
|
||||||
|
? sshPortParsed
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bonjour = await startGatewayBonjourAdvertiser({
|
||||||
|
instanceName: formatBonjourInstanceName(params.machineDisplayName),
|
||||||
|
gatewayPort: params.port,
|
||||||
|
bridgePort: params.bridgePort,
|
||||||
|
canvasPort: params.canvasPort,
|
||||||
|
sshPort,
|
||||||
|
tailnetDns,
|
||||||
|
cliPath: resolveBonjourCliPath(),
|
||||||
|
});
|
||||||
|
bonjourStop = bonjour.stop;
|
||||||
|
} catch (err) {
|
||||||
|
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.wideAreaDiscoveryEnabled && params.bridgePort) {
|
||||||
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
if (!tailnetIPv4) {
|
||||||
|
params.logDiscovery.warn(
|
||||||
|
"discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||||
|
const result = await writeWideAreaBridgeZone({
|
||||||
|
bridgePort: params.bridgePort,
|
||||||
|
gatewayPort: params.port,
|
||||||
|
displayName: formatBonjourInstanceName(params.machineDisplayName),
|
||||||
|
tailnetIPv4,
|
||||||
|
tailnetIPv6: tailnetIPv6 ?? undefined,
|
||||||
|
tailnetDns,
|
||||||
|
sshPort,
|
||||||
|
cliPath: resolveBonjourCliPath(),
|
||||||
|
});
|
||||||
|
params.logDiscovery.info(
|
||||||
|
`wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN} → ${result.zonePath})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
params.logDiscovery.warn(
|
||||||
|
`wide-area discovery update failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bonjourStop };
|
||||||
|
}
|
||||||
13
src/gateway/server-lanes.ts
Normal file
13
src/gateway/server-lanes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { loadConfig } from "../config/config.js";
|
||||||
|
import { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||||
|
|
||||||
|
export function applyGatewayLaneConcurrency(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
) {
|
||||||
|
setCommandLaneConcurrency("cron", cfg.cron?.maxConcurrentRuns ?? 1);
|
||||||
|
setCommandLaneConcurrency("main", cfg.agents?.defaults?.maxConcurrent ?? 1);
|
||||||
|
setCommandLaneConcurrency(
|
||||||
|
"subagent",
|
||||||
|
cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/gateway/server-maintenance.ts
Normal file
129
src/gateway/server-maintenance.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
|
import {
|
||||||
|
abortChatRunById,
|
||||||
|
type ChatAbortControllerEntry,
|
||||||
|
} from "./chat-abort.js";
|
||||||
|
import { setBroadcastHealthUpdate } from "./server/health-state.js";
|
||||||
|
import type { ChatRunEntry } from "./server-chat.js";
|
||||||
|
import {
|
||||||
|
DEDUPE_MAX,
|
||||||
|
DEDUPE_TTL_MS,
|
||||||
|
HEALTH_REFRESH_INTERVAL_MS,
|
||||||
|
TICK_INTERVAL_MS,
|
||||||
|
} from "./server-constants.js";
|
||||||
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
|
import { formatError } from "./server-utils.js";
|
||||||
|
|
||||||
|
export function startGatewayMaintenanceTimers(params: {
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||||
|
getPresenceVersion: () => number;
|
||||||
|
getHealthVersion: () => number;
|
||||||
|
refreshGatewayHealthSnapshot: (opts?: {
|
||||||
|
probe?: boolean;
|
||||||
|
}) => Promise<HealthSummary>;
|
||||||
|
logHealth: { error: (msg: string) => void };
|
||||||
|
dedupe: Map<string, DedupeEntry>;
|
||||||
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||||
|
chatRunState: { abortedRuns: Map<string, number> };
|
||||||
|
chatRunBuffers: Map<string, string>;
|
||||||
|
chatDeltaSentAt: Map<string, number>;
|
||||||
|
removeChatRun: (
|
||||||
|
sessionId: string,
|
||||||
|
clientRunId: string,
|
||||||
|
sessionKey?: string,
|
||||||
|
) => ChatRunEntry | undefined;
|
||||||
|
agentRunSeq: Map<string, number>;
|
||||||
|
bridgeSendToSession: (
|
||||||
|
sessionKey: string,
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
) => void;
|
||||||
|
}): {
|
||||||
|
tickInterval: ReturnType<typeof setInterval>;
|
||||||
|
healthInterval: ReturnType<typeof setInterval>;
|
||||||
|
dedupeCleanup: ReturnType<typeof setInterval>;
|
||||||
|
} {
|
||||||
|
setBroadcastHealthUpdate((snap: HealthSummary) => {
|
||||||
|
params.broadcast("health", snap, {
|
||||||
|
stateVersion: {
|
||||||
|
presence: params.getPresenceVersion(),
|
||||||
|
health: params.getHealthVersion(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.bridgeSendToAllSubscribed("health", snap);
|
||||||
|
});
|
||||||
|
|
||||||
|
// periodic keepalive
|
||||||
|
const tickInterval = setInterval(() => {
|
||||||
|
const payload = { ts: Date.now() };
|
||||||
|
params.broadcast("tick", payload, { dropIfSlow: true });
|
||||||
|
params.bridgeSendToAllSubscribed("tick", payload);
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// periodic health refresh to keep cached snapshot warm
|
||||||
|
const healthInterval = setInterval(() => {
|
||||||
|
void params
|
||||||
|
.refreshGatewayHealthSnapshot({ probe: true })
|
||||||
|
.catch((err) =>
|
||||||
|
params.logHealth.error(`refresh failed: ${formatError(err)}`),
|
||||||
|
);
|
||||||
|
}, HEALTH_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Prime cache so first client gets a snapshot without waiting.
|
||||||
|
void params
|
||||||
|
.refreshGatewayHealthSnapshot({ probe: true })
|
||||||
|
.catch((err) =>
|
||||||
|
params.logHealth.error(`initial refresh failed: ${formatError(err)}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// dedupe cache cleanup
|
||||||
|
const dedupeCleanup = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of params.dedupe) {
|
||||||
|
if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k);
|
||||||
|
}
|
||||||
|
if (params.dedupe.size > DEDUPE_MAX) {
|
||||||
|
const entries = [...params.dedupe.entries()].sort(
|
||||||
|
(a, b) => a[1].ts - b[1].ts,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < params.dedupe.size - DEDUPE_MAX; i++) {
|
||||||
|
params.dedupe.delete(entries[i][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [runId, entry] of params.chatAbortControllers) {
|
||||||
|
if (now <= entry.expiresAtMs) continue;
|
||||||
|
abortChatRunById(
|
||||||
|
{
|
||||||
|
chatAbortControllers: params.chatAbortControllers,
|
||||||
|
chatRunBuffers: params.chatRunBuffers,
|
||||||
|
chatDeltaSentAt: params.chatDeltaSentAt,
|
||||||
|
chatAbortedRuns: params.chatRunState.abortedRuns,
|
||||||
|
removeChatRun: params.removeChatRun,
|
||||||
|
agentRunSeq: params.agentRunSeq,
|
||||||
|
broadcast: params.broadcast,
|
||||||
|
bridgeSendToSession: params.bridgeSendToSession,
|
||||||
|
},
|
||||||
|
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABORTED_RUN_TTL_MS = 60 * 60_000;
|
||||||
|
for (const [runId, abortedAt] of params.chatRunState.abortedRuns) {
|
||||||
|
if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue;
|
||||||
|
params.chatRunState.abortedRuns.delete(runId);
|
||||||
|
params.chatRunBuffers.delete(runId);
|
||||||
|
params.chatDeltaSentAt.delete(runId);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return { tickInterval, healthInterval, dedupeCleanup };
|
||||||
|
}
|
||||||
83
src/gateway/server-methods-list.ts
Normal file
83
src/gateway/server-methods-list.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
|
|
||||||
|
const BASE_METHODS = [
|
||||||
|
"health",
|
||||||
|
"logs.tail",
|
||||||
|
"channels.status",
|
||||||
|
"channels.logout",
|
||||||
|
"status",
|
||||||
|
"usage.status",
|
||||||
|
"config.get",
|
||||||
|
"config.set",
|
||||||
|
"config.apply",
|
||||||
|
"config.schema",
|
||||||
|
"wizard.start",
|
||||||
|
"wizard.next",
|
||||||
|
"wizard.cancel",
|
||||||
|
"wizard.status",
|
||||||
|
"talk.mode",
|
||||||
|
"models.list",
|
||||||
|
"agents.list",
|
||||||
|
"skills.status",
|
||||||
|
"skills.install",
|
||||||
|
"skills.update",
|
||||||
|
"update.run",
|
||||||
|
"voicewake.get",
|
||||||
|
"voicewake.set",
|
||||||
|
"sessions.list",
|
||||||
|
"sessions.patch",
|
||||||
|
"sessions.reset",
|
||||||
|
"sessions.delete",
|
||||||
|
"sessions.compact",
|
||||||
|
"last-heartbeat",
|
||||||
|
"set-heartbeats",
|
||||||
|
"wake",
|
||||||
|
"node.pair.request",
|
||||||
|
"node.pair.list",
|
||||||
|
"node.pair.approve",
|
||||||
|
"node.pair.reject",
|
||||||
|
"node.pair.verify",
|
||||||
|
"node.rename",
|
||||||
|
"node.list",
|
||||||
|
"node.describe",
|
||||||
|
"node.invoke",
|
||||||
|
"cron.list",
|
||||||
|
"cron.status",
|
||||||
|
"cron.add",
|
||||||
|
"cron.update",
|
||||||
|
"cron.remove",
|
||||||
|
"cron.run",
|
||||||
|
"cron.runs",
|
||||||
|
"system-presence",
|
||||||
|
"system-event",
|
||||||
|
"send",
|
||||||
|
"agent",
|
||||||
|
"agent.wait",
|
||||||
|
// WebChat WebSocket-native chat methods
|
||||||
|
"chat.history",
|
||||||
|
"chat.abort",
|
||||||
|
"chat.send",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHANNEL_METHODS = listChannelPlugins().flatMap(
|
||||||
|
(plugin) => plugin.gatewayMethods ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GATEWAY_METHODS = Array.from(
|
||||||
|
new Set([...BASE_METHODS, ...CHANNEL_METHODS]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GATEWAY_EVENTS = [
|
||||||
|
"agent",
|
||||||
|
"chat",
|
||||||
|
"presence",
|
||||||
|
"tick",
|
||||||
|
"talk.mode",
|
||||||
|
"shutdown",
|
||||||
|
"health",
|
||||||
|
"heartbeat",
|
||||||
|
"cron",
|
||||||
|
"node.pair.requested",
|
||||||
|
"node.pair.resolved",
|
||||||
|
"voicewake.changed",
|
||||||
|
];
|
||||||
16
src/gateway/server-mobile-nodes.ts
Normal file
16
src/gateway/server-mobile-nodes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
type BridgeLike = {
|
||||||
|
listConnected?: () => Array<{ platform?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobilePlatform = (platform: unknown): boolean => {
|
||||||
|
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
|
||||||
|
if (!p) return false;
|
||||||
|
return (
|
||||||
|
p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean {
|
||||||
|
const connected = bridge?.listConnected?.() ?? [];
|
||||||
|
return connected.some((n) => isMobilePlatform(n.platform));
|
||||||
|
}
|
||||||
19
src/gateway/server-model-catalog.ts
Normal file
19
src/gateway/server-model-catalog.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
loadModelCatalog,
|
||||||
|
type ModelCatalogEntry,
|
||||||
|
resetModelCatalogCacheForTest,
|
||||||
|
} from "../agents/model-catalog.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
export type GatewayModelChoice = ModelCatalogEntry;
|
||||||
|
|
||||||
|
// Test-only escape hatch: model catalog is cached at module scope for the
|
||||||
|
// process lifetime, which is fine for the real gateway daemon, but makes
|
||||||
|
// isolated unit tests harder. Keep this intentionally obscure.
|
||||||
|
export function __resetModelCatalogCacheForTest() {
|
||||||
|
resetModelCatalogCacheForTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
|
||||||
|
return await loadModelCatalog({ config: loadConfig() });
|
||||||
|
}
|
||||||
171
src/gateway/server-node-bridge.ts
Normal file
171
src/gateway/server-node-bridge.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
|
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
||||||
|
import {
|
||||||
|
listSystemPresence,
|
||||||
|
upsertPresence,
|
||||||
|
} from "../infra/system-presence.js";
|
||||||
|
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
|
||||||
|
import { isLoopbackAddress } from "./net.js";
|
||||||
|
import {
|
||||||
|
getHealthVersion,
|
||||||
|
getPresenceVersion,
|
||||||
|
incrementPresenceVersion,
|
||||||
|
} from "./server/health-state.js";
|
||||||
|
import type {
|
||||||
|
BridgeEvent,
|
||||||
|
BridgeRequest,
|
||||||
|
BridgeResponse,
|
||||||
|
} from "./server-bridge-types.js";
|
||||||
|
|
||||||
|
export type GatewayNodeBridgeRuntime = {
|
||||||
|
bridge: NodeBridgeServer | null;
|
||||||
|
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function startGatewayNodeBridge(params: {
|
||||||
|
bridgeEnabled: boolean;
|
||||||
|
bridgePort: number;
|
||||||
|
bridgeHost: string | null;
|
||||||
|
machineDisplayName: string;
|
||||||
|
canvasHostPort?: number;
|
||||||
|
canvasHostHost?: string;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
bridgeUnsubscribeAll: (nodeId: string) => void;
|
||||||
|
handleBridgeRequest: (
|
||||||
|
nodeId: string,
|
||||||
|
req: BridgeRequest,
|
||||||
|
) => Promise<BridgeResponse>;
|
||||||
|
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
|
||||||
|
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}): Promise<GatewayNodeBridgeRuntime> {
|
||||||
|
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
|
const stopNodePresenceTimer = (nodeId: string) => {
|
||||||
|
const timer = nodePresenceTimers.get(nodeId);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
nodePresenceTimers.delete(nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beaconNodePresence = (
|
||||||
|
node: {
|
||||||
|
nodeId: string;
|
||||||
|
displayName?: string;
|
||||||
|
remoteIp?: string;
|
||||||
|
version?: string;
|
||||||
|
platform?: string;
|
||||||
|
deviceFamily?: string;
|
||||||
|
modelIdentifier?: string;
|
||||||
|
},
|
||||||
|
reason: string,
|
||||||
|
) => {
|
||||||
|
const host = node.displayName?.trim() || node.nodeId;
|
||||||
|
const rawIp = node.remoteIp?.trim();
|
||||||
|
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
|
||||||
|
const version = node.version?.trim() || "unknown";
|
||||||
|
const platform = node.platform?.trim() || undefined;
|
||||||
|
const deviceFamily = node.deviceFamily?.trim() || undefined;
|
||||||
|
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
|
||||||
|
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`;
|
||||||
|
upsertPresence(node.nodeId, {
|
||||||
|
host,
|
||||||
|
ip,
|
||||||
|
version,
|
||||||
|
platform,
|
||||||
|
deviceFamily,
|
||||||
|
modelIdentifier,
|
||||||
|
mode: "remote",
|
||||||
|
reason,
|
||||||
|
lastInputSeconds: 0,
|
||||||
|
instanceId: node.nodeId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
incrementPresenceVersion();
|
||||||
|
params.broadcast(
|
||||||
|
"presence",
|
||||||
|
{ presence: listSystemPresence() },
|
||||||
|
{
|
||||||
|
dropIfSlow: true,
|
||||||
|
stateVersion: {
|
||||||
|
presence: getPresenceVersion(),
|
||||||
|
health: getHealthVersion(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNodePresenceTimer = (node: { nodeId: string }) => {
|
||||||
|
stopNodePresenceTimer(node.nodeId);
|
||||||
|
nodePresenceTimers.set(
|
||||||
|
node.nodeId,
|
||||||
|
setInterval(() => {
|
||||||
|
beaconNodePresence(node, "periodic");
|
||||||
|
}, 180_000),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) {
|
||||||
|
try {
|
||||||
|
const started = await startNodeBridgeServer({
|
||||||
|
host: params.bridgeHost,
|
||||||
|
port: params.bridgePort,
|
||||||
|
serverName: params.machineDisplayName,
|
||||||
|
canvasHostPort: params.canvasHostPort,
|
||||||
|
canvasHostHost: params.canvasHostHost,
|
||||||
|
onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req),
|
||||||
|
onAuthenticated: async (node) => {
|
||||||
|
beaconNodePresence(node, "node-connected");
|
||||||
|
startNodePresenceTimer(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
started.sendEvent({
|
||||||
|
nodeId: node.nodeId,
|
||||||
|
event: "voicewake.changed",
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnected: (node) => {
|
||||||
|
params.bridgeUnsubscribeAll(node.nodeId);
|
||||||
|
stopNodePresenceTimer(node.nodeId);
|
||||||
|
beaconNodePresence(node, "node-disconnected");
|
||||||
|
},
|
||||||
|
onEvent: params.handleBridgeEvent,
|
||||||
|
onPairRequested: (request) => {
|
||||||
|
params.broadcast("node.pair.requested", request, {
|
||||||
|
dropIfSlow: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (started.port > 0) {
|
||||||
|
params.logBridge.info(
|
||||||
|
`listening on tcp://${params.bridgeHost}:${started.port} (node)`,
|
||||||
|
);
|
||||||
|
return { bridge: started, nodePresenceTimers };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logBridge.warn(`failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
params.bridgeEnabled &&
|
||||||
|
params.bridgePort > 0 &&
|
||||||
|
!params.bridgeHost
|
||||||
|
) {
|
||||||
|
params.logBridge.warn(
|
||||||
|
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bridge: null, nodePresenceTimers };
|
||||||
|
}
|
||||||
42
src/gateway/server-plugins.ts
Normal file
42
src/gateway/server-plugins.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { loadConfig } from "../config/config.js";
|
||||||
|
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||||
|
import type { GatewayRequestHandler } from "./server-methods/types.js";
|
||||||
|
|
||||||
|
export function loadGatewayPlugins(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
workspaceDir: string;
|
||||||
|
log: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
debug: (msg: string) => void;
|
||||||
|
};
|
||||||
|
coreGatewayHandlers: Record<string, GatewayRequestHandler>;
|
||||||
|
baseMethods: string[];
|
||||||
|
}) {
|
||||||
|
const pluginRegistry = loadClawdbotPlugins({
|
||||||
|
config: params.cfg,
|
||||||
|
workspaceDir: params.workspaceDir,
|
||||||
|
logger: {
|
||||||
|
info: (msg) => params.log.info(msg),
|
||||||
|
warn: (msg) => params.log.warn(msg),
|
||||||
|
error: (msg) => params.log.error(msg),
|
||||||
|
debug: (msg) => params.log.debug(msg),
|
||||||
|
},
|
||||||
|
coreGatewayHandlers: params.coreGatewayHandlers,
|
||||||
|
});
|
||||||
|
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
|
||||||
|
const gatewayMethods = Array.from(
|
||||||
|
new Set([...params.baseMethods, ...pluginMethods]),
|
||||||
|
);
|
||||||
|
if (pluginRegistry.diagnostics.length > 0) {
|
||||||
|
for (const diag of pluginRegistry.diagnostics) {
|
||||||
|
if (diag.level === "error") {
|
||||||
|
params.log.warn(`[plugins] ${diag.message}`);
|
||||||
|
} else {
|
||||||
|
params.log.info(`[plugins] ${diag.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pluginRegistry, gatewayMethods };
|
||||||
|
}
|
||||||
178
src/gateway/server-reload-handlers.ts
Normal file
178
src/gateway/server-reload-handlers.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { loadConfig } from "../config/config.js";
|
||||||
|
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
|
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
|
import { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||||
|
import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
|
||||||
|
import { resolveHooksConfig } from "./hooks.js";
|
||||||
|
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||||
|
import {
|
||||||
|
buildGatewayCronService,
|
||||||
|
type GatewayCronState,
|
||||||
|
} from "./server-cron.js";
|
||||||
|
|
||||||
|
type GatewayHotReloadState = {
|
||||||
|
hooksConfig: ReturnType<typeof resolveHooksConfig>;
|
||||||
|
heartbeatRunner: { stop: () => void };
|
||||||
|
cronState: GatewayCronState;
|
||||||
|
browserControl: Awaited<
|
||||||
|
ReturnType<typeof startBrowserControlServerIfEnabled>
|
||||||
|
> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createGatewayReloadHandlers(params: {
|
||||||
|
deps: CliDeps;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: { dropIfSlow?: boolean },
|
||||||
|
) => void;
|
||||||
|
getState: () => GatewayHotReloadState;
|
||||||
|
setState: (state: GatewayHotReloadState) => void;
|
||||||
|
startChannel: (name: ChannelKind) => Promise<void>;
|
||||||
|
stopChannel: (name: ChannelKind) => Promise<void>;
|
||||||
|
logHooks: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
};
|
||||||
|
logBrowser: { error: (msg: string) => void };
|
||||||
|
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
|
||||||
|
logCron: { error: (msg: string) => void };
|
||||||
|
logReload: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}) {
|
||||||
|
const applyHotReload = async (
|
||||||
|
plan: GatewayReloadPlan,
|
||||||
|
nextConfig: ReturnType<typeof loadConfig>,
|
||||||
|
) => {
|
||||||
|
const state = params.getState();
|
||||||
|
const nextState = { ...state };
|
||||||
|
|
||||||
|
if (plan.reloadHooks) {
|
||||||
|
try {
|
||||||
|
nextState.hooksConfig = resolveHooksConfig(nextConfig);
|
||||||
|
} catch (err) {
|
||||||
|
params.logHooks.warn(`hooks config reload failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartHeartbeat) {
|
||||||
|
state.heartbeatRunner.stop();
|
||||||
|
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartCron) {
|
||||||
|
state.cronState.cron.stop();
|
||||||
|
nextState.cronState = buildGatewayCronService({
|
||||||
|
cfg: nextConfig,
|
||||||
|
deps: params.deps,
|
||||||
|
broadcast: params.broadcast,
|
||||||
|
});
|
||||||
|
void nextState.cronState.cron
|
||||||
|
.start()
|
||||||
|
.catch((err) =>
|
||||||
|
params.logCron.error(`failed to start: ${String(err)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartBrowserControl) {
|
||||||
|
if (state.browserControl) {
|
||||||
|
await state.browserControl.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nextState.browserControl = await startBrowserControlServerIfEnabled();
|
||||||
|
} catch (err) {
|
||||||
|
params.logBrowser.error(`server failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartGmailWatcher) {
|
||||||
|
await stopGmailWatcher().catch(() => {});
|
||||||
|
if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") {
|
||||||
|
try {
|
||||||
|
const gmailResult = await startGmailWatcher(nextConfig);
|
||||||
|
if (gmailResult.started) {
|
||||||
|
params.logHooks.info("gmail watcher started");
|
||||||
|
} else if (
|
||||||
|
gmailResult.reason &&
|
||||||
|
gmailResult.reason !== "hooks not enabled" &&
|
||||||
|
gmailResult.reason !== "no gmail account configured"
|
||||||
|
) {
|
||||||
|
params.logHooks.warn(
|
||||||
|
`gmail watcher not started: ${gmailResult.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logHooks.error(
|
||||||
|
`gmail watcher failed to start: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.logHooks.info(
|
||||||
|
"skipping gmail watcher restart (CLAWDBOT_SKIP_GMAIL_WATCHER=1)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartChannels.size > 0) {
|
||||||
|
if (
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
|
||||||
|
process.env.CLAWDBOT_SKIP_PROVIDERS === "1"
|
||||||
|
) {
|
||||||
|
params.logChannels.info(
|
||||||
|
"skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const restartChannel = async (name: ChannelKind) => {
|
||||||
|
params.logChannels.info(`restarting ${name} channel`);
|
||||||
|
await params.stopChannel(name);
|
||||||
|
await params.startChannel(name);
|
||||||
|
};
|
||||||
|
for (const channel of plan.restartChannels) {
|
||||||
|
await restartChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
|
||||||
|
setCommandLaneConcurrency(
|
||||||
|
"main",
|
||||||
|
nextConfig.agents?.defaults?.maxConcurrent ?? 1,
|
||||||
|
);
|
||||||
|
setCommandLaneConcurrency(
|
||||||
|
"subagent",
|
||||||
|
nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (plan.hotReasons.length > 0) {
|
||||||
|
params.logReload.info(
|
||||||
|
`config hot reload applied (${plan.hotReasons.join(", ")})`,
|
||||||
|
);
|
||||||
|
} else if (plan.noopPaths.length > 0) {
|
||||||
|
params.logReload.info(
|
||||||
|
`config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setState(nextState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestGatewayRestart = (
|
||||||
|
plan: GatewayReloadPlan,
|
||||||
|
_nextConfig: ReturnType<typeof loadConfig>,
|
||||||
|
) => {
|
||||||
|
const reasons = plan.restartReasons.length
|
||||||
|
? plan.restartReasons.join(", ")
|
||||||
|
: plan.changedPaths.join(", ");
|
||||||
|
params.logReload.warn(
|
||||||
|
`config change requires gateway restart (${reasons})`,
|
||||||
|
);
|
||||||
|
if (process.listenerCount("SIGUSR1") === 0) {
|
||||||
|
params.logReload.warn("no SIGUSR1 listener found; restart skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.emit("SIGUSR1");
|
||||||
|
};
|
||||||
|
|
||||||
|
return { applyHotReload, requestGatewayRestart };
|
||||||
|
}
|
||||||
75
src/gateway/server-restart-sentinel.ts
Normal file
75
src/gateway/server-restart-sentinel.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
|
||||||
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import { agentCommand } from "../commands/agent.js";
|
||||||
|
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
||||||
|
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||||
|
import {
|
||||||
|
consumeRestartSentinel,
|
||||||
|
formatRestartSentinelMessage,
|
||||||
|
summarizeRestartSentinel,
|
||||||
|
} from "../infra/restart-sentinel.js";
|
||||||
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
|
|
||||||
|
export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||||
|
const sentinel = await consumeRestartSentinel();
|
||||||
|
if (!sentinel) return;
|
||||||
|
const payload = sentinel.payload;
|
||||||
|
const sessionKey = payload.sessionKey?.trim();
|
||||||
|
const message = formatRestartSentinelMessage(payload);
|
||||||
|
const summary = summarizeRestartSentinel(payload);
|
||||||
|
|
||||||
|
if (!sessionKey) {
|
||||||
|
const mainSessionKey = resolveMainSessionKeyFromConfig();
|
||||||
|
enqueueSystemEvent(message, { sessionKey: mainSessionKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||||
|
const lastChannel = entry?.lastChannel;
|
||||||
|
const lastTo = entry?.lastTo?.trim();
|
||||||
|
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
|
||||||
|
const channelRaw = lastChannel ?? parsedTarget?.channel;
|
||||||
|
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
||||||
|
const to = lastTo || parsedTarget?.to;
|
||||||
|
if (!channel || !to) {
|
||||||
|
enqueueSystemEvent(message, { sessionKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveOutboundTarget({
|
||||||
|
channel,
|
||||||
|
to,
|
||||||
|
cfg,
|
||||||
|
accountId: parsedTarget?.accountId ?? entry?.lastAccountId,
|
||||||
|
mode: "implicit",
|
||||||
|
});
|
||||||
|
if (!resolved.ok) {
|
||||||
|
enqueueSystemEvent(message, { sessionKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agentCommand(
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
sessionKey,
|
||||||
|
to: resolved.to,
|
||||||
|
channel,
|
||||||
|
deliver: true,
|
||||||
|
bestEffortDeliver: true,
|
||||||
|
messageChannel: channel,
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
params.deps,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
enqueueSystemEvent(`${summary}\n${String(err)}`, { sessionKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldWakeFromRestartSentinel() {
|
||||||
|
return !process.env.VITEST && process.env.NODE_ENV !== "test";
|
||||||
|
}
|
||||||
105
src/gateway/server-runtime-config.ts
Normal file
105
src/gateway/server-runtime-config.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type {
|
||||||
|
BridgeBindMode,
|
||||||
|
GatewayAuthConfig,
|
||||||
|
GatewayTailscaleConfig,
|
||||||
|
loadConfig,
|
||||||
|
} from "../config/config.js";
|
||||||
|
import {
|
||||||
|
assertGatewayAuthConfigured,
|
||||||
|
type ResolvedGatewayAuth,
|
||||||
|
resolveGatewayAuth,
|
||||||
|
} from "./auth.js";
|
||||||
|
import { normalizeControlUiBasePath } from "./control-ui.js";
|
||||||
|
import { resolveHooksConfig } from "./hooks.js";
|
||||||
|
import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";
|
||||||
|
|
||||||
|
export type GatewayRuntimeConfig = {
|
||||||
|
bindHost: string;
|
||||||
|
controlUiEnabled: boolean;
|
||||||
|
openAiChatCompletionsEnabled: boolean;
|
||||||
|
controlUiBasePath: string;
|
||||||
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
|
authMode: ResolvedGatewayAuth["mode"];
|
||||||
|
tailscaleConfig: GatewayTailscaleConfig;
|
||||||
|
tailscaleMode: "off" | "serve" | "funnel";
|
||||||
|
hooksConfig: ReturnType<typeof resolveHooksConfig>;
|
||||||
|
canvasHostEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveGatewayRuntimeConfig(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
port: number;
|
||||||
|
bind?: BridgeBindMode;
|
||||||
|
host?: string;
|
||||||
|
controlUiEnabled?: boolean;
|
||||||
|
openAiChatCompletionsEnabled?: boolean;
|
||||||
|
auth?: GatewayAuthConfig;
|
||||||
|
tailscale?: GatewayTailscaleConfig;
|
||||||
|
}): Promise<GatewayRuntimeConfig> {
|
||||||
|
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
|
||||||
|
const customBindHost = params.cfg.gateway?.customBindHost;
|
||||||
|
const bindHost =
|
||||||
|
params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
|
||||||
|
const controlUiEnabled =
|
||||||
|
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
|
||||||
|
const openAiChatCompletionsEnabled =
|
||||||
|
params.openAiChatCompletionsEnabled ??
|
||||||
|
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
|
||||||
|
false;
|
||||||
|
const controlUiBasePath = normalizeControlUiBasePath(
|
||||||
|
params.cfg.gateway?.controlUi?.basePath,
|
||||||
|
);
|
||||||
|
const authBase = params.cfg.gateway?.auth ?? {};
|
||||||
|
const authOverrides = params.auth ?? {};
|
||||||
|
const authConfig = {
|
||||||
|
...authBase,
|
||||||
|
...authOverrides,
|
||||||
|
};
|
||||||
|
const tailscaleBase = params.cfg.gateway?.tailscale ?? {};
|
||||||
|
const tailscaleOverrides = params.tailscale ?? {};
|
||||||
|
const tailscaleConfig = {
|
||||||
|
...tailscaleBase,
|
||||||
|
...tailscaleOverrides,
|
||||||
|
};
|
||||||
|
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||||
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
|
authConfig,
|
||||||
|
env: process.env,
|
||||||
|
tailscaleMode,
|
||||||
|
});
|
||||||
|
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
|
||||||
|
const hooksConfig = resolveHooksConfig(params.cfg);
|
||||||
|
const canvasHostEnabled =
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" &&
|
||||||
|
params.cfg.canvasHost?.enabled !== false;
|
||||||
|
|
||||||
|
assertGatewayAuthConfigured(resolvedAuth);
|
||||||
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||||
|
throw new Error(
|
||||||
|
"tailscale funnel requires gateway auth mode=password (set gateway.auth.password or CLAWDBOT_GATEWAY_PASSWORD)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
|
||||||
|
throw new Error(
|
||||||
|
"tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isLoopbackHost(bindHost) && authMode === "none") {
|
||||||
|
throw new Error(
|
||||||
|
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bindHost,
|
||||||
|
controlUiEnabled,
|
||||||
|
openAiChatCompletionsEnabled,
|
||||||
|
controlUiBasePath,
|
||||||
|
resolvedAuth,
|
||||||
|
authMode,
|
||||||
|
tailscaleConfig,
|
||||||
|
tailscaleMode,
|
||||||
|
hooksConfig,
|
||||||
|
canvasHostEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
146
src/gateway/server-runtime-state.ts
Normal file
146
src/gateway/server-runtime-state.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Server as HttpServer } from "node:http";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
|
||||||
|
import {
|
||||||
|
type CanvasHostHandler,
|
||||||
|
createCanvasHostHandler,
|
||||||
|
} from "../canvas-host/server.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { createSubsystemLogger } from "../logging.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
|
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||||
|
import type { HooksConfigResolved } from "./hooks.js";
|
||||||
|
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||||
|
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||||
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
|
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||||
|
import { type ChatRunEntry, createChatRunState } from "./server-chat.js";
|
||||||
|
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
|
||||||
|
import {
|
||||||
|
attachGatewayUpgradeHandler,
|
||||||
|
createGatewayHttpServer,
|
||||||
|
} from "./server-http.js";
|
||||||
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
|
|
||||||
|
export async function createGatewayRuntimeState(params: {
|
||||||
|
cfg: {
|
||||||
|
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
|
||||||
|
};
|
||||||
|
bindHost: string;
|
||||||
|
port: number;
|
||||||
|
controlUiEnabled: boolean;
|
||||||
|
controlUiBasePath: string;
|
||||||
|
openAiChatCompletionsEnabled: boolean;
|
||||||
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
|
hooksConfig: () => HooksConfigResolved | null;
|
||||||
|
deps: CliDeps;
|
||||||
|
canvasRuntime: RuntimeEnv;
|
||||||
|
canvasHostEnabled: boolean;
|
||||||
|
allowCanvasHostInTests?: boolean;
|
||||||
|
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
logHooks: ReturnType<typeof createSubsystemLogger>;
|
||||||
|
}): Promise<{
|
||||||
|
canvasHost: CanvasHostHandler | null;
|
||||||
|
httpServer: HttpServer;
|
||||||
|
wss: WebSocketServer;
|
||||||
|
clients: Set<GatewayWsClient>;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
agentRunSeq: Map<string, number>;
|
||||||
|
dedupe: Map<string, DedupeEntry>;
|
||||||
|
chatRunState: ReturnType<typeof createChatRunState>;
|
||||||
|
chatRunBuffers: Map<string, string>;
|
||||||
|
chatDeltaSentAt: Map<string, number>;
|
||||||
|
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||||
|
removeChatRun: (
|
||||||
|
sessionId: string,
|
||||||
|
clientRunId: string,
|
||||||
|
sessionKey?: string,
|
||||||
|
) => ChatRunEntry | undefined;
|
||||||
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||||
|
}> {
|
||||||
|
let canvasHost: CanvasHostHandler | null = null;
|
||||||
|
if (params.canvasHostEnabled) {
|
||||||
|
try {
|
||||||
|
const handler = await createCanvasHostHandler({
|
||||||
|
runtime: params.canvasRuntime,
|
||||||
|
rootDir: params.cfg.canvasHost?.root,
|
||||||
|
basePath: CANVAS_HOST_PATH,
|
||||||
|
allowInTests: params.allowCanvasHostInTests,
|
||||||
|
liveReload: params.cfg.canvasHost?.liveReload,
|
||||||
|
});
|
||||||
|
if (handler.rootDir) {
|
||||||
|
canvasHost = handler;
|
||||||
|
params.logCanvas.info(
|
||||||
|
`canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logCanvas.warn(`canvas host failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHooksRequest = createGatewayHooksRequestHandler({
|
||||||
|
deps: params.deps,
|
||||||
|
getHooksConfig: params.hooksConfig,
|
||||||
|
bindHost: params.bindHost,
|
||||||
|
port: params.port,
|
||||||
|
logHooks: params.logHooks,
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpServer = createGatewayHttpServer({
|
||||||
|
canvasHost,
|
||||||
|
controlUiEnabled: params.controlUiEnabled,
|
||||||
|
controlUiBasePath: params.controlUiBasePath,
|
||||||
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||||
|
handleHooksRequest,
|
||||||
|
resolvedAuth: params.resolvedAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
await listenGatewayHttpServer({
|
||||||
|
httpServer,
|
||||||
|
bindHost: params.bindHost,
|
||||||
|
port: params.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({
|
||||||
|
noServer: true,
|
||||||
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
|
});
|
||||||
|
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
|
||||||
|
|
||||||
|
const clients = new Set<GatewayWsClient>();
|
||||||
|
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||||
|
const agentRunSeq = new Map<string, number>();
|
||||||
|
const dedupe = new Map<string, DedupeEntry>();
|
||||||
|
const chatRunState = createChatRunState();
|
||||||
|
const chatRunRegistry = chatRunState.registry;
|
||||||
|
const chatRunBuffers = chatRunState.buffers;
|
||||||
|
const chatDeltaSentAt = chatRunState.deltaSentAt;
|
||||||
|
const addChatRun = chatRunRegistry.add;
|
||||||
|
const removeChatRun = chatRunRegistry.remove;
|
||||||
|
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasHost,
|
||||||
|
httpServer,
|
||||||
|
wss,
|
||||||
|
clients,
|
||||||
|
broadcast,
|
||||||
|
agentRunSeq,
|
||||||
|
dedupe,
|
||||||
|
chatRunState,
|
||||||
|
chatRunBuffers,
|
||||||
|
chatDeltaSentAt,
|
||||||
|
addChatRun,
|
||||||
|
removeChatRun,
|
||||||
|
chatAbortControllers,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/gateway/server-session-key.ts
Normal file
22
src/gateway/server-session-key.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
|
import {
|
||||||
|
getAgentRunContext,
|
||||||
|
registerAgentRunContext,
|
||||||
|
} from "../infra/agent-events.js";
|
||||||
|
|
||||||
|
export function resolveSessionKeyForRun(runId: string) {
|
||||||
|
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||||
|
if (cached) return cached;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const found = Object.entries(store).find(
|
||||||
|
([, entry]) => entry?.sessionId === runId,
|
||||||
|
);
|
||||||
|
const sessionKey = found?.[0];
|
||||||
|
if (sessionKey) {
|
||||||
|
registerAgentRunContext(runId, { sessionKey });
|
||||||
|
}
|
||||||
|
return sessionKey;
|
||||||
|
}
|
||||||
31
src/gateway/server-startup-log.ts
Normal file
31
src/gateway/server-startup-log.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
|
import type { loadConfig } from "../config/config.js";
|
||||||
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
|
|
||||||
|
export function logGatewayStartup(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
bindHost: string;
|
||||||
|
port: number;
|
||||||
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||||
|
isNixMode: boolean;
|
||||||
|
}) {
|
||||||
|
const { provider: agentProvider, model: agentModel } =
|
||||||
|
resolveConfiguredModelRef({
|
||||||
|
cfg: params.cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
const modelRef = `${agentProvider}/${agentModel}`;
|
||||||
|
params.log.info(`agent model: ${modelRef}`, {
|
||||||
|
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
||||||
|
});
|
||||||
|
params.log.info(
|
||||||
|
`listening on ws://${params.bindHost}:${params.port} (PID ${process.pid})`,
|
||||||
|
);
|
||||||
|
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||||
|
if (params.isNixMode) {
|
||||||
|
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/gateway/server-startup.ts
Normal file
136
src/gateway/server-startup.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
|
import {
|
||||||
|
getModelRefStatus,
|
||||||
|
resolveConfiguredModelRef,
|
||||||
|
resolveHooksGmailModel,
|
||||||
|
} from "../agents/model-selection.js";
|
||||||
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
import type { loadConfig } from "../config/config.js";
|
||||||
|
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
|
import type { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||||
|
import {
|
||||||
|
type PluginServicesHandle,
|
||||||
|
startPluginServices,
|
||||||
|
} from "../plugins/services.js";
|
||||||
|
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||||
|
import {
|
||||||
|
scheduleRestartSentinelWake,
|
||||||
|
shouldWakeFromRestartSentinel,
|
||||||
|
} from "./server-restart-sentinel.js";
|
||||||
|
|
||||||
|
export async function startGatewaySidecars(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
pluginRegistry: ReturnType<typeof loadClawdbotPlugins>;
|
||||||
|
defaultWorkspaceDir: string;
|
||||||
|
deps: CliDeps;
|
||||||
|
startChannels: () => Promise<void>;
|
||||||
|
log: { warn: (msg: string) => void };
|
||||||
|
logHooks: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
};
|
||||||
|
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
|
||||||
|
logBrowser: { error: (msg: string) => void };
|
||||||
|
}) {
|
||||||
|
// Start clawd browser control server (unless disabled via config).
|
||||||
|
let browserControl: Awaited<
|
||||||
|
ReturnType<typeof startBrowserControlServerIfEnabled>
|
||||||
|
> = null;
|
||||||
|
try {
|
||||||
|
browserControl = await startBrowserControlServerIfEnabled();
|
||||||
|
} catch (err) {
|
||||||
|
params.logBrowser.error(`server failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Gmail watcher if configured (hooks.gmail.account).
|
||||||
|
if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") {
|
||||||
|
try {
|
||||||
|
const gmailResult = await startGmailWatcher(params.cfg);
|
||||||
|
if (gmailResult.started) {
|
||||||
|
params.logHooks.info("gmail watcher started");
|
||||||
|
} else if (
|
||||||
|
gmailResult.reason &&
|
||||||
|
gmailResult.reason !== "hooks not enabled" &&
|
||||||
|
gmailResult.reason !== "no gmail account configured"
|
||||||
|
) {
|
||||||
|
params.logHooks.warn(
|
||||||
|
`gmail watcher not started: ${gmailResult.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logHooks.error(`gmail watcher failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hooks.gmail.model if configured.
|
||||||
|
if (params.cfg.hooks?.gmail?.model) {
|
||||||
|
const hooksModelRef = resolveHooksGmailModel({
|
||||||
|
cfg: params.cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
if (hooksModelRef) {
|
||||||
|
const { provider: defaultProvider, model: defaultModel } =
|
||||||
|
resolveConfiguredModelRef({
|
||||||
|
cfg: params.cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||||
|
const status = getModelRefStatus({
|
||||||
|
cfg: params.cfg,
|
||||||
|
catalog,
|
||||||
|
ref: hooksModelRef,
|
||||||
|
defaultProvider,
|
||||||
|
defaultModel,
|
||||||
|
});
|
||||||
|
if (!status.allowed) {
|
||||||
|
params.logHooks.warn(
|
||||||
|
`hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!status.inCatalog) {
|
||||||
|
params.logHooks.warn(
|
||||||
|
`hooks.gmail.model "${status.key}" not in the model catalog (may fail at runtime)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch configured channels so gateway replies via the surface the message came from.
|
||||||
|
// Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS).
|
||||||
|
const skipChannels =
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
|
||||||
|
process.env.CLAWDBOT_SKIP_PROVIDERS === "1";
|
||||||
|
if (!skipChannels) {
|
||||||
|
try {
|
||||||
|
await params.startChannels();
|
||||||
|
} catch (err) {
|
||||||
|
params.logChannels.error(`channel startup failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.logChannels.info(
|
||||||
|
"skipping channel start (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginServices: PluginServicesHandle | null = null;
|
||||||
|
try {
|
||||||
|
pluginServices = await startPluginServices({
|
||||||
|
registry: params.pluginRegistry,
|
||||||
|
config: params.cfg,
|
||||||
|
workspaceDir: params.defaultWorkspaceDir,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
params.log.warn(`plugin services failed to start: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldWakeFromRestartSentinel()) {
|
||||||
|
setTimeout(() => {
|
||||||
|
void scheduleRestartSentinelWake({ deps: params.deps });
|
||||||
|
}, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { browserControl, pluginServices };
|
||||||
|
}
|
||||||
60
src/gateway/server-tailscale.ts
Normal file
60
src/gateway/server-tailscale.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
disableTailscaleFunnel,
|
||||||
|
disableTailscaleServe,
|
||||||
|
enableTailscaleFunnel,
|
||||||
|
enableTailscaleServe,
|
||||||
|
getTailnetHostname,
|
||||||
|
} from "../infra/tailscale.js";
|
||||||
|
|
||||||
|
export async function startGatewayTailscaleExposure(params: {
|
||||||
|
tailscaleMode: "off" | "serve" | "funnel";
|
||||||
|
resetOnExit?: boolean;
|
||||||
|
port: number;
|
||||||
|
controlUiBasePath?: string;
|
||||||
|
logTailscale: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}): Promise<(() => Promise<void>) | null> {
|
||||||
|
if (params.tailscaleMode === "off") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (params.tailscaleMode === "serve") {
|
||||||
|
await enableTailscaleServe(params.port);
|
||||||
|
} else {
|
||||||
|
await enableTailscaleFunnel(params.port);
|
||||||
|
}
|
||||||
|
const host = await getTailnetHostname().catch(() => null);
|
||||||
|
if (host) {
|
||||||
|
const uiPath = params.controlUiBasePath
|
||||||
|
? `${params.controlUiBasePath}/`
|
||||||
|
: "/";
|
||||||
|
params.logTailscale.info(
|
||||||
|
`${params.tailscaleMode} enabled: https://${host}${uiPath} (WS via wss://${host})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
params.logTailscale.info(`${params.tailscaleMode} enabled`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logTailscale.warn(
|
||||||
|
`${params.tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.resetOnExit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
if (params.tailscaleMode === "serve") {
|
||||||
|
await disableTailscaleServe();
|
||||||
|
} else {
|
||||||
|
await disableTailscaleFunnel();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
params.logTailscale.warn(
|
||||||
|
`${params.tailscaleMode} cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/gateway/server-wizard-sessions.ts
Normal file
21
src/gateway/server-wizard-sessions.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { WizardSession } from "../wizard/session.js";
|
||||||
|
|
||||||
|
export function createWizardSessionTracker() {
|
||||||
|
const wizardSessions = new Map<string, WizardSession>();
|
||||||
|
|
||||||
|
const findRunningWizard = (): string | null => {
|
||||||
|
for (const [id, session] of wizardSessions) {
|
||||||
|
if (session.getStatus() === "running") return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const purgeWizardSession = (id: string) => {
|
||||||
|
const session = wizardSessions.get(id);
|
||||||
|
if (!session) return;
|
||||||
|
if (session.getStatus() === "running") return;
|
||||||
|
wizardSessions.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { wizardSessions, findRunningWizard, purgeWizardSession };
|
||||||
|
}
|
||||||
52
src/gateway/server-ws-runtime.ts
Normal file
52
src/gateway/server-ws-runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { WebSocketServer } from "ws";
|
||||||
|
import type { createSubsystemLogger } from "../logging.js";
|
||||||
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
|
import { attachGatewayWsConnectionHandler } from "./server/ws-connection.js";
|
||||||
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
|
import type {
|
||||||
|
GatewayRequestContext,
|
||||||
|
GatewayRequestHandlers,
|
||||||
|
} from "./server-methods/types.js";
|
||||||
|
|
||||||
|
export function attachGatewayWsHandlers(params: {
|
||||||
|
wss: WebSocketServer;
|
||||||
|
clients: Set<GatewayWsClient>;
|
||||||
|
port: number;
|
||||||
|
bridgeHost?: string;
|
||||||
|
canvasHostEnabled: boolean;
|
||||||
|
canvasHostServerPort?: number;
|
||||||
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
|
gatewayMethods: string[];
|
||||||
|
events: string[];
|
||||||
|
logGateway: ReturnType<typeof createSubsystemLogger>;
|
||||||
|
logHealth: ReturnType<typeof createSubsystemLogger>;
|
||||||
|
logWsControl: ReturnType<typeof createSubsystemLogger>;
|
||||||
|
extraHandlers: GatewayRequestHandlers;
|
||||||
|
broadcast: (
|
||||||
|
event: string,
|
||||||
|
payload: unknown,
|
||||||
|
opts?: {
|
||||||
|
dropIfSlow?: boolean;
|
||||||
|
stateVersion?: { presence?: number; health?: number };
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
context: GatewayRequestContext;
|
||||||
|
}) {
|
||||||
|
attachGatewayWsConnectionHandler({
|
||||||
|
wss: params.wss,
|
||||||
|
clients: params.clients,
|
||||||
|
port: params.port,
|
||||||
|
bridgeHost: params.bridgeHost,
|
||||||
|
canvasHostEnabled: params.canvasHostEnabled,
|
||||||
|
canvasHostServerPort: params.canvasHostServerPort,
|
||||||
|
resolvedAuth: params.resolvedAuth,
|
||||||
|
gatewayMethods: params.gatewayMethods,
|
||||||
|
events: params.events,
|
||||||
|
logGateway: params.logGateway,
|
||||||
|
logHealth: params.logHealth,
|
||||||
|
logWsControl: params.logWsControl,
|
||||||
|
extraHandlers: params.extraHandlers,
|
||||||
|
broadcast: params.broadcast,
|
||||||
|
buildRequestContext: () => params.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user