feat: add gateway config/update restart flow

This commit is contained in:
Peter Steinberger
2026-01-08 01:29:56 +01:00
parent 3398fc3820
commit 71c31266a1
28 changed files with 1630 additions and 50 deletions

View File

@@ -11,6 +11,8 @@ import {
ChatEventSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
type ConfigApplyParams,
ConfigApplyParamsSchema,
type ConfigGetParams,
ConfigGetParamsSchema,
type ConfigSchemaParams,
@@ -107,6 +109,8 @@ import {
TalkModeParamsSchema,
type TickEvent,
TickEventSchema,
type UpdateRunParams,
UpdateRunParamsSchema,
type WakeParams,
WakeParamsSchema,
type WebLoginStartParams,
@@ -202,6 +206,9 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(
ConfigApplyParamsSchema,
);
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(
ConfigSchemaParamsSchema,
);
@@ -257,6 +264,9 @@ export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
ChatAbortParamsSchema,
);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(
UpdateRunParamsSchema,
);
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
WebLoginStartParamsSchema,
);
@@ -302,6 +312,7 @@ export {
SessionsCompactParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ConfigApplyParamsSchema,
ConfigSchemaParamsSchema,
ConfigSchemaResponseSchema,
WizardStartParamsSchema,
@@ -329,6 +340,7 @@ export {
CronRunsParamsSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
UpdateRunParamsSchema,
TickEventSchema,
ShutdownEventSchema,
ProtocolSchemas,
@@ -359,6 +371,7 @@ export type {
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
ConfigApplyParams,
ConfigSchemaParams,
ConfigSchemaResponse,
WizardStartParams,
@@ -395,4 +408,5 @@ export type {
CronRunsParams,
CronRunLogEntry,
PollParams,
UpdateRunParams,
};

View File

@@ -374,11 +374,31 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ConfigApplyParamsSchema = Type.Object(
{
raw: NonEmptyString,
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const ConfigSchemaParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const UpdateRunParamsSchema = Type.Object(
{
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
export const ConfigUiHintSchema = Type.Object(
{
label: Type.Optional(Type.String()),
@@ -870,6 +890,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsCompactParams: SessionsCompactParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ConfigApplyParams: ConfigApplyParamsSchema,
ConfigSchemaParams: ConfigSchemaParamsSchema,
ConfigSchemaResponse: ConfigSchemaResponseSchema,
WizardStartParams: WizardStartParamsSchema,
@@ -903,6 +924,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,
ChatEvent: ChatEventSchema,
UpdateRunParams: UpdateRunParamsSchema,
TickEvent: TickEventSchema,
ShutdownEvent: ShutdownEventSchema,
};
@@ -939,6 +961,7 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>;
export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>;
export type WizardStartParams = Static<typeof WizardStartParamsSchema>;
@@ -970,6 +993,7 @@ export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
export type TickEvent = Static<typeof TickEventSchema>;
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;

View File

@@ -17,6 +17,7 @@ import type {
GatewayRequestHandlers,
GatewayRequestOptions,
} from "./server-methods/types.js";
import { updateHandlers } from "./server-methods/update.js";
import { usageHandlers } from "./server-methods/usage.js";
import { voicewakeHandlers } from "./server-methods/voicewake.js";
import { webHandlers } from "./server-methods/web.js";
@@ -37,6 +38,7 @@ const handlers: GatewayRequestHandlers = {
...skillsHandlers,
...sessionsHandlers,
...systemHandlers,
...updateHandlers,
...nodeHandlers,
...sendHandlers,
...usageHandlers,

View File

@@ -6,10 +6,16 @@ import {
writeConfigFile,
} from "../../config/config.js";
import { buildConfigSchema } from "../../config/schema.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import {
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateConfigApplyParams,
validateConfigGetParams,
validateConfigSchemaParams,
validateConfigSetParams,
@@ -102,4 +108,102 @@ export const configHandlers: GatewayRequestHandlers = {
undefined,
);
},
"config.apply": async ({ params, respond }) => {
if (!validateConfigApplyParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.apply params: ${formatValidationErrors(validateConfigApplyParams.errors)}`,
),
);
return;
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid config.apply params: raw (string) required",
),
);
return;
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
);
return;
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
details: { issues: validated.issues },
}),
);
return;
}
await writeConfigFile(validated.config);
const sessionKey =
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
? (params as { sessionKey?: string }).sessionKey?.trim() || undefined
: undefined;
const note =
typeof (params as { note?: unknown }).note === "string"
? (params as { note?: string }).note?.trim() || undefined
: undefined;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
.restartDelayMs;
const restartDelayMs =
typeof restartDelayMsRaw === "number" &&
Number.isFinite(restartDelayMsRaw)
? Math.max(0, Math.floor(restartDelayMsRaw))
: undefined;
const payload: RestartSentinelPayload = {
kind: "config-apply",
status: "ok",
ts: Date.now(),
sessionKey,
message: note ?? null,
stats: {
mode: "config.apply",
root: CONFIG_PATH_CLAWDBOT,
},
};
let sentinelPath: string | null = null;
try {
sentinelPath = await writeRestartSentinel(payload);
} catch {
sentinelPath = null;
}
const restart = scheduleGatewaySigusr1Restart({
delayMs: restartDelayMs,
reason: "config.apply",
});
respond(
true,
{
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
restart,
sentinel: {
path: sentinelPath,
payload,
},
},
undefined,
);
},
};

View File

@@ -0,0 +1,119 @@
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import {
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { runGatewayUpdate } from "../../infra/update-runner.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateUpdateRunParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export const updateHandlers: GatewayRequestHandlers = {
"update.run": async ({ params, respond }) => {
if (!validateUpdateRunParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid update.run params: ${formatValidationErrors(validateUpdateRunParams.errors)}`,
),
);
return;
}
const sessionKey =
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
? (params as { sessionKey?: string }).sessionKey?.trim() || undefined
: undefined;
const note =
typeof (params as { note?: unknown }).note === "string"
? (params as { note?: string }).note?.trim() || undefined
: undefined;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
.restartDelayMs;
const restartDelayMs =
typeof restartDelayMsRaw === "number" &&
Number.isFinite(restartDelayMsRaw)
? Math.max(0, Math.floor(restartDelayMsRaw))
: undefined;
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;
const timeoutMs =
typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw)
? Math.max(1000, Math.floor(timeoutMsRaw))
: undefined;
let result: Awaited<ReturnType<typeof runGatewayUpdate>>;
try {
result = await runGatewayUpdate({
timeoutMs,
cwd: process.cwd(),
argv1: process.argv[1],
});
} catch (err) {
result = {
status: "error",
mode: "unknown",
reason: String(err),
steps: [],
durationMs: 0,
};
}
const payload: RestartSentinelPayload = {
kind: "update",
status: result.status,
ts: Date.now(),
sessionKey,
message: note ?? null,
stats: {
mode: result.mode,
root: result.root ?? undefined,
before: result.before ?? null,
after: result.after ?? null,
steps: result.steps.map((step) => ({
name: step.name,
command: step.command,
cwd: step.cwd,
durationMs: step.durationMs,
log: {
stdoutTail: step.stdoutTail ?? null,
stderrTail: step.stderrTail ?? null,
exitCode: step.exitCode ?? null,
},
})),
reason: result.reason ?? null,
durationMs: result.durationMs,
},
};
let sentinelPath: string | null = null;
try {
sentinelPath = await writeRestartSentinel(payload);
} catch {
sentinelPath = null;
}
const restart = scheduleGatewaySigusr1Restart({
delayMs: restartDelayMs,
reason: "update.run",
});
respond(
true,
{
ok: true,
result,
restart,
sentinel: {
path: sentinelPath,
payload,
},
},
undefined,
);
},
};

View File

@@ -0,0 +1,85 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
connectOk,
installGatewayTestHooks,
onceMessage,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => {
vi.useFakeTimers();
const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agent": { "workspace": "~/clawd" } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
await vi.advanceTimersByTimeAsync(0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(
os.homedir(),
".clawdbot",
"restart-sentinel.json",
);
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
ws.close();
await server.close();
process.off("SIGUSR1", sigusr1);
vi.useRealTimers();
});
it("rejects invalid raw config", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const id = "req-2";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: "{",
},
}),
);
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
ws.close();
await server.close();
});
});

View File

@@ -10,6 +10,7 @@ import {
resetModelCatalogCacheForTest,
} from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import {
type CanvasHostHandler,
@@ -18,6 +19,7 @@ import {
startCanvasHost,
} from "../canvas-host/server.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
import {
CONFIG_PATH_CLAWDBOT,
@@ -57,7 +59,13 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import {
consumeRestartSentinel,
formatRestartSentinelMessage,
summarizeRestartSentinel,
} from "../infra/restart-sentinel.js";
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
@@ -88,6 +96,7 @@ import {
runtimeForLogger,
} from "../logging.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { runOnboardingWizard } from "../wizard/onboarding.js";
import type { WizardSession } from "../wizard/session.js";
import {
@@ -107,6 +116,18 @@ import {
isLoopbackHost,
resolveGatewayBindHost,
} from "./net.js";
import {
type ConnectParams,
ErrorCodes,
type ErrorShape,
errorShape,
formatValidationErrors,
PROTOCOL_VERSION,
type RequestFrame,
type Snapshot,
validateConnectParams,
validateRequestFrame,
} from "./protocol/index.js";
import { createBridgeHandlers } from "./server-bridge.js";
import {
type BridgeListConnectedFn,
@@ -138,6 +159,7 @@ import { handleGatewayRequest } from "./server-methods.js";
import { createProviderManager } from "./server-providers.js";
import type { DedupeEntry } from "./server-shared.js";
import { formatError } from "./server-utils.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
ensureClawdbotCliOnPath();
@@ -181,19 +203,6 @@ async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
return await loadModelCatalog({ config: loadConfig() });
}
import {
type ConnectParams,
ErrorCodes,
type ErrorShape,
errorShape,
formatValidationErrors,
PROTOCOL_VERSION,
type RequestFrame,
type Snapshot,
validateConnectParams,
validateRequestFrame,
} from "./protocol/index.js";
type Client = {
socket: WebSocket;
connect: ConnectParams;
@@ -208,6 +217,7 @@ const METHODS = [
"usage.status",
"config.get",
"config.set",
"config.apply",
"config.schema",
"wizard.start",
"wizard.next",
@@ -218,6 +228,7 @@ const METHODS = [
"skills.status",
"skills.install",
"skills.update",
"update.run",
"voicewake.get",
"voicewake.set",
"sessions.list",
@@ -1650,6 +1661,77 @@ export async function startGatewayServer(
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)");
}
const scheduleRestartSentinelWake = async () => {
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) {
enqueueSystemEvent(message);
return;
}
const { cfg, entry } = loadSessionEntry(sessionKey);
const lastProvider =
entry?.lastProvider && entry.lastProvider !== "webchat"
? entry.lastProvider
: undefined;
const lastTo = entry?.lastTo?.trim();
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
const provider = lastProvider ?? parsedTarget?.provider;
const to = lastTo || parsedTarget?.to;
if (!provider || !to) {
enqueueSystemEvent(message);
return;
}
const resolved = resolveOutboundTarget({
provider: provider as
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "webchat",
to,
allowFrom: cfg.whatsapp?.allowFrom ?? [],
});
if (!resolved.ok) {
enqueueSystemEvent(message);
return;
}
try {
await agentCommand(
{
message,
sessionKey,
to: resolved.to,
provider,
deliver: true,
bestEffortDeliver: true,
messageProvider: provider,
},
defaultRuntime,
deps,
);
} catch (err) {
enqueueSystemEvent(`${summary}\n${String(err)}`);
}
};
const shouldWakeFromSentinel =
!process.env.VITEST && process.env.NODE_ENV !== "test";
if (shouldWakeFromSentinel) {
setTimeout(() => {
void scheduleRestartSentinelWake();
}, 750);
}
const applyHotReload = async (
plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>,

View File

@@ -0,0 +1,72 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(async () => ({
status: "ok",
mode: "git",
root: "/repo",
steps: [],
durationMs: 12,
})),
}));
import {
connectOk,
installGatewayTestHooks,
onceMessage,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway update.run", () => {
it("writes sentinel and schedules restart", async () => {
vi.useFakeTimers();
const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const id = "req-update";
ws.send(
JSON.stringify({
type: "req",
id,
method: "update.run",
params: {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
await vi.advanceTimersByTimeAsync(0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(
os.homedir(),
".clawdbot",
"restart-sentinel.json",
);
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as {
payload?: { kind?: string; stats?: { mode?: string } };
};
expect(parsed.payload?.kind).toBe("update");
expect(parsed.payload?.stats?.mode).toBe("git");
ws.close();
await server.close();
process.off("SIGUSR1", sigusr1);
vi.useRealTimers();
});
});