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

@@ -401,6 +401,10 @@ Subcommands:
- `gateway restart`
- `gateway daemon status` (alias for `clawdbot daemon status`)
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake)
## Models
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.

View File

@@ -23,6 +23,26 @@ The Control UI renders a form from this schema, with a **Raw JSON** editor as an
Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
better forms without hard-coding config knowledge.
## Apply + restart (RPC)
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
It writes a restart sentinel and pings the last active session after the Gateway comes back.
Params:
- `raw` (string) — JSON5 payload for the entire config
- `sessionKey` (optional) — last active session key for the wake-up ping
- `restartDelayMs` (optional) — delay before restart (default 2000)
Example (via `gateway call`):
```bash
clawdbot gateway call config.apply --params '{
"raw": "{\\n agent: { workspace: \\"~/clawd\\" }\\n}\\n",
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000
}'
```
## Minimal config (recommended starting point)
```json5

View File

@@ -42,6 +42,15 @@ Notes:
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs.
- If youre pinned to a specific version, see “Rollback / pinning” below.
## Update (Control UI / RPC)
The Control UI has **Update & Restart** (RPC: `update.run`). It:
1) Runs a git update (clean rebase) or package manager update.
2) Writes a restart sentinel with a structured report (stdout/stderr tail).
3) Restarts the gateway and pings the last active session with the report.
If the rebase fails, the gateway aborts and restarts without applying the update.
## Update (from source)
From the repo checkout:

View File

@@ -159,10 +159,13 @@ Notes:
- `update` uses `{ id, patch }`.
### `gateway`
Restart the running Gateway process (in-place).
Restart or apply updates to the running Gateway process (in-place).
Core actions:
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway`/`gateway-daemon` restart in-place)
- `config.get` / `config.schema`
- `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake)
Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.

View File

@@ -36,8 +36,10 @@ The dashboard settings panel lets you store a token; passwords are not persisted
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
- Update: run a package/git update + restart (`update.run`) with a restart report
## Tailnet access (recommended)

View File

@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(async () => ({ ok: true })),
}));
describe("gateway tool", () => {
it("schedules SIGUSR1 restart", async () => {
vi.useFakeTimers();
@@ -33,4 +37,51 @@ describe("gateway tool", () => {
vi.useRealTimers();
}
});
it("passes config.apply through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createClawdbotTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n';
await tool.execute("call2", {
action: "config.apply",
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.apply",
expect.any(Object),
expect.objectContaining({
raw: raw.trim(),
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
});
it("passes update.run through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createClawdbotTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
await tool.execute("call3", {
action: "update.run",
note: "test update",
});
expect(callGatewayTool).toHaveBeenCalledWith(
"update.run",
expect.any(Object),
expect.objectContaining({
note: "test update",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
});
});

View File

@@ -36,7 +36,7 @@ export function createClawdbotTools(options?: {
createSlackTool(),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,

View File

@@ -1,6 +1,8 @@
import { Type } from "@sinclair/typebox";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
const GatewayToolSchema = Type.Union([
Type.Object({
@@ -8,46 +10,137 @@ const GatewayToolSchema = Type.Union([
delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("config.get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("config.schema"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("config.apply"),
raw: Type.String(),
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("update.run"),
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()),
timeoutMs: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
}),
]);
export function createGatewayTool(): AnyAgentTool {
export function createGatewayTool(opts?: {
agentSessionKey?: string;
}): AnyAgentTool {
return {
label: "Gateway",
name: "gateway",
description:
"Restart the running gateway process in-place (SIGUSR1) without needing an external supervisor. Use delayMs to avoid interrupting an in-flight reply.",
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.apply/update.run to write config or run updates with validation and restart.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action !== "restart") throw new Error(`Unknown action: ${action}`);
if (action === "restart") {
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
: undefined;
const reason =
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
const scheduled = scheduleGatewaySigusr1Restart({
delayMs,
reason,
});
return jsonResult(scheduled);
}
const delayMsRaw =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
: 2000;
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
const reason =
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
const gatewayUrl =
typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()
? params.gatewayUrl.trim()
: undefined;
const gatewayToken =
typeof params.gatewayToken === "string" && params.gatewayToken.trim()
? params.gatewayToken.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? Math.max(1, Math.floor(params.timeoutMs))
: undefined;
const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs };
const pid = process.pid;
setTimeout(() => {
try {
process.kill(pid, "SIGUSR1");
} catch {
/* ignore */
}
}, delayMs);
if (action === "config.get") {
const result = await callGatewayTool("config.get", gatewayOpts, {});
return jsonResult({ ok: true, result });
}
if (action === "config.schema") {
const result = await callGatewayTool("config.schema", gatewayOpts, {});
return jsonResult({ ok: true, result });
}
if (action === "config.apply") {
const raw = readStringParam(params, "raw", { required: true });
const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim()
? params.sessionKey.trim()
: opts?.agentSessionKey?.trim() || undefined;
const note =
typeof params.note === "string" && params.note.trim()
? params.note.trim()
: undefined;
const restartDelayMs =
typeof params.restartDelayMs === "number" &&
Number.isFinite(params.restartDelayMs)
? Math.floor(params.restartDelayMs)
: undefined;
const result = await callGatewayTool("config.apply", gatewayOpts, {
raw,
sessionKey,
note,
restartDelayMs,
});
return jsonResult({ ok: true, result });
}
if (action === "update.run") {
const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim()
? params.sessionKey.trim()
: opts?.agentSessionKey?.trim() || undefined;
const note =
typeof params.note === "string" && params.note.trim()
? params.note.trim()
: undefined;
const restartDelayMs =
typeof params.restartDelayMs === "number" &&
Number.isFinite(params.restartDelayMs)
? Math.floor(params.restartDelayMs)
: undefined;
const result = await callGatewayTool("update.run", gatewayOpts, {
sessionKey,
note,
restartDelayMs,
timeoutMs,
});
return jsonResult({ ok: true, result });
}
return jsonResult({
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason: reason ?? null,
});
throw new Error(`Unknown action: ${action}`);
},
};
}

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();
});
});

View File

@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
consumeRestartSentinel,
readRestartSentinel,
resolveRestartSentinelPath,
trimLogTail,
writeRestartSentinel,
} from "./restart-sentinel.js";
describe("restart sentinel", () => {
let prevStateDir: string | undefined;
let tempDir: string;
beforeEach(async () => {
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sentinel-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
});
afterEach(async () => {
if (prevStateDir) process.env.CLAWDBOT_STATE_DIR = prevStateDir;
else delete process.env.CLAWDBOT_STATE_DIR;
await fs.rm(tempDir, { recursive: true, force: true });
});
it("writes and consumes a sentinel", async () => {
const payload = {
kind: "update" as const,
status: "ok" as const,
ts: Date.now(),
sessionKey: "agent:main:whatsapp:dm:+15555550123",
stats: { mode: "git" },
};
const filePath = await writeRestartSentinel(payload);
expect(filePath).toBe(resolveRestartSentinelPath());
const read = await readRestartSentinel();
expect(read?.payload.kind).toBe("update");
const consumed = await consumeRestartSentinel();
expect(consumed?.payload.sessionKey).toBe(payload.sessionKey);
const empty = await readRestartSentinel();
expect(empty).toBeNull();
});
it("drops invalid sentinel payloads", async () => {
const filePath = resolveRestartSentinelPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, "not-json", "utf-8");
const read = await readRestartSentinel();
expect(read).toBeNull();
await expect(fs.stat(filePath)).rejects.toThrow();
});
it("trims log tails", () => {
const text = "a".repeat(9000);
const trimmed = trimLogTail(text, 8000);
expect(trimmed?.length).toBeLessThanOrEqual(8001);
expect(trimmed?.startsWith("…")).toBe(true);
});
});

View File

@@ -0,0 +1,116 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type RestartSentinelLog = {
stdoutTail?: string | null;
stderrTail?: string | null;
exitCode?: number | null;
};
export type RestartSentinelStep = {
name: string;
command: string;
cwd?: string | null;
durationMs?: number | null;
log?: RestartSentinelLog | null;
};
export type RestartSentinelStats = {
mode?: string;
root?: string;
before?: Record<string, unknown> | null;
after?: Record<string, unknown> | null;
steps?: RestartSentinelStep[];
reason?: string | null;
durationMs?: number | null;
};
export type RestartSentinelPayload = {
kind: "config-apply" | "update";
status: "ok" | "error" | "skipped";
ts: number;
sessionKey?: string;
message?: string | null;
stats?: RestartSentinelStats | null;
};
export type RestartSentinel = {
version: 1;
payload: RestartSentinelPayload;
};
const SENTINEL_FILENAME = "restart-sentinel.json";
export function resolveRestartSentinelPath(
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveStateDir(env), SENTINEL_FILENAME);
}
export async function writeRestartSentinel(
payload: RestartSentinelPayload,
env: NodeJS.ProcessEnv = process.env,
) {
const filePath = resolveRestartSentinelPath(env);
await fs.mkdir(path.dirname(filePath), { recursive: true });
const data: RestartSentinel = { version: 1, payload };
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
return filePath;
}
export async function readRestartSentinel(
env: NodeJS.ProcessEnv = process.env,
): Promise<RestartSentinel | null> {
const filePath = resolveRestartSentinelPath(env);
try {
const raw = await fs.readFile(filePath, "utf-8");
let parsed: RestartSentinel | undefined;
try {
parsed = JSON.parse(raw) as RestartSentinel | undefined;
} catch {
await fs.unlink(filePath).catch(() => {});
return null;
}
if (!parsed || parsed.version !== 1 || !parsed.payload) {
await fs.unlink(filePath).catch(() => {});
return null;
}
return parsed;
} catch {
return null;
}
}
export async function consumeRestartSentinel(
env: NodeJS.ProcessEnv = process.env,
): Promise<RestartSentinel | null> {
const filePath = resolveRestartSentinelPath(env);
const parsed = await readRestartSentinel(env);
if (!parsed) return null;
await fs.unlink(filePath).catch(() => {});
return parsed;
}
export function formatRestartSentinelMessage(
payload: RestartSentinelPayload,
): string {
return `GatewayRestart:\n${JSON.stringify(payload, null, 2)}`;
}
export function summarizeRestartSentinel(
payload: RestartSentinelPayload,
): string {
const kind = payload.kind;
const status = payload.status;
const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";
return `Gateway restart ${kind} ${status}${mode}`.trim();
}
export function trimLogTail(input?: string | null, maxChars = 8000) {
if (!input) return null;
const text = input.trimEnd();
if (text.length <= maxChars) return text;
return `${text.slice(text.length - maxChars)}`;
}

View File

@@ -34,3 +34,48 @@ export function triggerClawdbotRestart():
spawnSync("launchctl", ["kickstart", "-k", target], { stdio: "ignore" });
return "launchctl";
}
export type ScheduledRestart = {
ok: boolean;
pid: number;
signal: "SIGUSR1";
delayMs: number;
reason?: string;
mode: "emit" | "signal";
};
export function scheduleGatewaySigusr1Restart(opts?: {
delayMs?: number;
reason?: string;
}): ScheduledRestart {
const delayMsRaw =
typeof opts?.delayMs === "number" && Number.isFinite(opts.delayMs)
? Math.floor(opts.delayMs)
: 2000;
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
const reason =
typeof opts?.reason === "string" && opts.reason.trim()
? opts.reason.trim().slice(0, 200)
: undefined;
const pid = process.pid;
const hasListener = process.listenerCount("SIGUSR1") > 0;
setTimeout(() => {
try {
if (hasListener) {
process.emit("SIGUSR1");
} else {
process.kill(pid, "SIGUSR1");
}
} catch {
/* ignore */
}
}, delayMs);
return {
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason,
mode: hasListener ? "emit" : "signal",
};
}

View File

@@ -0,0 +1,111 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runGatewayUpdate } from "./update-runner.js";
type CommandResult = { stdout?: string; stderr?: string; code?: number };
function createRunner(responses: Record<string, CommandResult>) {
const calls: string[] = [];
const runner = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
const res = responses[key] ?? {};
return {
stdout: res.stdout ?? "",
stderr: res.stderr ?? "",
code: res.code ?? 0,
};
};
return { runner, calls };
}
describe("runGatewayUpdate", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("skips git update when worktree is dirty", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
"utf-8",
);
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
});
const result = await runGatewayUpdate({
cwd: tempDir,
runCommand: async (argv, _options) => runner(argv),
timeoutMs: 5000,
});
expect(result.status).toBe("skipped");
expect(result.reason).toBe("dirty");
expect(calls.some((call) => call.includes("rebase"))).toBe(false);
});
it("aborts rebase on failure", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
"utf-8",
);
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]:
{ stdout: "origin/main" },
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
});
const result = await runGatewayUpdate({
cwd: tempDir,
runCommand: async (argv, _options) => runner(argv),
timeoutMs: 5000,
});
expect(result.status).toBe("error");
expect(result.reason).toBe("rebase-failed");
expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true);
});
it("runs package manager update when no git root", async () => {
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8");
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 },
"pnpm update": { stdout: "ok" },
});
const result = await runGatewayUpdate({
cwd: tempDir,
runCommand: async (argv, _options) => runner(argv),
timeoutMs: 5000,
});
expect(result.status).toBe("ok");
expect(result.mode).toBe("pnpm");
expect(calls.some((call) => call.includes("pnpm update"))).toBe(true);
});
});

390
src/infra/update-runner.ts Normal file
View File

@@ -0,0 +1,390 @@
import fs from "node:fs/promises";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import { trimLogTail } from "./restart-sentinel.js";
export type UpdateStepResult = {
name: string;
command: string;
cwd: string;
durationMs: number;
exitCode: number | null;
stdoutTail?: string | null;
stderrTail?: string | null;
};
export type UpdateRunResult = {
status: "ok" | "error" | "skipped";
mode: "git" | "pnpm" | "bun" | "npm" | "unknown";
root?: string;
reason?: string;
before?: { sha?: string | null; version?: string | null };
after?: { sha?: string | null; version?: string | null };
steps: UpdateStepResult[];
durationMs: number;
};
type CommandRunner = (
argv: string[],
options: CommandOptions,
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
type UpdateRunnerOptions = {
cwd?: string;
argv1?: string;
timeoutMs?: number;
runCommand?: CommandRunner;
};
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return path.resolve(trimmed);
}
function buildStartDirs(opts: UpdateRunnerOptions): string[] {
const dirs: string[] = [];
const cwd = normalizeDir(opts.cwd);
if (cwd) dirs.push(cwd);
const argv1 = normalizeDir(opts.argv1);
if (argv1) dirs.push(path.dirname(argv1));
const proc = normalizeDir(process.cwd());
if (proc) dirs.push(proc);
return Array.from(new Set(dirs));
}
async function readPackageVersion(root: string) {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { version?: string };
return typeof parsed?.version === "string" ? parsed.version : null;
} catch {
return null;
}
}
async function resolveGitRoot(
runCommand: CommandRunner,
candidates: string[],
timeoutMs: number,
): Promise<string | null> {
for (const dir of candidates) {
const res = await runCommand(
["git", "-C", dir, "rev-parse", "--show-toplevel"],
{
timeoutMs,
},
);
if (res.code === 0) {
const root = res.stdout.trim();
if (root) return root;
}
}
return null;
}
async function findPackageRoot(candidates: string[]) {
for (const dir of candidates) {
let current = dir;
for (let i = 0; i < 12; i += 1) {
const pkgPath = path.join(current, "package.json");
try {
const raw = await fs.readFile(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as { name?: string };
if (parsed?.name === "clawdbot") return current;
} catch {
// ignore
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
}
return null;
}
async function detectPackageManager(root: string) {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { packageManager?: string };
const pm = parsed?.packageManager?.split("@")[0]?.trim();
if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm;
} catch {
// ignore
}
const files = await fs.readdir(root).catch((): string[] => []);
if (files.includes("pnpm-lock.yaml")) return "pnpm";
if (files.includes("bun.lockb")) return "bun";
if (files.includes("package-lock.json")) return "npm";
return "npm";
}
async function runStep(
runCommand: CommandRunner,
name: string,
argv: string[],
cwd: string,
timeoutMs: number,
): Promise<UpdateStepResult> {
const started = Date.now();
const result = await runCommand(argv, { cwd, timeoutMs });
const durationMs = Date.now() - started;
return {
name,
command: argv.join(" "),
cwd,
durationMs,
exitCode: result.code,
stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
};
}
function managerScriptArgs(
manager: "pnpm" | "bun" | "npm",
script: string,
args: string[] = [],
) {
if (manager === "pnpm") return ["pnpm", script, ...args];
if (manager === "bun") return ["bun", "run", script, ...args];
if (args.length > 0) return ["npm", "run", script, "--", ...args];
return ["npm", "run", script];
}
function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
if (manager === "pnpm") return ["pnpm", "install"];
if (manager === "bun") return ["bun", "install"];
return ["npm", "install"];
}
function managerUpdateArgs(manager: "pnpm" | "bun" | "npm") {
if (manager === "pnpm") return ["pnpm", "update"];
if (manager === "bun") return ["bun", "update"];
return ["npm", "update"];
}
export async function runGatewayUpdate(
opts: UpdateRunnerOptions = {},
): Promise<UpdateRunResult> {
const startedAt = Date.now();
const runCommand =
opts.runCommand ??
(async (argv, options) => {
const res = await runCommandWithTimeout(argv, options);
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
});
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const steps: UpdateStepResult[] = [];
const candidates = buildStartDirs(opts);
const gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs);
if (gitRoot) {
const beforeSha = (
await runStep(
runCommand,
"git rev-parse HEAD",
["git", "-C", gitRoot, "rev-parse", "HEAD"],
gitRoot,
timeoutMs,
)
).stdoutTail?.trim();
const beforeVersion = await readPackageVersion(gitRoot);
const statusStep = await runStep(
runCommand,
"git status",
["git", "-C", gitRoot, "status", "--porcelain"],
gitRoot,
timeoutMs,
);
steps.push(statusStep);
if ((statusStep.stdoutTail ?? "").trim()) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "dirty",
before: { sha: beforeSha ?? null, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const upstreamStep = await runStep(
runCommand,
"git upstream",
[
"git",
"-C",
gitRoot,
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
gitRoot,
timeoutMs,
);
steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "no-upstream",
before: { sha: beforeSha ?? null, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
steps.push(
await runStep(
runCommand,
"git fetch",
["git", "-C", gitRoot, "fetch", "--all", "--prune"],
gitRoot,
timeoutMs,
),
);
const rebaseStep = await runStep(
runCommand,
"git rebase",
["git", "-C", gitRoot, "rebase", "@{upstream}"],
gitRoot,
timeoutMs,
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
steps.push(
await runStep(
runCommand,
"git rebase --abort",
["git", "-C", gitRoot, "rebase", "--abort"],
gitRoot,
timeoutMs,
),
);
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "rebase-failed",
before: { sha: beforeSha ?? null, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(gitRoot);
steps.push(
await runStep(
runCommand,
"deps install",
managerInstallArgs(manager),
gitRoot,
timeoutMs,
),
);
steps.push(
await runStep(
runCommand,
"build",
managerScriptArgs(manager, "build"),
gitRoot,
timeoutMs,
),
);
steps.push(
await runStep(
runCommand,
"ui:install",
managerScriptArgs(manager, "ui:install"),
gitRoot,
timeoutMs,
),
);
steps.push(
await runStep(
runCommand,
"ui:build",
managerScriptArgs(manager, "ui:build"),
gitRoot,
timeoutMs,
),
);
steps.push(
await runStep(
runCommand,
"clawdbot doctor",
managerScriptArgs(manager, "clawdbot", ["doctor"]),
gitRoot,
timeoutMs,
),
);
const failedStep = steps.find((step) => step.exitCode !== 0);
const afterShaStep = await runStep(
runCommand,
"git rev-parse HEAD (after)",
["git", "-C", gitRoot, "rev-parse", "HEAD"],
gitRoot,
timeoutMs,
);
steps.push(afterShaStep);
const afterVersion = await readPackageVersion(gitRoot);
return {
status: failedStep ? "error" : "ok",
mode: "git",
root: gitRoot,
reason: failedStep ? failedStep.name : undefined,
before: { sha: beforeSha ?? null, version: beforeVersion },
after: {
sha: afterShaStep.stdoutTail?.trim() ?? null,
version: afterVersion,
},
steps,
durationMs: Date.now() - startedAt,
};
}
const pkgRoot = await findPackageRoot(candidates);
if (!pkgRoot) {
return {
status: "error",
mode: "unknown",
reason: `no root (${START_DIRS.join(",")})`,
steps: [],
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(pkgRoot);
steps.push(
await runStep(
runCommand,
"deps update",
managerUpdateArgs(manager),
pkgRoot,
timeoutMs,
),
);
const failed = steps.find((step) => step.exitCode !== 0);
return {
status: failed ? "error" : "ok",
mode: manager,
root: pkgRoot,
reason: failed ? failed.name : undefined,
steps,
durationMs: Date.now() - startedAt,
};
}

View File

@@ -60,7 +60,13 @@ import {
} from "./controllers/skills";
import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat";
import { loadConfig, saveConfig, updateConfigFormValue } from "./controllers/config";
import {
applyConfig,
loadConfig,
runUpdate,
saveConfig,
updateConfigFormValue,
} from "./controllers/config";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
@@ -97,6 +103,8 @@ export type AppViewState = {
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaLoading: boolean;
@@ -244,7 +252,11 @@ export function renderApp(state: AppViewState) {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next });
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
},
onRefresh: () => state.loadOverview(),
onReconnect: () => state.connect(),
@@ -384,7 +396,11 @@ export function renderApp(state: AppViewState) {
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({ ...state.settings, sessionKey: next });
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state);
},
thinkingLevel: state.chatThinkingLevel,
@@ -422,6 +438,8 @@ export function renderApp(state: AppViewState) {
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
@@ -433,6 +451,8 @@ export function renderApp(state: AppViewState) {
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}

View File

@@ -199,6 +199,9 @@ export class ClawdbotApp extends LitElement {
@state() configValid: boolean | null = null;
@state() configIssues: unknown[] = [];
@state() configSaving = false;
@state() configApplying = false;
@state() updateRunning = false;
@state() applySessionKey = this.settings.lastActiveSessionKey;
@state() configSnapshot: ConfigSnapshot | null = null;
@state() configSchema: unknown | null = null;
@state() configSchemaVersion: string | null = null;
@@ -616,6 +619,9 @@ export class ClawdbotApp extends LitElement {
if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined;
if (payload?.sessionKey) {
this.setLastActiveSessionKey(payload.sessionKey);
}
const state = handleChatEvent(this, payload);
if (state === "final" || state === "error" || state === "aborted") {
this.resetToolStream();
@@ -652,12 +658,25 @@ export class ClawdbotApp extends LitElement {
}
applySettings(next: UiSettings) {
this.settings = next;
saveSettings(next);
const normalized = {
...next,
lastActiveSessionKey:
next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
};
this.settings = normalized;
saveSettings(normalized);
if (next.theme !== this.theme) {
this.theme = next.theme;
this.applyResolvedTheme(resolveTheme(next.theme));
}
this.applySessionKey = this.settings.lastActiveSessionKey;
}
private setLastActiveSessionKey(next: string) {
const trimmed = next.trim();
if (!trimmed) return;
if (this.settings.lastActiveSessionKey === trimmed) return;
this.applySettings({ ...this.settings, lastActiveSessionKey: trimmed });
}
private applySettingsFromUrl() {
@@ -843,6 +862,9 @@ export class ClawdbotApp extends LitElement {
if (!this.connected) return;
this.resetToolStream();
const ok = await sendChat(this);
if (ok) {
this.setLastActiveSessionKey(this.sessionKey);
}
if (ok && this.chatRunId) {
// chat.send returned (run finished), but we missed the chat final event.
this.chatRunId = null;

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
applyConfigSnapshot,
applyConfig,
runUpdate,
updateConfigFormValue,
type ConfigState,
} from "./config";
@@ -95,11 +97,14 @@ function createState(): ConfigState {
return {
client: null,
connected: false,
applySessionKey: "main",
configLoading: false,
configRaw: "",
configValid: null,
configIssues: [],
configSaving: false,
configApplying: false,
updateRunning: false,
configSnapshot: null,
configSchema: null,
configSchemaVersion: null,
@@ -161,3 +166,38 @@ describe("updateConfigFormValue", () => {
});
});
});
describe("applyConfig", () => {
it("sends config.apply with raw and session key", async () => {
const request = vi.fn().mockResolvedValue({});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
state.configFormMode = "raw";
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
await applyConfig(state);
expect(request).toHaveBeenCalledWith("config.apply", {
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
});
describe("runUpdate", () => {
it("sends update.run with session key", async () => {
const request = vi.fn().mockResolvedValue({});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
await runUpdate(state);
expect(request).toHaveBeenCalledWith("update.run", {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
});

View File

@@ -21,11 +21,14 @@ import {
export type ConfigState = {
client: GatewayBrowserClient | null;
connected: boolean;
applySessionKey: string;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaVersion: string | null;
@@ -397,6 +400,43 @@ export async function saveConfig(state: ConfigState) {
}
}
export async function applyConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configApplying = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
: state.configRaw;
await state.client.request("config.apply", {
raw,
sessionKey: state.applySessionKey,
});
state.configFormDirty = false;
await loadConfig(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.configApplying = false;
}
}
export async function runUpdate(state: ConfigState) {
if (!state.client || !state.connected) return;
state.updateRunning = true;
state.lastError = null;
try {
await state.client.request("update.run", {
sessionKey: state.applySessionKey,
});
} catch (err) {
state.lastError = String(err);
} finally {
state.updateRunning = false;
}
}
export function updateConfigFormValue(
state: ConfigState,
path: Array<string | number>,

View File

@@ -6,6 +6,7 @@ export type UiSettings = {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
chatFocusMode: boolean;
};
@@ -20,6 +21,7 @@ export function loadSettings(): UiSettings {
gatewayUrl: defaultUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
};
@@ -38,6 +40,13 @@ export function loadSettings(): UiSettings {
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
: defaults.sessionKey,
lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" &&
parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" &&
parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme:
parsed.theme === "light" ||
parsed.theme === "dark" ||

View File

@@ -147,7 +147,17 @@
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] },
"config.get": { "label": "config get" },
"config.schema": { "label": "config schema" },
"config.apply": {
"label": "config apply",
"detailKeys": ["restartDelayMs"]
},
"update.run": {
"label": "update run",
"detailKeys": ["restartDelayMs"]
}
}
},
"whatsapp_login": {

View File

@@ -8,6 +8,8 @@ export type ConfigProps = {
issues: unknown[];
loading: boolean;
saving: boolean;
applying: boolean;
updating: boolean;
connected: boolean;
schema: unknown | null;
schemaLoading: boolean;
@@ -19,6 +21,8 @@ export type ConfigProps = {
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onReload: () => void;
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
};
export function renderConfig(props: ConfigProps) {
@@ -34,6 +38,12 @@ export function renderConfig(props: ConfigProps) {
props.connected &&
!props.saving &&
(props.formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -67,12 +77,27 @@ export function renderConfig(props: ConfigProps) {
>
${props.saving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${!canApply}
@click=${props.onApply}
>
${props.applying ? "Applying…" : "Apply & Restart"}
</button>
<button
class="btn"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
${props.updating ? "Updating…" : "Update & Restart"}
</button>
</div>
</div>
<div class="muted" style="margin-top: 10px;">
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Some changes
require a gateway restart.
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Apply &
Update restart the gateway and will ping the last active session when it
comes back.
</div>
${props.formMode === "form"