fix: follow up config.patch restarts/docs/tests (#1653)
* fix: land config.patch restarts/docs/tests (#1624) (thanks @Glucksberg) * docs: update changelog entry for config.patch follow-up (#1653) (thanks @Glucksberg)
This commit is contained in:
committed by
GitHub
parent
5570e1a946
commit
8e159ab0b7
@@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: update Fly.io guide notes.
|
- Docs: update Fly.io guide notes.
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ Subcommands:
|
|||||||
|
|
||||||
Common RPCs:
|
Common RPCs:
|
||||||
- `config.apply` (validate + write config + restart + wake)
|
- `config.apply` (validate + write config + restart + wake)
|
||||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
- `config.patch` (merge a partial update + restart + wake)
|
||||||
- `update.run` (run update + restart + wake)
|
- `update.run` (run update + restart + wake)
|
||||||
|
|
||||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ Params:
|
|||||||
- `raw` (string) — JSON5 payload for the entire config
|
- `raw` (string) — JSON5 payload for the entire config
|
||||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||||
|
- `note` (optional) — note to include in the restart sentinel
|
||||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||||
|
|
||||||
Example (via `gateway call`):
|
Example (via `gateway call`):
|
||||||
@@ -71,10 +72,15 @@ unrelated keys. It applies JSON merge patch semantics:
|
|||||||
- objects merge recursively
|
- objects merge recursively
|
||||||
- `null` deletes a key
|
- `null` deletes a key
|
||||||
- arrays replace
|
- arrays replace
|
||||||
|
Like `config.apply`, it validates, writes the config, stores a restart sentinel, and schedules
|
||||||
|
the Gateway restart (with an optional wake when `sessionKey` is provided).
|
||||||
|
|
||||||
Params:
|
Params:
|
||||||
- `raw` (string) — JSON5 payload containing just the keys to change
|
- `raw` (string) — JSON5 payload containing just the keys to change
|
||||||
- `baseHash` (required) — config hash from `config.get`
|
- `baseHash` (required) — config hash from `config.get`
|
||||||
|
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||||
|
- `note` (optional) — note to include in the restart sentinel
|
||||||
|
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -82,7 +88,9 @@ Example:
|
|||||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||||
clawdbot gateway call config.patch --params '{
|
clawdbot gateway call config.patch --params '{
|
||||||
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
||||||
"baseHash": "<hash-from-config.get>"
|
"baseHash": "<hash-from-config.get>",
|
||||||
|
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
|
||||||
|
"restartDelayMs": 1000
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ Core actions:
|
|||||||
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
|
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
|
||||||
- `config.get` / `config.schema`
|
- `config.get` / `config.schema`
|
||||||
- `config.apply` (validate + write config + restart + wake)
|
- `config.apply` (validate + write config + restart + wake)
|
||||||
|
- `config.patch` (merge partial update + restart + wake)
|
||||||
- `update.run` (run update + restart + wake)
|
- `update.run` (run update + restart + wake)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/io.js";
|
import { loadConfig, resolveConfigSnapshotHash } from "../../config/io.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +13,17 @@ import { stringEnum } from "../schema/typebox.js";
|
|||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool } from "./gateway.js";
|
import { callGatewayTool } from "./gateway.js";
|
||||||
|
|
||||||
|
function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined {
|
||||||
|
if (!snapshot || typeof snapshot !== "object") return undefined;
|
||||||
|
const hashValue = (snapshot as { hash?: unknown }).hash;
|
||||||
|
const rawValue = (snapshot as { raw?: unknown }).raw;
|
||||||
|
const hash = resolveConfigSnapshotHash({
|
||||||
|
hash: typeof hashValue === "string" ? hashValue : undefined,
|
||||||
|
raw: typeof rawValue === "string" ? rawValue : undefined,
|
||||||
|
});
|
||||||
|
return hash ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const GATEWAY_ACTIONS = [
|
const GATEWAY_ACTIONS = [
|
||||||
"restart",
|
"restart",
|
||||||
"config.get",
|
"config.get",
|
||||||
@@ -165,17 +174,7 @@ export function createGatewayTool(opts?: {
|
|||||||
let baseHash = readStringParam(params, "baseHash");
|
let baseHash = readStringParam(params, "baseHash");
|
||||||
if (!baseHash) {
|
if (!baseHash) {
|
||||||
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
||||||
if (snapshot && typeof snapshot === "object") {
|
baseHash = resolveBaseHashFromSnapshot(snapshot);
|
||||||
const hash = (snapshot as { hash?: unknown }).hash;
|
|
||||||
if (typeof hash === "string" && hash.trim()) {
|
|
||||||
baseHash = hash.trim();
|
|
||||||
} else {
|
|
||||||
const rawSnapshot = (snapshot as { raw?: unknown }).raw;
|
|
||||||
if (typeof rawSnapshot === "string") {
|
|
||||||
baseHash = crypto.createHash("sha256").update(rawSnapshot).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||||
@@ -201,17 +200,7 @@ export function createGatewayTool(opts?: {
|
|||||||
let baseHash = readStringParam(params, "baseHash");
|
let baseHash = readStringParam(params, "baseHash");
|
||||||
if (!baseHash) {
|
if (!baseHash) {
|
||||||
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
||||||
if (snapshot && typeof snapshot === "object") {
|
baseHash = resolveBaseHashFromSnapshot(snapshot);
|
||||||
const hash = (snapshot as { hash?: unknown }).hash;
|
|
||||||
if (typeof hash === "string" && hash.trim()) {
|
|
||||||
baseHash = hash.trim();
|
|
||||||
} else {
|
|
||||||
const rawSnapshot = (snapshot as { raw?: unknown }).raw;
|
|
||||||
if (typeof rawSnapshot === "string") {
|
|
||||||
baseHash = crypto.createHash("sha256").update(rawSnapshot).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||||
|
|||||||
@@ -120,6 +120,91 @@ describe("gateway config.patch", () => {
|
|||||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||||
|
const setId = "req-set-restart";
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: setId,
|
||||||
|
method: "config.set",
|
||||||
|
params: {
|
||||||
|
raw: JSON.stringify({
|
||||||
|
gateway: { mode: "local" },
|
||||||
|
channels: { telegram: { botToken: "token-1" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const setRes = await onceMessage<{ ok: boolean }>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === setId,
|
||||||
|
);
|
||||||
|
expect(setRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const getId = "req-get-restart";
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: getId,
|
||||||
|
method: "config.get",
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === getId,
|
||||||
|
);
|
||||||
|
expect(getRes.ok).toBe(true);
|
||||||
|
const baseHash = resolveConfigSnapshotHash({
|
||||||
|
hash: getRes.payload?.hash,
|
||||||
|
raw: getRes.payload?.raw,
|
||||||
|
});
|
||||||
|
expect(typeof baseHash).toBe("string");
|
||||||
|
|
||||||
|
const patchId = "req-patch-restart";
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: patchId,
|
||||||
|
method: "config.patch",
|
||||||
|
params: {
|
||||||
|
raw: JSON.stringify({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { requireMention: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
baseHash,
|
||||||
|
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||||
|
note: "test patch",
|
||||||
|
restartDelayMs: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const patchRes = await onceMessage<{ ok: boolean }>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === patchId,
|
||||||
|
);
|
||||||
|
expect(patchRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
try {
|
||||||
|
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("config-apply");
|
||||||
|
expect(parsed.payload?.stats?.mode).toBe("config.patch");
|
||||||
|
} catch {
|
||||||
|
expect(patchRes.ok).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("requires base hash when config exists", async () => {
|
it("requires base hash when config exists", async () => {
|
||||||
const setId = "req-set-2";
|
const setId = "req-set-2";
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type AppViewState = {
|
|||||||
execApprovalError: string | null;
|
execApprovalError: string | null;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configRaw: string;
|
configRaw: string;
|
||||||
|
configRawOriginal: string;
|
||||||
configValid: boolean | null;
|
configValid: boolean | null;
|
||||||
configIssues: unknown[];
|
configIssues: unknown[];
|
||||||
configSaving: boolean;
|
configSaving: boolean;
|
||||||
@@ -84,6 +85,7 @@ export type AppViewState = {
|
|||||||
configSchemaLoading: boolean;
|
configSchemaLoading: boolean;
|
||||||
configUiHints: Record<string, unknown>;
|
configUiHints: Record<string, unknown>;
|
||||||
configForm: Record<string, unknown> | null;
|
configForm: Record<string, unknown> | null;
|
||||||
|
configFormOriginal: Record<string, unknown> | null;
|
||||||
configFormMode: "form" | "raw";
|
configFormMode: "form" | "raw";
|
||||||
channelsLoading: boolean;
|
channelsLoading: boolean;
|
||||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ describe("config view", () => {
|
|||||||
expect(saveButton?.disabled).toBe(true);
|
expect(saveButton?.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables save and apply when raw is unchanged", () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
render(
|
||||||
|
renderConfig({
|
||||||
|
...baseProps(),
|
||||||
|
formMode: "raw",
|
||||||
|
raw: "{\n}\n",
|
||||||
|
originalRaw: "{\n}\n",
|
||||||
|
}),
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButton = Array.from(
|
||||||
|
container.querySelectorAll("button"),
|
||||||
|
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| undefined;
|
||||||
|
const applyButton = Array.from(
|
||||||
|
container.querySelectorAll("button"),
|
||||||
|
).find((btn) => btn.textContent?.trim() === "Apply") as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| undefined;
|
||||||
|
expect(saveButton).not.toBeUndefined();
|
||||||
|
expect(applyButton).not.toBeUndefined();
|
||||||
|
expect(saveButton?.disabled).toBe(true);
|
||||||
|
expect(applyButton?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("switches mode via the sidebar toggle", () => {
|
it("switches mode via the sidebar toggle", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const onFormModeChange = vi.fn();
|
const onFormModeChange = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user