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 restart`
- `gateway daemon status` (alias for `clawdbot daemon status`) - `gateway daemon status` (alias for `clawdbot daemon status`)
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake)
## Models ## Models
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy. 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 Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
better forms without hard-coding config knowledge. 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) ## Minimal config (recommended starting point)
```json5 ```json5

View File

@@ -42,6 +42,15 @@ Notes:
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. - 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. - 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) ## Update (from source)
From the repo checkout: From the repo checkout:

View File

@@ -159,10 +159,13 @@ Notes:
- `update` uses `{ id, patch }`. - `update` uses `{ id, patch }`.
### `gateway` ### `gateway`
Restart the running Gateway process (in-place). Restart or apply updates to the running Gateway process (in-place).
Core actions: Core actions:
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway`/`gateway-daemon` restart in-place) - `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: Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. - 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.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - Nodes: list + caps (`node.list`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - 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 - 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`) - 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) ## Tailnet access (recommended)

View File

@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(async () => ({ ok: true })),
}));
describe("gateway tool", () => { describe("gateway tool", () => {
it("schedules SIGUSR1 restart", async () => { it("schedules SIGUSR1 restart", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -33,4 +37,51 @@ describe("gateway tool", () => {
vi.useRealTimers(); 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(), createSlackTool(),
createTelegramTool(), createTelegramTool(),
createWhatsAppTool(), createWhatsAppTool(),
createGatewayTool(), createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
createSessionsListTool({ createSessionsListTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed, sandboxed: options?.sandboxed,

View File

@@ -1,6 +1,8 @@
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
const GatewayToolSchema = Type.Union([ const GatewayToolSchema = Type.Union([
Type.Object({ Type.Object({
@@ -8,46 +10,137 @@ const GatewayToolSchema = Type.Union([
delayMs: Type.Optional(Type.Number()), delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()), 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 { return {
label: "Gateway", label: "Gateway",
name: "gateway", name: "gateway",
description: 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, parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true }); 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 = const gatewayUrl =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs) typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()
? Math.floor(params.delayMs) ? params.gatewayUrl.trim()
: 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)
: undefined; : 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; if (action === "config.get") {
setTimeout(() => { const result = await callGatewayTool("config.get", gatewayOpts, {});
try { return jsonResult({ ok: true, result });
process.kill(pid, "SIGUSR1"); }
} catch { if (action === "config.schema") {
/* ignore */ const result = await callGatewayTool("config.schema", gatewayOpts, {});
} return jsonResult({ ok: true, result });
}, delayMs); }
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({ throw new Error(`Unknown action: ${action}`);
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason: reason ?? null,
});
}, },
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,16 @@ import {
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import { buildConfigSchema } from "../../config/schema.js"; import { buildConfigSchema } from "../../config/schema.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import {
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateConfigApplyParams,
validateConfigGetParams, validateConfigGetParams,
validateConfigSchemaParams, validateConfigSchemaParams,
validateConfigSetParams, validateConfigSetParams,
@@ -102,4 +108,102 @@ export const configHandlers: GatewayRequestHandlers = {
undefined, 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, resetModelCatalogCacheForTest,
} from "../agents/model-catalog.js"; } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.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 { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import { import {
type CanvasHostHandler, type CanvasHostHandler,
@@ -18,6 +19,7 @@ import {
startCanvasHost, startCanvasHost,
} from "../canvas-host/server.js"; } from "../canvas-host/server.js";
import { createDefaultDeps } from "../cli/deps.js"; import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
import { import {
CONFIG_PATH_CLAWDBOT, CONFIG_PATH_CLAWDBOT,
@@ -57,7 +59,13 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.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 { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { import {
@@ -88,6 +96,7 @@ import {
runtimeForLogger, runtimeForLogger,
} from "../logging.js"; } from "../logging.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { runOnboardingWizard } from "../wizard/onboarding.js"; import { runOnboardingWizard } from "../wizard/onboarding.js";
import type { WizardSession } from "../wizard/session.js"; import type { WizardSession } from "../wizard/session.js";
import { import {
@@ -107,6 +116,18 @@ import {
isLoopbackHost, isLoopbackHost,
resolveGatewayBindHost, resolveGatewayBindHost,
} from "./net.js"; } 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 { createBridgeHandlers } from "./server-bridge.js";
import { import {
type BridgeListConnectedFn, type BridgeListConnectedFn,
@@ -138,6 +159,7 @@ import { handleGatewayRequest } from "./server-methods.js";
import { createProviderManager } from "./server-providers.js"; import { createProviderManager } from "./server-providers.js";
import type { DedupeEntry } from "./server-shared.js"; import type { DedupeEntry } from "./server-shared.js";
import { formatError } from "./server-utils.js"; import { formatError } from "./server-utils.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
ensureClawdbotCliOnPath(); ensureClawdbotCliOnPath();
@@ -181,19 +203,6 @@ async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
return await loadModelCatalog({ config: loadConfig() }); 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 = { type Client = {
socket: WebSocket; socket: WebSocket;
connect: ConnectParams; connect: ConnectParams;
@@ -208,6 +217,7 @@ const METHODS = [
"usage.status", "usage.status",
"config.get", "config.get",
"config.set", "config.set",
"config.apply",
"config.schema", "config.schema",
"wizard.start", "wizard.start",
"wizard.next", "wizard.next",
@@ -218,6 +228,7 @@ const METHODS = [
"skills.status", "skills.status",
"skills.install", "skills.install",
"skills.update", "skills.update",
"update.run",
"voicewake.get", "voicewake.get",
"voicewake.set", "voicewake.set",
"sessions.list", "sessions.list",
@@ -1650,6 +1661,77 @@ export async function startGatewayServer(
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)"); 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 ( const applyHotReload = async (
plan: GatewayReloadPlan, plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>, 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" }); spawnSync("launchctl", ["kickstart", "-k", target], { stdio: "ignore" });
return "launchctl"; 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"; } from "./controllers/skills";
import { loadNodes } from "./controllers/nodes"; import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat"; 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 { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
@@ -97,6 +103,8 @@ export type AppViewState = {
configValid: boolean | null; configValid: boolean | null;
configIssues: unknown[]; configIssues: unknown[];
configSaving: boolean; configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null; configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null; configSchema: unknown | null;
configSchemaLoading: boolean; configSchemaLoading: boolean;
@@ -244,7 +252,11 @@ export function renderApp(state: AppViewState) {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream(); state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
}, },
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
onReconnect: () => state.connect(), onReconnect: () => state.connect(),
@@ -384,7 +396,11 @@ export function renderApp(state: AppViewState) {
state.chatRunId = null; state.chatRunId = null;
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state); void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
@@ -422,6 +438,8 @@ export function renderApp(state: AppViewState) {
issues: state.configIssues, issues: state.configIssues,
loading: state.configLoading, loading: state.configLoading,
saving: state.configSaving, saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected, connected: state.connected,
schema: state.configSchema, schema: state.configSchema,
schemaLoading: state.configSchemaLoading, schemaLoading: state.configSchemaLoading,
@@ -433,6 +451,8 @@ export function renderApp(state: AppViewState) {
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onReload: () => loadConfig(state), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
}) })
: nothing} : nothing}

View File

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

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
applyConfigSnapshot, applyConfigSnapshot,
applyConfig,
runUpdate,
updateConfigFormValue, updateConfigFormValue,
type ConfigState, type ConfigState,
} from "./config"; } from "./config";
@@ -95,11 +97,14 @@ function createState(): ConfigState {
return { return {
client: null, client: null,
connected: false, connected: false,
applySessionKey: "main",
configLoading: false, configLoading: false,
configRaw: "", configRaw: "",
configValid: null, configValid: null,
configIssues: [], configIssues: [],
configSaving: false, configSaving: false,
configApplying: false,
updateRunning: false,
configSnapshot: null, configSnapshot: null,
configSchema: null, configSchema: null,
configSchemaVersion: 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 = { export type ConfigState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
applySessionKey: string;
configLoading: boolean; configLoading: boolean;
configRaw: string; configRaw: string;
configValid: boolean | null; configValid: boolean | null;
configIssues: unknown[]; configIssues: unknown[];
configSaving: boolean; configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null; configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null; configSchema: unknown | null;
configSchemaVersion: string | 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( export function updateConfigFormValue(
state: ConfigState, state: ConfigState,
path: Array<string | number>, path: Array<string | number>,

View File

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

View File

@@ -147,7 +147,17 @@
"emoji": "🔌", "emoji": "🔌",
"title": "Gateway", "title": "Gateway",
"actions": { "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": { "whatsapp_login": {

View File

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