From 71c31266a1db2102499122bd3dbc89b9058daa0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 01:29:56 +0100 Subject: [PATCH] feat: add gateway config/update restart flow --- docs/cli/index.md | 4 + docs/gateway/configuration.md | 20 ++ docs/install/updating.md | 9 + docs/tools/index.md | 5 +- docs/web/control-ui.md | 2 + src/agents/clawdbot-gateway-tool.test.ts | 51 +++ src/agents/clawdbot-tools.ts | 2 +- src/agents/tools/gateway-tool.ts | 145 +++++++-- src/gateway/protocol/index.ts | 14 + src/gateway/protocol/schema.ts | 24 ++ src/gateway/server-methods.ts | 2 + src/gateway/server-methods/config.ts | 104 ++++++ src/gateway/server-methods/update.ts | 119 +++++++ src/gateway/server.config-apply.test.ts | 85 +++++ src/gateway/server.ts | 108 ++++++- src/gateway/server.update-run.test.ts | 72 +++++ src/infra/restart-sentinel.test.ts | 68 ++++ src/infra/restart-sentinel.ts | 116 +++++++ src/infra/restart.ts | 45 +++ src/infra/update-runner.test.ts | 111 +++++++ src/infra/update-runner.ts | 390 +++++++++++++++++++++++ ui/src/ui/app-render.ts | 26 +- ui/src/ui/app.ts | 26 +- ui/src/ui/controllers/config.test.ts | 42 ++- ui/src/ui/controllers/config.ts | 40 +++ ui/src/ui/storage.ts | 9 + ui/src/ui/tool-display.json | 12 +- ui/src/ui/views/config.ts | 29 +- 28 files changed, 1630 insertions(+), 50 deletions(-) create mode 100644 src/gateway/server-methods/update.ts create mode 100644 src/gateway/server.config-apply.test.ts create mode 100644 src/gateway/server.update-run.test.ts create mode 100644 src/infra/restart-sentinel.test.ts create mode 100644 src/infra/restart-sentinel.ts create mode 100644 src/infra/update-runner.test.ts create mode 100644 src/infra/update-runner.ts diff --git a/docs/cli/index.md b/docs/cli/index.md index d7c4faca0..fe7be7d4d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9648eb65c..348354b25 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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 diff --git a/docs/install/updating.md b/docs/install/updating.md index f6e045c4e..6698f0468 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -42,6 +42,15 @@ Notes: - If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. - If you’re 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: diff --git a/docs/tools/index.md b/docs/tools/index.md index 408051f2f..029789a86 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index be656b7ad..a0c7b8a27 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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) diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index 7fffe3cf4..e9ac32622 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -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", + }), + ); + }); }); diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index ecc866a3d..5754609e8 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -36,7 +36,7 @@ export function createClawdbotTools(options?: { createSlackTool(), createTelegramTool(), createWhatsAppTool(), - createGatewayTool(), + createGatewayTool({ agentSessionKey: options?.agentSessionKey }), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 2ea899fe4..552d7e23e 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -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; 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}`); }, }; } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 16b0b2176..c80b9f515 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( export const validateConfigSetParams = ajv.compile( ConfigSetParamsSchema, ); +export const validateConfigApplyParams = ajv.compile( + ConfigApplyParamsSchema, +); export const validateConfigSchemaParams = ajv.compile( ConfigSchemaParamsSchema, ); @@ -257,6 +264,9 @@ export const validateChatAbortParams = ajv.compile( ChatAbortParamsSchema, ); export const validateChatEvent = ajv.compile(ChatEventSchema); +export const validateUpdateRunParams = ajv.compile( + UpdateRunParamsSchema, +); export const validateWebLoginStartParams = ajv.compile( 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, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 46ce67c74..884adcfc2 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 = { SessionsCompactParams: SessionsCompactParamsSchema, ConfigGetParams: ConfigGetParamsSchema, ConfigSetParams: ConfigSetParamsSchema, + ConfigApplyParams: ConfigApplyParamsSchema, ConfigSchemaParams: ConfigSchemaParamsSchema, ConfigSchemaResponse: ConfigSchemaResponseSchema, WizardStartParams: WizardStartParamsSchema, @@ -903,6 +924,7 @@ export const ProtocolSchemas: Record = { ChatSendParams: ChatSendParamsSchema, ChatAbortParams: ChatAbortParamsSchema, ChatEvent: ChatEventSchema, + UpdateRunParams: UpdateRunParamsSchema, TickEvent: TickEventSchema, ShutdownEvent: ShutdownEventSchema, }; @@ -939,6 +961,7 @@ export type SessionsDeleteParams = Static; export type SessionsCompactParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; +export type ConfigApplyParams = Static; export type ConfigSchemaParams = Static; export type ConfigSchemaResponse = Static; export type WizardStartParams = Static; @@ -970,6 +993,7 @@ export type CronRunsParams = Static; export type CronRunLogEntry = Static; export type ChatAbortParams = Static; export type ChatEvent = Static; +export type UpdateRunParams = Static; export type TickEvent = Static; export type ShutdownEvent = Static; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 210bbbc0b..a174aa966 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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, diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 59dd90548..5d6e09348 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -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, + ); + }, }; diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts new file mode 100644 index 000000000..3901eb782 --- /dev/null +++ b/src/gateway/server-methods/update.ts @@ -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>; + 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, + ); + }, +}; diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.test.ts new file mode 100644 index 000000000..65d290078 --- /dev/null +++ b/src/gateway/server.config-apply.test.ts @@ -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(); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index c06a8941d..ab63b3fd1 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 { 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, diff --git a/src/gateway/server.update-run.test.ts b/src/gateway/server.update-run.test.ts new file mode 100644 index 000000000..ed0efe61a --- /dev/null +++ b/src/gateway/server.update-run.test.ts @@ -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(); + }); +}); diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts new file mode 100644 index 000000000..817d0e963 --- /dev/null +++ b/src/infra/restart-sentinel.test.ts @@ -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); + }); +}); diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts new file mode 100644 index 000000000..a5adfd74c --- /dev/null +++ b/src/infra/restart-sentinel.ts @@ -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 | null; + after?: Record | 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 { + 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 { + 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)}`; +} diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 76d442ed1..2a6fb6107 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -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", + }; +} diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts new file mode 100644 index 000000000..e6c10ad02 --- /dev/null +++ b/src/infra/update-runner.test.ts @@ -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) { + 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); + }); +}); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts new file mode 100644 index 000000000..bdc25a7d8 --- /dev/null +++ b/src/infra/update-runner.ts @@ -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 { + 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 { + 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 { + 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, + }; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 85f15b740..d783166bf 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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} diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 5a6aa3435..702cf69ee 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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; diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 87fda4ce1..0007d377b 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -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", + }); + }); +}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 5844baad5..325bcadfc 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -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, diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b77dd96d4..671c7b669 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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" || diff --git a/ui/src/ui/tool-display.json b/ui/src/ui/tool-display.json index ce83d1520..1b978b4ae 100644 --- a/ui/src/ui/tool-display.json +++ b/ui/src/ui/tool-display.json @@ -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": { diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 27571d1ab..bdee6b8fe 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -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, 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`
@@ -67,12 +77,27 @@ export function renderConfig(props: ConfigProps) { > ${props.saving ? "Saving…" : "Save"} + +
- Writes to ~/.clawdbot/clawdbot.json. Some changes - require a gateway restart. + Writes to ~/.clawdbot/clawdbot.json. Apply & + Update restart the gateway and will ping the last active session when it + comes back.
${props.formMode === "form"