feat: add gateway config/update restart flow
This commit is contained in:
@@ -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