feat(gateway-tool): add config.patch action for safe partial config updates (#1624)
* fix(ui): enable save button only when config has changes The save button in the Control UI config editor was not properly gating on whether actual changes were made. This adds: - `configRawOriginal` state to track the original raw config for comparison - Change detection for both form mode (via computeDiff) and raw mode - `hasChanges` check in canSave/canApply logic - Set `configFormDirty` when raw mode edits occur - Handle raw mode UI correctly (badge shows "Unsaved changes", no diff panel) Fixes #1609 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(gateway-tool): add config.patch action for safe partial config updates Exposes the existing config.patch server method to agents, allowing safe partial config updates that merge with existing config instead of replacing it. - Add config.patch to GATEWAY_ACTIONS in gateway tool - Add restart + sentinel logic to config.patch server method - Extend ConfigPatchParamsSchema with sessionKey, note, restartDelayMs - Add unit test for config.patch gateway tool action Closes #1617 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,32 @@ describe("gateway tool", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes config.patch 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 channels: { telegram: { groups: { "*": { requireMention: false } } } }\n}\n';
|
||||||
|
await tool.execute("call4", {
|
||||||
|
action: "config.patch",
|
||||||
|
raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
|
||||||
|
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||||
|
"config.patch",
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({
|
||||||
|
raw: raw.trim(),
|
||||||
|
baseHash: "hash-1",
|
||||||
|
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes update.run through gateway call", async () => {
|
it("passes update.run through gateway call", async () => {
|
||||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const GATEWAY_ACTIONS = [
|
|||||||
"config.get",
|
"config.get",
|
||||||
"config.schema",
|
"config.schema",
|
||||||
"config.apply",
|
"config.apply",
|
||||||
|
"config.patch",
|
||||||
"update.run",
|
"update.run",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -35,10 +36,10 @@ const GatewayToolSchema = Type.Object({
|
|||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String()),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
// config.apply
|
// config.apply, config.patch
|
||||||
raw: Type.Optional(Type.String()),
|
raw: Type.Optional(Type.String()),
|
||||||
baseHash: Type.Optional(Type.String()),
|
baseHash: Type.Optional(Type.String()),
|
||||||
// config.apply, update.run
|
// config.apply, config.patch, update.run
|
||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(Type.String()),
|
||||||
note: Type.Optional(Type.String()),
|
note: Type.Optional(Type.String()),
|
||||||
restartDelayMs: Type.Optional(Type.Number()),
|
restartDelayMs: Type.Optional(Type.Number()),
|
||||||
@@ -56,7 +57,7 @@ export function createGatewayTool(opts?: {
|
|||||||
label: "Gateway",
|
label: "Gateway",
|
||||||
name: "gateway",
|
name: "gateway",
|
||||||
description:
|
description:
|
||||||
"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.",
|
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing.",
|
||||||
parameters: GatewayToolSchema,
|
parameters: GatewayToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
@@ -195,6 +196,42 @@ export function createGatewayTool(opts?: {
|
|||||||
});
|
});
|
||||||
return jsonResult({ ok: true, result });
|
return jsonResult({ ok: true, result });
|
||||||
}
|
}
|
||||||
|
if (action === "config.patch") {
|
||||||
|
const raw = readStringParam(params, "raw", { required: true });
|
||||||
|
let baseHash = readStringParam(params, "baseHash");
|
||||||
|
if (!baseHash) {
|
||||||
|
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
||||||
|
if (snapshot && typeof snapshot === "object") {
|
||||||
|
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 =
|
||||||
|
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.patch", gatewayOpts, {
|
||||||
|
raw,
|
||||||
|
baseHash,
|
||||||
|
sessionKey,
|
||||||
|
note,
|
||||||
|
restartDelayMs,
|
||||||
|
});
|
||||||
|
return jsonResult({ ok: true, result });
|
||||||
|
}
|
||||||
if (action === "update.run") {
|
if (action === "update.run") {
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const ConfigPatchParamsSchema = Type.Object(
|
|||||||
{
|
{
|
||||||
raw: NonEmptyString,
|
raw: NonEmptyString,
|
||||||
baseHash: Type.Optional(NonEmptyString),
|
baseHash: Type.Optional(NonEmptyString),
|
||||||
|
sessionKey: Type.Optional(Type.String()),
|
||||||
|
note: Type.Optional(Type.String()),
|
||||||
|
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -260,12 +260,54 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await writeConfigFile(validated.config);
|
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,
|
||||||
|
doctorHint: formatDoctorNonInteractiveHint(),
|
||||||
|
stats: {
|
||||||
|
mode: "config.patch",
|
||||||
|
root: CONFIG_PATH_CLAWDBOT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let sentinelPath: string | null = null;
|
||||||
|
try {
|
||||||
|
sentinelPath = await writeRestartSentinel(payload);
|
||||||
|
} catch {
|
||||||
|
sentinelPath = null;
|
||||||
|
}
|
||||||
|
const restart = scheduleGatewaySigusr1Restart({
|
||||||
|
delayMs: restartDelayMs,
|
||||||
|
reason: "config.patch",
|
||||||
|
});
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
path: CONFIG_PATH_CLAWDBOT,
|
path: CONFIG_PATH_CLAWDBOT,
|
||||||
config: validated.config,
|
config: validated.config,
|
||||||
|
restart,
|
||||||
|
sentinel: {
|
||||||
|
path: sentinelPath,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
${state.tab === "config"
|
${state.tab === "config"
|
||||||
? renderConfig({
|
? renderConfig({
|
||||||
raw: state.configRaw,
|
raw: state.configRaw,
|
||||||
|
originalRaw: state.configRawOriginal,
|
||||||
valid: state.configValid,
|
valid: state.configValid,
|
||||||
issues: state.configIssues,
|
issues: state.configIssues,
|
||||||
loading: state.configLoading,
|
loading: state.configLoading,
|
||||||
@@ -509,7 +510,10 @@ export function renderApp(state: AppViewState) {
|
|||||||
searchQuery: state.configSearchQuery,
|
searchQuery: state.configSearchQuery,
|
||||||
activeSection: state.configActiveSection,
|
activeSection: state.configActiveSection,
|
||||||
activeSubsection: state.configActiveSubsection,
|
activeSubsection: state.configActiveSubsection,
|
||||||
onRawChange: (next) => (state.configRaw = next),
|
onRawChange: (next) => {
|
||||||
|
state.configRaw = next;
|
||||||
|
state.configFormDirty = true;
|
||||||
|
},
|
||||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
|
|
||||||
@state() configLoading = false;
|
@state() configLoading = false;
|
||||||
@state() configRaw = "{\n}\n";
|
@state() configRaw = "{\n}\n";
|
||||||
|
@state() configRawOriginal = "";
|
||||||
@state() configValid: boolean | null = null;
|
@state() configValid: boolean | null = null;
|
||||||
@state() configIssues: unknown[] = [];
|
@state() configIssues: unknown[] = [];
|
||||||
@state() configSaving = false;
|
@state() configSaving = false;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function createState(): ConfigState {
|
|||||||
applySessionKey: "main",
|
applySessionKey: "main",
|
||||||
configLoading: false,
|
configLoading: false,
|
||||||
configRaw: "",
|
configRaw: "",
|
||||||
|
configRawOriginal: "",
|
||||||
configValid: null,
|
configValid: null,
|
||||||
configIssues: [],
|
configIssues: [],
|
||||||
configSaving: false,
|
configSaving: false,
|
||||||
@@ -26,6 +27,7 @@ function createState(): ConfigState {
|
|||||||
configSchemaLoading: false,
|
configSchemaLoading: false,
|
||||||
configUiHints: {},
|
configUiHints: {},
|
||||||
configForm: null,
|
configForm: null,
|
||||||
|
configFormOriginal: null,
|
||||||
configFormDirty: false,
|
configFormDirty: false,
|
||||||
configFormMode: "form",
|
configFormMode: "form",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
@@ -63,6 +65,37 @@ describe("applyConfigSnapshot", () => {
|
|||||||
|
|
||||||
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
|
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets configRawOriginal when clean for change detection", () => {
|
||||||
|
const state = createState();
|
||||||
|
applyConfigSnapshot(state, {
|
||||||
|
config: { gateway: { mode: "local" } },
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
raw: '{ "gateway": { "mode": "local" } }',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }');
|
||||||
|
expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves configRawOriginal when dirty", () => {
|
||||||
|
const state = createState();
|
||||||
|
state.configFormDirty = true;
|
||||||
|
state.configRawOriginal = '{ "original": true }';
|
||||||
|
state.configFormOriginal = { original: true };
|
||||||
|
|
||||||
|
applyConfigSnapshot(state, {
|
||||||
|
config: { gateway: { mode: "local" } },
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
raw: '{ "gateway": { "mode": "local" } }',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Original values should be preserved when dirty
|
||||||
|
expect(state.configRawOriginal).toBe('{ "original": true }');
|
||||||
|
expect(state.configFormOriginal).toEqual({ original: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateConfigFormValue", () => {
|
describe("updateConfigFormValue", () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type ConfigState = {
|
|||||||
applySessionKey: string;
|
applySessionKey: string;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configRaw: string;
|
configRaw: string;
|
||||||
|
configRawOriginal: string;
|
||||||
configValid: boolean | null;
|
configValid: boolean | null;
|
||||||
configIssues: unknown[];
|
configIssues: unknown[];
|
||||||
configSaving: boolean;
|
configSaving: boolean;
|
||||||
@@ -98,6 +99,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
|||||||
if (!state.configFormDirty) {
|
if (!state.configFormDirty) {
|
||||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||||
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
|
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
|
||||||
|
state.configRawOriginal = rawFromSnapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { renderConfig } from "./config";
|
|||||||
describe("config view", () => {
|
describe("config view", () => {
|
||||||
const baseProps = () => ({
|
const baseProps = () => ({
|
||||||
raw: "{\n}\n",
|
raw: "{\n}\n",
|
||||||
|
originalRaw: "{\n}\n",
|
||||||
valid: true,
|
valid: true,
|
||||||
issues: [],
|
issues: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
export type ConfigProps = {
|
export type ConfigProps = {
|
||||||
raw: string;
|
raw: string;
|
||||||
|
originalRaw: string;
|
||||||
valid: boolean | null;
|
valid: boolean | null;
|
||||||
issues: unknown[];
|
issues: unknown[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -187,18 +188,6 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
const formUnsafe = analysis.schema
|
const formUnsafe = analysis.schema
|
||||||
? analysis.unsupportedPaths.length > 0
|
? analysis.unsupportedPaths.length > 0
|
||||||
: false;
|
: false;
|
||||||
const canSaveForm =
|
|
||||||
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
|
||||||
const canSave =
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Get available sections from schema
|
// Get available sections from schema
|
||||||
const schemaProps = analysis.schema?.properties ?? {};
|
const schemaProps = analysis.schema?.properties ?? {};
|
||||||
@@ -237,11 +226,28 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
? null
|
? null
|
||||||
: props.activeSubsection ?? (subsections[0]?.key ?? null);
|
: props.activeSubsection ?? (subsections[0]?.key ?? null);
|
||||||
|
|
||||||
// Compute diff for showing changes
|
// Compute diff for showing changes (works for both form and raw modes)
|
||||||
const diff = props.formMode === "form"
|
const diff = props.formMode === "form"
|
||||||
? computeDiff(props.originalValue, props.formValue)
|
? computeDiff(props.originalValue, props.formValue)
|
||||||
: [];
|
: [];
|
||||||
const hasChanges = diff.length > 0;
|
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
|
||||||
|
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
|
||||||
|
|
||||||
|
// Save/apply buttons require actual changes to be enabled
|
||||||
|
const canSaveForm =
|
||||||
|
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
||||||
|
const canSave =
|
||||||
|
props.connected &&
|
||||||
|
!props.saving &&
|
||||||
|
hasChanges &&
|
||||||
|
(props.formMode === "raw" ? true : canSaveForm);
|
||||||
|
const canApply =
|
||||||
|
props.connected &&
|
||||||
|
!props.applying &&
|
||||||
|
!props.updating &&
|
||||||
|
hasChanges &&
|
||||||
|
(props.formMode === "raw" ? true : canSaveForm);
|
||||||
|
const canUpdate = props.connected && !props.applying && !props.updating;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="config-layout">
|
<div class="config-layout">
|
||||||
@@ -319,7 +325,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
<div class="config-actions">
|
<div class="config-actions">
|
||||||
<div class="config-actions__left">
|
<div class="config-actions__left">
|
||||||
${hasChanges ? html`
|
${hasChanges ? html`
|
||||||
<span class="config-changes-badge">${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}</span>
|
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
|
||||||
` : html`
|
` : html`
|
||||||
<span class="config-status muted">No changes</span>
|
<span class="config-status muted">No changes</span>
|
||||||
`}
|
`}
|
||||||
@@ -352,8 +358,8 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Diff panel -->
|
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
|
||||||
${hasChanges ? html`
|
${hasChanges && props.formMode === "form" ? html`
|
||||||
<details class="config-diff">
|
<details class="config-diff">
|
||||||
<summary class="config-diff__summary">
|
<summary class="config-diff__summary">
|
||||||
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
|
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user