feat: add gateway config/update restart flow

This commit is contained in:
Peter Steinberger
2026-01-08 01:29:56 +01:00
parent 3398fc3820
commit 71c31266a1
28 changed files with 1630 additions and 50 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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",
});
});
});

View File

@@ -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>,

View File

@@ -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" ||

View File

@@ -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": {

View File

@@ -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"