mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:01:22 +00:00
feat: add gateway config/update restart flow
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
119
src/gateway/server-methods/update.ts
Normal file
119
src/gateway/server-methods/update.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
85
src/gateway/server.config-apply.test.ts
Normal file
85
src/gateway/server.config-apply.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
|
||||
72
src/gateway/server.update-run.test.ts
Normal file
72
src/gateway/server.update-run.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user