feat: add gateway config/update restart flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createClawdbotTools(options?: {
|
||||
createSlackTool(),
|
||||
createTelegramTool(),
|
||||
createWhatsAppTool(),
|
||||
createGatewayTool(),
|
||||
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
|
||||
createSessionsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
sandboxed: options?.sandboxed,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool } from "./gateway.js";
|
||||
|
||||
const GatewayToolSchema = Type.Union([
|
||||
Type.Object({
|
||||
@@ -8,46 +10,137 @@ const GatewayToolSchema = Type.Union([
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
reason: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("config.get"),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("config.schema"),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("config.apply"),
|
||||
raw: Type.String(),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Number()),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("update.run"),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Number()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export function createGatewayTool(): AnyAgentTool {
|
||||
export function createGatewayTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "Gateway",
|
||||
name: "gateway",
|
||||
description:
|
||||
"Restart the running gateway process in-place (SIGUSR1) without needing an external supervisor. Use delayMs to avoid interrupting an in-flight reply.",
|
||||
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.apply/update.run to write config or run updates with validation and restart.",
|
||||
parameters: GatewayToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
if (action !== "restart") throw new Error(`Unknown action: ${action}`);
|
||||
if (action === "restart") {
|
||||
const delayMs =
|
||||
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
|
||||
? Math.floor(params.delayMs)
|
||||
: undefined;
|
||||
const reason =
|
||||
typeof params.reason === "string" && params.reason.trim()
|
||||
? params.reason.trim().slice(0, 200)
|
||||
: undefined;
|
||||
const scheduled = scheduleGatewaySigusr1Restart({
|
||||
delayMs,
|
||||
reason,
|
||||
});
|
||||
return jsonResult(scheduled);
|
||||
}
|
||||
|
||||
const delayMsRaw =
|
||||
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
|
||||
? Math.floor(params.delayMs)
|
||||
: 2000;
|
||||
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
|
||||
const reason =
|
||||
typeof params.reason === "string" && params.reason.trim()
|
||||
? params.reason.trim().slice(0, 200)
|
||||
const gatewayUrl =
|
||||
typeof params.gatewayUrl === "string" && params.gatewayUrl.trim()
|
||||
? params.gatewayUrl.trim()
|
||||
: undefined;
|
||||
const gatewayToken =
|
||||
typeof params.gatewayToken === "string" && params.gatewayToken.trim()
|
||||
? params.gatewayToken.trim()
|
||||
: undefined;
|
||||
const timeoutMs =
|
||||
typeof params.timeoutMs === "number" &&
|
||||
Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: undefined;
|
||||
const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs };
|
||||
|
||||
const pid = process.pid;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(pid, "SIGUSR1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, delayMs);
|
||||
if (action === "config.get") {
|
||||
const result = await callGatewayTool("config.get", gatewayOpts, {});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.schema") {
|
||||
const result = await callGatewayTool("config.schema", gatewayOpts, {});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.apply") {
|
||||
const raw = readStringParam(params, "raw", { required: true });
|
||||
const sessionKey =
|
||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||
? params.sessionKey.trim()
|
||||
: opts?.agentSessionKey?.trim() || undefined;
|
||||
const note =
|
||||
typeof params.note === "string" && params.note.trim()
|
||||
? params.note.trim()
|
||||
: undefined;
|
||||
const restartDelayMs =
|
||||
typeof params.restartDelayMs === "number" &&
|
||||
Number.isFinite(params.restartDelayMs)
|
||||
? Math.floor(params.restartDelayMs)
|
||||
: undefined;
|
||||
const result = await callGatewayTool("config.apply", gatewayOpts, {
|
||||
raw,
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "update.run") {
|
||||
const sessionKey =
|
||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||
? params.sessionKey.trim()
|
||||
: opts?.agentSessionKey?.trim() || undefined;
|
||||
const note =
|
||||
typeof params.note === "string" && params.note.trim()
|
||||
? params.note.trim()
|
||||
: undefined;
|
||||
const restartDelayMs =
|
||||
typeof params.restartDelayMs === "number" &&
|
||||
Number.isFinite(params.restartDelayMs)
|
||||
? Math.floor(params.restartDelayMs)
|
||||
: undefined;
|
||||
const result = await callGatewayTool("update.run", gatewayOpts, {
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
timeoutMs,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
pid,
|
||||
signal: "SIGUSR1",
|
||||
delayMs,
|
||||
reason: reason ?? null,
|
||||
});
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
type ConfigApplyParams,
|
||||
ConfigApplyParamsSchema,
|
||||
type ConfigGetParams,
|
||||
ConfigGetParamsSchema,
|
||||
type ConfigSchemaParams,
|
||||
@@ -107,6 +109,8 @@ import {
|
||||
TalkModeParamsSchema,
|
||||
type TickEvent,
|
||||
TickEventSchema,
|
||||
type UpdateRunParams,
|
||||
UpdateRunParamsSchema,
|
||||
type WakeParams,
|
||||
WakeParamsSchema,
|
||||
type WebLoginStartParams,
|
||||
@@ -202,6 +206,9 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||
ConfigSetParamsSchema,
|
||||
);
|
||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(
|
||||
ConfigApplyParamsSchema,
|
||||
);
|
||||
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(
|
||||
ConfigSchemaParamsSchema,
|
||||
);
|
||||
@@ -257,6 +264,9 @@ export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
|
||||
ChatAbortParamsSchema,
|
||||
);
|
||||
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(
|
||||
UpdateRunParamsSchema,
|
||||
);
|
||||
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
|
||||
WebLoginStartParamsSchema,
|
||||
);
|
||||
@@ -302,6 +312,7 @@ export {
|
||||
SessionsCompactParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
WizardStartParamsSchema,
|
||||
@@ -329,6 +340,7 @@ export {
|
||||
CronRunsParamsSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
TickEventSchema,
|
||||
ShutdownEventSchema,
|
||||
ProtocolSchemas,
|
||||
@@ -359,6 +371,7 @@ export type {
|
||||
NodePairApproveParams,
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
ConfigApplyParams,
|
||||
ConfigSchemaParams,
|
||||
ConfigSchemaResponse,
|
||||
WizardStartParams,
|
||||
@@ -395,4 +408,5 @@ export type {
|
||||
CronRunsParams,
|
||||
CronRunLogEntry,
|
||||
PollParams,
|
||||
UpdateRunParams,
|
||||
};
|
||||
|
||||
@@ -374,11 +374,31 @@ export const ConfigSetParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigApplyParamsSchema = Type.Object(
|
||||
{
|
||||
raw: NonEmptyString,
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigSchemaParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const UpdateRunParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigUiHintSchema = Type.Object(
|
||||
{
|
||||
label: Type.Optional(Type.String()),
|
||||
@@ -870,6 +890,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||
ConfigSchemaParams: ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponse: ConfigSchemaResponseSchema,
|
||||
WizardStartParams: WizardStartParamsSchema,
|
||||
@@ -903,6 +924,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
ChatEvent: ChatEventSchema,
|
||||
UpdateRunParams: UpdateRunParamsSchema,
|
||||
TickEvent: TickEventSchema,
|
||||
ShutdownEvent: ShutdownEventSchema,
|
||||
};
|
||||
@@ -939,6 +961,7 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
|
||||
export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>;
|
||||
export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>;
|
||||
export type WizardStartParams = Static<typeof WizardStartParamsSchema>;
|
||||
@@ -970,6 +993,7 @@ export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
||||
export type TickEvent = Static<typeof TickEventSchema>;
|
||||
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
GatewayRequestHandlers,
|
||||
GatewayRequestOptions,
|
||||
} from "./server-methods/types.js";
|
||||
import { updateHandlers } from "./server-methods/update.js";
|
||||
import { usageHandlers } from "./server-methods/usage.js";
|
||||
import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
||||
import { webHandlers } from "./server-methods/web.js";
|
||||
@@ -37,6 +38,7 @@ const handlers: GatewayRequestHandlers = {
|
||||
...skillsHandlers,
|
||||
...sessionsHandlers,
|
||||
...systemHandlers,
|
||||
...updateHandlers,
|
||||
...nodeHandlers,
|
||||
...sendHandlers,
|
||||
...usageHandlers,
|
||||
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { buildConfigSchema } from "../../config/schema.js";
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import {
|
||||
type RestartSentinelPayload,
|
||||
writeRestartSentinel,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateConfigApplyParams,
|
||||
validateConfigGetParams,
|
||||
validateConfigSchemaParams,
|
||||
validateConfigSetParams,
|
||||
@@ -102,4 +108,102 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"config.apply": async ({ params, respond }) => {
|
||||
if (!validateConfigApplyParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid config.apply params: ${formatValidationErrors(validateConfigApplyParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"invalid config.apply params: raw (string) required",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const parsedRes = parseConfigJson5(rawValue);
|
||||
if (!parsedRes.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
|
||||
details: { issues: validated.issues },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
||||
? (params as { sessionKey?: string }).sessionKey?.trim() || undefined
|
||||
: undefined;
|
||||
const note =
|
||||
typeof (params as { note?: unknown }).note === "string"
|
||||
? (params as { note?: string }).note?.trim() || undefined
|
||||
: undefined;
|
||||
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
|
||||
.restartDelayMs;
|
||||
const restartDelayMs =
|
||||
typeof restartDelayMsRaw === "number" &&
|
||||
Number.isFinite(restartDelayMsRaw)
|
||||
? Math.max(0, Math.floor(restartDelayMsRaw))
|
||||
: undefined;
|
||||
|
||||
const payload: RestartSentinelPayload = {
|
||||
kind: "config-apply",
|
||||
status: "ok",
|
||||
ts: Date.now(),
|
||||
sessionKey,
|
||||
message: note ?? null,
|
||||
stats: {
|
||||
mode: "config.apply",
|
||||
root: CONFIG_PATH_CLAWDBOT,
|
||||
},
|
||||
};
|
||||
let sentinelPath: string | null = null;
|
||||
try {
|
||||
sentinelPath = await writeRestartSentinel(payload);
|
||||
} catch {
|
||||
sentinelPath = null;
|
||||
}
|
||||
const restart = scheduleGatewaySigusr1Restart({
|
||||
delayMs: restartDelayMs,
|
||||
reason: "config.apply",
|
||||
});
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH_CLAWDBOT,
|
||||
config: validated.config,
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
119
src/gateway/server-methods/update.ts
Normal file
119
src/gateway/server-methods/update.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import {
|
||||
type RestartSentinelPayload,
|
||||
writeRestartSentinel,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { runGatewayUpdate } from "../../infra/update-runner.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateUpdateRunParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const updateHandlers: GatewayRequestHandlers = {
|
||||
"update.run": async ({ params, respond }) => {
|
||||
if (!validateUpdateRunParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid update.run params: ${formatValidationErrors(validateUpdateRunParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
||||
? (params as { sessionKey?: string }).sessionKey?.trim() || undefined
|
||||
: undefined;
|
||||
const note =
|
||||
typeof (params as { note?: unknown }).note === "string"
|
||||
? (params as { note?: string }).note?.trim() || undefined
|
||||
: undefined;
|
||||
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
|
||||
.restartDelayMs;
|
||||
const restartDelayMs =
|
||||
typeof restartDelayMsRaw === "number" &&
|
||||
Number.isFinite(restartDelayMsRaw)
|
||||
? Math.max(0, Math.floor(restartDelayMsRaw))
|
||||
: undefined;
|
||||
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;
|
||||
const timeoutMs =
|
||||
typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw)
|
||||
? Math.max(1000, Math.floor(timeoutMsRaw))
|
||||
: undefined;
|
||||
|
||||
let result: Awaited<ReturnType<typeof runGatewayUpdate>>;
|
||||
try {
|
||||
result = await runGatewayUpdate({
|
||||
timeoutMs,
|
||||
cwd: process.cwd(),
|
||||
argv1: process.argv[1],
|
||||
});
|
||||
} catch (err) {
|
||||
result = {
|
||||
status: "error",
|
||||
mode: "unknown",
|
||||
reason: String(err),
|
||||
steps: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const payload: RestartSentinelPayload = {
|
||||
kind: "update",
|
||||
status: result.status,
|
||||
ts: Date.now(),
|
||||
sessionKey,
|
||||
message: note ?? null,
|
||||
stats: {
|
||||
mode: result.mode,
|
||||
root: result.root ?? undefined,
|
||||
before: result.before ?? null,
|
||||
after: result.after ?? null,
|
||||
steps: result.steps.map((step) => ({
|
||||
name: step.name,
|
||||
command: step.command,
|
||||
cwd: step.cwd,
|
||||
durationMs: step.durationMs,
|
||||
log: {
|
||||
stdoutTail: step.stdoutTail ?? null,
|
||||
stderrTail: step.stderrTail ?? null,
|
||||
exitCode: step.exitCode ?? null,
|
||||
},
|
||||
})),
|
||||
reason: result.reason ?? null,
|
||||
durationMs: result.durationMs,
|
||||
},
|
||||
};
|
||||
|
||||
let sentinelPath: string | null = null;
|
||||
try {
|
||||
sentinelPath = await writeRestartSentinel(payload);
|
||||
} catch {
|
||||
sentinelPath = null;
|
||||
}
|
||||
|
||||
const restart = scheduleGatewaySigusr1Restart({
|
||||
delayMs: restartDelayMs,
|
||||
reason: "update.run",
|
||||
});
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
result,
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
85
src/gateway/server.config-apply.test.ts
Normal file
85
src/gateway/server.config-apply.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway config.apply", () => {
|
||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sigusr1 = vi.fn();
|
||||
process.on("SIGUSR1", sigusr1);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: '{ "agent": { "workspace": "~/clawd" } }',
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
restartDelayMs: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sigusr1).toHaveBeenCalled();
|
||||
|
||||
const sentinelPath = path.join(
|
||||
os.homedir(),
|
||||
".clawdbot",
|
||||
"restart-sentinel.json",
|
||||
);
|
||||
const raw = await fs.readFile(sentinelPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
|
||||
expect(parsed.payload?.kind).toBe("config-apply");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
process.off("SIGUSR1", sigusr1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects invalid raw config", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: "{",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resetModelCatalogCacheForTest,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
|
||||
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
|
||||
import {
|
||||
type CanvasHostHandler,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
startCanvasHost,
|
||||
} from "../canvas-host/server.js";
|
||||
import { createDefaultDeps } from "../cli/deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
@@ -57,7 +59,13 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import {
|
||||
consumeRestartSentinel,
|
||||
formatRestartSentinelMessage,
|
||||
summarizeRestartSentinel,
|
||||
} from "../infra/restart-sentinel.js";
|
||||
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import {
|
||||
@@ -88,6 +96,7 @@ import {
|
||||
runtimeForLogger,
|
||||
} from "../logging.js";
|
||||
import { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runOnboardingWizard } from "../wizard/onboarding.js";
|
||||
import type { WizardSession } from "../wizard/session.js";
|
||||
import {
|
||||
@@ -107,6 +116,18 @@ import {
|
||||
isLoopbackHost,
|
||||
resolveGatewayBindHost,
|
||||
} from "./net.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
type Snapshot,
|
||||
validateConnectParams,
|
||||
validateRequestFrame,
|
||||
} from "./protocol/index.js";
|
||||
import { createBridgeHandlers } from "./server-bridge.js";
|
||||
import {
|
||||
type BridgeListConnectedFn,
|
||||
@@ -138,6 +159,7 @@ import { handleGatewayRequest } from "./server-methods.js";
|
||||
import { createProviderManager } from "./server-providers.js";
|
||||
import type { DedupeEntry } from "./server-shared.js";
|
||||
import { formatError } from "./server-utils.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
|
||||
|
||||
ensureClawdbotCliOnPath();
|
||||
@@ -181,19 +203,6 @@ async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
|
||||
return await loadModelCatalog({ config: loadConfig() });
|
||||
}
|
||||
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
type Snapshot,
|
||||
validateConnectParams,
|
||||
validateRequestFrame,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
type Client = {
|
||||
socket: WebSocket;
|
||||
connect: ConnectParams;
|
||||
@@ -208,6 +217,7 @@ const METHODS = [
|
||||
"usage.status",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.apply",
|
||||
"config.schema",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
@@ -218,6 +228,7 @@ const METHODS = [
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
"update.run",
|
||||
"voicewake.get",
|
||||
"voicewake.set",
|
||||
"sessions.list",
|
||||
@@ -1650,6 +1661,77 @@ export async function startGatewayServer(
|
||||
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)");
|
||||
}
|
||||
|
||||
const scheduleRestartSentinelWake = async () => {
|
||||
const sentinel = await consumeRestartSentinel();
|
||||
if (!sentinel) return;
|
||||
const payload = sentinel.payload;
|
||||
const sessionKey = payload.sessionKey?.trim();
|
||||
const message = formatRestartSentinelMessage(payload);
|
||||
const summary = summarizeRestartSentinel(payload);
|
||||
|
||||
if (!sessionKey) {
|
||||
enqueueSystemEvent(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||
const lastProvider =
|
||||
entry?.lastProvider && entry.lastProvider !== "webchat"
|
||||
? entry.lastProvider
|
||||
: undefined;
|
||||
const lastTo = entry?.lastTo?.trim();
|
||||
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
|
||||
const provider = lastProvider ?? parsedTarget?.provider;
|
||||
const to = lastTo || parsedTarget?.to;
|
||||
if (!provider || !to) {
|
||||
enqueueSystemEvent(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveOutboundTarget({
|
||||
provider: provider as
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "webchat",
|
||||
to,
|
||||
allowFrom: cfg.whatsapp?.allowFrom ?? [],
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
enqueueSystemEvent(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
message,
|
||||
sessionKey,
|
||||
to: resolved.to,
|
||||
provider,
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
messageProvider: provider,
|
||||
},
|
||||
defaultRuntime,
|
||||
deps,
|
||||
);
|
||||
} catch (err) {
|
||||
enqueueSystemEvent(`${summary}\n${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldWakeFromSentinel =
|
||||
!process.env.VITEST && process.env.NODE_ENV !== "test";
|
||||
if (shouldWakeFromSentinel) {
|
||||
setTimeout(() => {
|
||||
void scheduleRestartSentinelWake();
|
||||
}, 750);
|
||||
}
|
||||
|
||||
const applyHotReload = async (
|
||||
plan: GatewayReloadPlan,
|
||||
nextConfig: ReturnType<typeof loadConfig>,
|
||||
|
||||
72
src/gateway/server.update-run.test.ts
Normal file
72
src/gateway/server.update-run.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../infra/update-runner.js", () => ({
|
||||
runGatewayUpdate: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
mode: "git",
|
||||
root: "/repo",
|
||||
steps: [],
|
||||
durationMs: 12,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway update.run", () => {
|
||||
it("writes sentinel and schedules restart", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sigusr1 = vi.fn();
|
||||
process.on("SIGUSR1", sigusr1);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const id = "req-update";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "update.run",
|
||||
params: {
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
restartDelayMs: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === id,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sigusr1).toHaveBeenCalled();
|
||||
|
||||
const sentinelPath = path.join(
|
||||
os.homedir(),
|
||||
".clawdbot",
|
||||
"restart-sentinel.json",
|
||||
);
|
||||
const raw = await fs.readFile(sentinelPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
payload?: { kind?: string; stats?: { mode?: string } };
|
||||
};
|
||||
expect(parsed.payload?.kind).toBe("update");
|
||||
expect(parsed.payload?.stats?.mode).toBe("git");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
process.off("SIGUSR1", sigusr1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
68
src/infra/restart-sentinel.test.ts
Normal file
68
src/infra/restart-sentinel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
src/infra/restart-sentinel.ts
Normal file
116
src/infra/restart-sentinel.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
111
src/infra/update-runner.test.ts
Normal file
111
src/infra/update-runner.test.ts
Normal 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
390
src/infra/update-runner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
applySessionKey: string;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown | null;
|
||||
configSchemaVersion: string | null;
|
||||
@@ -397,6 +400,43 @@ export async function saveConfig(state: ConfigState) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
|
||||
: state.configRaw;
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdate(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.updateRunning = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
await state.client.request("update.run", {
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.updateRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ConfigProps = {
|
||||
issues: unknown[];
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
applying: boolean;
|
||||
updating: boolean;
|
||||
connected: boolean;
|
||||
schema: unknown | null;
|
||||
schemaLoading: boolean;
|
||||
@@ -19,6 +21,8 @@ export type ConfigProps = {
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onReload: () => void;
|
||||
onSave: () => void;
|
||||
onApply: () => void;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
@@ -34,6 +38,12 @@ export function renderConfig(props: ConfigProps) {
|
||||
props.connected &&
|
||||
!props.saving &&
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
const canApply =
|
||||
props.connected &&
|
||||
!props.applying &&
|
||||
!props.updating &&
|
||||
(props.formMode === "raw" ? true : canSaveForm);
|
||||
const canUpdate = props.connected && !props.applying && !props.updating;
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
@@ -67,12 +77,27 @@ export function renderConfig(props: ConfigProps) {
|
||||
>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${!canApply}
|
||||
@click=${props.onApply}
|
||||
>
|
||||
${props.applying ? "Applying…" : "Apply & Restart"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${!canUpdate}
|
||||
@click=${props.onUpdate}
|
||||
>
|
||||
${props.updating ? "Updating…" : "Update & Restart"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top: 10px;">
|
||||
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Some changes
|
||||
require a gateway restart.
|
||||
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Apply &
|
||||
Update restart the gateway and will ping the last active session when it
|
||||
comes back.
|
||||
</div>
|
||||
|
||||
${props.formMode === "form"
|
||||
|
||||
Reference in New Issue
Block a user