refactor(ui): split runtime config into layered modules (#122117)

* refactor(ui): split runtime config into layered modules

* refactor(ui): delete redundant config wrappers

* chore(lint): ratchet max-lines baseline after ui config split
This commit is contained in:
Peter Steinberger
2026-08-11 10:17:00 -07:00
committed by GitHub
parent e7ceb5aeb0
commit 5c81ed5883
47 changed files with 6460 additions and 6041 deletions
-2
View File
@@ -906,8 +906,6 @@ src/wizard/setup.test.ts
src/worker/worker.runtime.test.ts
ui/src/api/gateway.node.test.ts
ui/src/api/types.ts
ui/src/lib/config/index.test.ts
ui/src/lib/config/index.ts
ui/src/lib/cron/index.test.ts
ui/src/lib/cron/index.ts
ui/src/lib/nodes/index.ts
+1 -1
View File
@@ -13,7 +13,7 @@ import { setSessionPathBuilder } from "../app-session-path-builder.ts";
import { createAgentIdentityCapability } from "../lib/agents/identity.ts";
import { createAgentCapability } from "../lib/agents/index.ts";
import { createChannelCapability } from "../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import { createSessionCapability } from "../lib/sessions/index.ts";
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import { createWorkboardCapability } from "../lib/workboard/capability.ts";
+1 -1
View File
@@ -5,7 +5,7 @@ import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
import type { AgentCapability } from "../lib/agents/index.ts";
import type { ChannelCapability } from "../lib/channels/index.ts";
import type { ChatAttachment, ChatComposerMemoryFallback } from "../lib/chat/chat-types.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
import type { AgentSelectionCapability } from "./agent-selection.ts";
+1 -1
View File
@@ -1,6 +1,6 @@
import { vi } from "vitest";
import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import { pushServerUiPrefs } from "./server-prefs.ts";
export type RequestMock = ReturnType<
+1 -1
View File
@@ -4,7 +4,7 @@
// intent shadows server snapshots until the hash-free LWW ack; failed pushes degrade device-local.
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import {
extractServerUiPrefs,
prefValuesEqual,
+1 -1
View File
@@ -4,7 +4,7 @@ import { property, state } from "lit/decorators.js";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
import { hasOperatorAdminAccess } from "../app/operator-access.ts";
import { t } from "../i18n/index.ts";
import { resolveEditableSnapshotConfig } from "../lib/config/index.ts";
import { resolveEditableSnapshotConfig } from "../lib/config/config-state-model.ts";
import {
buildAddMcpServerPatch,
buildRemoveMcpServerPatch,
+1 -1
View File
@@ -1,7 +1,7 @@
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import type { ConfigAutoSaveStatus } from "../lib/config/index.ts";
import type { ConfigAutoSaveStatus } from "../lib/config/config-state-model.ts";
import { icons } from "./icons.ts";
const SAVED_VISIBLE_MS = 2_000;
+1 -1
View File
@@ -17,7 +17,7 @@ import type {
import { t } from "../../i18n/index.ts";
import { resolveAgentAvatarUrl, resolveAssistantTextAvatar } from "../avatar.ts";
import { buildCatalogDisplayLookup, buildChatModelOptionFromLookup } from "../chat/model-ref.ts";
import { resolveAgentConfigEntryTarget } from "../config/index.ts";
import { resolveAgentConfigEntryTarget } from "../config/config-state-model.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
type AgentRosterEntry = {
@@ -0,0 +1,814 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import {
CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS,
deferred,
createGatewayHarness,
createConfigServerMock,
createConfigCapabilityHarness,
} from "./config-test-harness.ts";
import { createRuntimeConfigCapability } from "./runtime-config-capability.ts";
describe("config draft model", () => {
it("serializes schema-coerced form values with the draft base hash", async () => {
const submitted: Array<{ method: string; params: unknown }> = [];
let configGetCount = 0;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
configGetCount += 1;
return {
config:
configGetCount === 1
? { count: 1, composedCount: 2, enabled: false, tags: [1], label: "ok" }
: {},
hash: configGetCount === 1 ? "hash-1" : "hash-2",
valid: true,
issues: [],
};
}
if (method === "config.schema") {
return {
schema: {
type: "object",
properties: {
count: { type: "number" },
composedCount: { type: "number", allOf: [{ minimum: 2 }] },
enabled: { type: "boolean" },
tags: { type: "array", items: { type: "integer" } },
label: { type: "string", minLength: 1 },
},
},
uiHints: {},
};
}
submitted.push({ method, params });
return {};
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]);
runtimeConfig.patchForm(["count"], "42.5");
runtimeConfig.patchForm(["composedCount"], "8.5");
runtimeConfig.patchForm(["enabled"], "true");
runtimeConfig.patchForm(["tags"], ["7", ""]);
runtimeConfig.patchForm(["label"], "");
await expect(runtimeConfig.save()).resolves.toBe(true);
const submission = submitted.find((entry) => entry.method === "config.set");
expect(submission?.params).toMatchObject({ baseHash: "hash-1" });
const raw = (submission?.params as { raw?: unknown } | undefined)?.raw;
expect(typeof raw).toBe("string");
expect(JSON.parse(raw as string)).toEqual({
count: 42.5,
composedCount: 8.5,
enabled: true,
tags: [7],
});
runtimeConfig.dispose();
});
it("removes a restored optional override from config.set while preserving siblings", async () => {
const submitted: Array<{ method: string; params: unknown }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
config: { runtime: { keep: true, mode: "custom" } },
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.schema") {
return {
schema: {
type: "object",
properties: {
runtime: {
type: "object",
properties: {
keep: { type: "boolean" },
mode: { type: "string", default: "balanced" },
},
},
},
},
uiHints: {},
};
}
submitted.push({ method, params });
return { hash: "hash-2" };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]);
runtimeConfig.removeFormValue(["runtime", "mode"]);
await expect(runtimeConfig.save()).resolves.toBe(true);
const submission = submitted.find((entry) => entry.method === "config.set");
const raw = (submission?.params as { raw?: unknown } | undefined)?.raw;
expect(typeof raw).toBe("string");
expect(JSON.parse(raw as string)).toEqual({ runtime: { keep: true } });
runtimeConfig.dispose();
});
it("submits only decimal numeric spellings as numbers", async () => {
const submitted: Array<{ method: string; params: unknown }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
config: {},
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.schema") {
return {
schema: {
type: "object",
properties: {
hex: { type: "number" },
binary: { type: "integer" },
explicitPlus: { type: "number" },
separator: { type: "number" },
nonFinite: { type: "number" },
scientific: { type: "number" },
decimal: { type: "number" },
fractionalInteger: { type: "integer" },
unionRadix: { anyOf: [{ type: "integer" }, { type: "string" }] },
unionScientific: { anyOf: [{ type: "integer" }, { type: "string" }] },
},
},
uiHints: {},
};
}
submitted.push({ method, params });
return { hash: "hash-2" };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]);
runtimeConfig.patchForm(["hex"], "0x10");
runtimeConfig.patchForm(["binary"], "0b1010");
runtimeConfig.patchForm(["explicitPlus"], "+5");
runtimeConfig.patchForm(["separator"], "1_000");
runtimeConfig.patchForm(["nonFinite"], "Infinity");
runtimeConfig.patchForm(["scientific"], "-2.5E-3");
runtimeConfig.patchForm(["decimal"], ".5");
runtimeConfig.patchForm(["fractionalInteger"], "42.5");
runtimeConfig.patchForm(["unionRadix"], "0o17");
runtimeConfig.patchForm(["unionScientific"], "1e5");
await expect(runtimeConfig.save()).resolves.toBe(true);
const submission = submitted.find((entry) => entry.method === "config.set");
const raw = (submission?.params as { raw?: unknown } | undefined)?.raw;
expect(typeof raw).toBe("string");
expect(JSON.parse(raw as string)).toEqual({
hex: "0x10",
binary: "0b1010",
explicitPlus: "+5",
separator: "1_000",
nonFinite: "Infinity",
scientific: -0.0025,
decimal: 0.5,
fractionalInteger: "42.5",
unionRadix: "0o17",
unionScientific: 100_000,
});
runtimeConfig.dispose();
});
it("stages inherited agent overrides and the default through the public capability", async () => {
const submitted: Array<{ method: string; params: unknown }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
sourceConfig: {
agents: {
entries: {
MAIN: {},
reviewer: { default: true },
},
},
},
hash: "hash-1",
valid: true,
issues: [],
};
}
submitted.push({ method, params });
return { hash: "hash-2" };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
const newAgent = runtimeConfig.agentEntry("new-agent", { ensure: true });
expect(newAgent).toEqual({
path: ["agents", "entries", "new-agent"],
entry: {},
});
runtimeConfig.patchForm([...newAgent!.path, "model"], "openai/gpt-5.4");
expect(runtimeConfig.stageDefaultAgent("main")).toBe(true);
expect(runtimeConfig.state.configForm).toEqual({
agents: {
entries: {
MAIN: { default: true },
reviewer: {},
"new-agent": { model: "openai/gpt-5.4" },
},
},
});
await expect(runtimeConfig.save()).resolves.toBe(true);
const raw = (
submitted.find((entry) => entry.method === "config.set")?.params as
| { raw?: unknown }
| undefined
)?.raw;
expect(JSON.parse(String(raw))).toEqual({
agents: {
entries: {
MAIN: { default: true },
reviewer: {},
"new-agent": { model: "openai/gpt-5.4" },
},
},
});
expect(JSON.parse(String(raw)).agents).not.toHaveProperty("list");
runtimeConfig.dispose();
});
it("refuses to create blocked agent entry paths", async () => {
const request = vi.fn(async (method: string) =>
method === "config.get"
? {
sourceConfig: { agents: { entries: { main: { default: true } } } },
hash: "hash-1",
valid: true,
issues: [],
}
: {},
);
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.agentEntry("__proto__", { ensure: true })).toBeNull();
expect(runtimeConfig.agentEntry(" ", { ensure: true })).toBeNull();
expect(runtimeConfig.state.configForm).toEqual({
agents: { entries: { main: { default: true } } },
});
runtimeConfig.dispose();
});
it.each([
["automatic save", "123"],
["automatic save", "z.ai"],
["automatic save", "a.models.3"],
["manual save", "123"],
["manual save", "z.ai"],
["manual save", "a.models.3"],
["manual save", "$&"],
["apply", "123"],
["apply", "z.ai"],
["apply", "a.models.3"],
] as const)(
"formats the rejected %s validation path for provider %s without changing the Gateway issue",
async (operation, providerId) => {
vi.useFakeTimers();
const issue = {
path: `models.providers.${providerId}.models.3.name`,
message: "Invalid model name",
};
const rejection = new GatewayRequestError({
code: "INVALID_REQUEST",
message: `invalid config: ${issue.path}: ${issue.message}`,
details: { issues: [issue] },
});
const config = {
models: {
providers: {
[providerId]: {
models: [
{ name: "First" },
{ name: "Second" },
{ name: "Third" },
{ name: "Fourth" },
],
},
},
},
};
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config, raw: JSON.stringify(config), hash: "hash-1", valid: true, issues: [] };
}
if (method === "config.set" || method === "config.apply") {
throw rejection;
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configSchema).toBeNull();
if (operation === "automatic save") {
runtimeConfig.patchForm(["models", "providers", providerId, "models", 3, "name"], "");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
} else if (operation === "manual save") {
runtimeConfig.patchForm(["models", "providers", providerId, "models", 3, "name"], "");
await expect(runtimeConfig.save()).resolves.toBe(false);
} else {
await expect(runtimeConfig.apply()).resolves.toBe(false);
}
expect(runtimeConfig.state.configSnapshot?.valid).toBe(true);
expect(runtimeConfig.state.configIssues).toEqual([]);
expect(runtimeConfig.state.lastError).toBe(
`GatewayRequestError: invalid config: models.providers.${providerId}.models.#4.name: Invalid model name`,
);
expect(issue.path).toBe(`models.providers.${providerId}.models.3.name`);
expect(rejection.message).toBe(
`invalid config: models.providers.${providerId}.models.3.name: Invalid model name`,
);
expect(rejection.details).toEqual({ issues: [issue] });
runtimeConfig.dispose();
},
);
it("preserves raw validation paths when dotted draft keys have multiple interpretations", async () => {
const issue = {
path: "models.providers.a.models.3.name",
message: "Invalid model name",
};
const config = {
models: {
providers: {
a: {
models: [{ name: "First" }, { name: "Second" }, { name: "Third" }, { name: "Fourth" }],
},
"a.models.3.name": { models: [{ name: "Ambiguous provider" }] },
},
},
};
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config, raw: JSON.stringify(config), hash: "hash-1", valid: true, issues: [] };
}
if (method === "config.set") {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message: `invalid config: ${issue.path}: ${issue.message}`,
details: { issues: [issue] },
});
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["models", "providers", "a", "models", 3, "name"], "");
await expect(runtimeConfig.save()).resolves.toBe(false);
expect(runtimeConfig.state.lastError).toBe(
"GatewayRequestError: invalid config: models.providers.a.models.3.name: Invalid model name",
);
expect(issue.path).toBe("models.providers.a.models.3.name");
runtimeConfig.dispose();
});
it("rewrites only complete validation fields when issue paths overlap", async () => {
const issues = [
{ path: "foo.items.0.name", message: "Invalid numeric record" },
{ path: "items.0.name", message: "Invalid array item" },
];
const config = {
foo: { items: { "0": { name: "Record" } } },
items: [{ name: "Array" }],
};
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config, raw: JSON.stringify(config), hash: "hash-1", valid: true, issues: [] };
}
if (method === "config.set") {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message:
"invalid config: foo.items.0.name: Invalid numeric record; items.0.name: Invalid array item",
details: { issues },
});
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["items", 0, "name"], "");
await expect(runtimeConfig.save()).resolves.toBe(false);
expect(runtimeConfig.state.lastError).toBe(
"GatewayRequestError: invalid config: foo.items.0.name: Invalid numeric record; items.#1.name: Invalid array item",
);
expect(issues.map((issue) => issue.path)).toEqual(["foo.items.0.name", "items.0.name"]);
runtimeConfig.dispose();
});
it("preserves machine-indexed validation paths for raw-editor submissions", async () => {
const issue = { path: "models.3.name", message: "Invalid model name" };
const config = {
models: [{ name: "First" }, { name: "Second" }, { name: "Third" }, { name: "Fourth" }],
};
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config, raw: JSON.stringify(config), hash: "hash-1", valid: true, issues: [] };
}
if (method === "config.set") {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message: `invalid config: ${issue.path}: ${issue.message}`,
details: { issues: [issue] },
});
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.setRaw(
JSON.stringify({
models: [{ name: "First" }, { name: "Second" }, { name: "Third" }, { name: "" }],
}),
);
await expect(runtimeConfig.save()).resolves.toBe(false);
expect(runtimeConfig.state.configFormMode).toBe("raw");
expect(runtimeConfig.state.lastError).toBe(
"GatewayRequestError: invalid config: models.3.name: Invalid model name",
);
runtimeConfig.dispose();
});
it.each(["automatic save", "manual save", "apply"] as const)(
"formats rejected %s paths against the submitted draft after a mid-flight edit",
async (operation) => {
vi.useFakeTimers();
const pendingWrite = deferred<unknown>();
const issue = {
path: "models.providers.a.models.3.models.3.name",
message: "Invalid model name",
};
const config = {
models: {
providers: {
"a.models.3": {
models: [
{ name: "First" },
{ name: "Second" },
{ name: "Third" },
{ name: "Fourth" },
],
},
},
},
};
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({
config,
raw: JSON.stringify(config),
hash: "hash-1",
valid: true,
issues: [],
});
}
if (method === "config.set" || method === "config.apply") {
return pendingWrite.promise;
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
let write: Promise<boolean> | undefined;
if (operation === "automatic save") {
runtimeConfig.patchForm(["models", "providers", "a.models.3", "models", 3, "name"], "");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
} else if (operation === "manual save") {
runtimeConfig.patchForm(["models", "providers", "a.models.3", "models", 3, "name"], "");
write = runtimeConfig.save();
} else {
write = runtimeConfig.apply();
}
expect(request).toHaveBeenCalledWith(
operation === "apply" ? "config.apply" : "config.set",
expect.any(Object),
);
runtimeConfig.patchForm(["models", "providers", "a"], {
models: [{}, {}, {}, { models: [{}, {}, {}, { name: "Overlapping provider" }] }],
});
pendingWrite.reject(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: `invalid config: ${issue.path}: ${issue.message}`,
details: { issues: [issue] },
}),
);
if (write) {
await expect(write).resolves.toBe(false);
} else {
await vi.advanceTimersByTimeAsync(0);
}
expect(runtimeConfig.state.lastError).toBe(
"GatewayRequestError: invalid config: models.providers.a.models.3.models.#4.name: Invalid model name",
);
runtimeConfig.dispose();
},
);
it("resets a stale Saved/error status as soon as a new edit lands", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.patchForm(["count"], 3);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
// Raw edits reset the indicator too.
runtimeConfig.setRaw('{\n "count": 9\n}\n');
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
runtimeConfig.dispose();
});
it("applies a clean snapshot's raw bytes verbatim instead of reserializing", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
// Hand-formatted (but JSON-parseable) raw that serializeConfigForm would
// rewrite into pretty-printed two-space form.
const rawDraft = '{"count":9,"keepFormatting":true}\n';
runtimeConfig.setRaw(rawDraft);
const savePromise = runtimeConfig.save();
await vi.advanceTimersByTimeAsync(0);
await expect(savePromise).resolves.toBe(true);
expect(server.submissions[0]?.raw).toBe(rawDraft);
// The banner's apply must not destroy the formatting that was just saved.
await expect(runtimeConfig.apply()).resolves.toBe(true);
expect(server.submissions[1]).toMatchObject({ method: "config.apply", raw: rawDraft });
runtimeConfig.dispose();
});
it("merges a form patch on top of a parseable dirty raw draft", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.setRaw('{\n "count": 1,\n "rawOnly": true\n}\n');
expect(runtimeConfig.state.configFormMode).toBe("raw");
// A Quick Settings patch lands on the shared capability: it must build on
// the parsed raw draft instead of the stale form.
runtimeConfig.patchForm(["count"], 7);
expect(runtimeConfig.state.configFormMode).toBe("form");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(server.submissions).toHaveLength(1);
expect(JSON.parse(server.submissions[0]?.raw ?? "{}")).toEqual({ count: 7, rawOnly: true });
runtimeConfig.dispose();
});
it("refuses form patches while an unparseable raw draft is pending", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const brokenRaw = '{\n "count": broken';
runtimeConfig.setRaw(brokenRaw);
runtimeConfig.patchForm(["count"], 7);
// The raw draft stays authoritative; the form edit is rejected loudly.
expect(runtimeConfig.state.configRaw).toBe(brokenRaw);
expect(runtimeConfig.state.configFormMode).toBe("raw");
expect(runtimeConfig.state.configAutoSaveStatus).toBe("error");
expect(runtimeConfig.state.lastError).toContain("Raw editor");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2);
expect(server.submissions).toHaveLength(0);
runtimeConfig.dispose();
});
it("clears a failure status and error when a mutation reverts the draft clean", async () => {
vi.useFakeTimers();
let setCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.set") {
setCalls += 1;
throw new Error("disk full");
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("error");
// Reverting to the original makes the failure moot.
runtimeConfig.patchForm(["count"], 1);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
expect(runtimeConfig.state.lastError).toBeNull();
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2);
expect(setCalls).toBe(1);
runtimeConfig.dispose();
});
it("keeps the conflict status until reload even when the draft reverts clean", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.set") {
throw new Error("config changed since last load; re-run config.get and retry");
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
// The snapshot is known stale; local cleanliness cannot clear that.
runtimeConfig.patchForm(["count"], 1);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
await runtimeConfig.refresh({ discardPendingChanges: true });
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
runtimeConfig.dispose();
});
it("discards offline drafts locally instead of no-op refreshing", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig, publish } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const originalRaw = runtimeConfig.state.configRawOriginal;
publish(false);
runtimeConfig.setRaw('{\n "count": 9\n}\n');
expect(runtimeConfig.state.configFormDirty).toBe(true);
await runtimeConfig.discardDraft();
expect(runtimeConfig.state.configRaw).toBe(originalRaw);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
expect(runtimeConfig.state.lastError).toBeNull();
// Connected discards still reload from disk.
publish(true);
runtimeConfig.patchForm(["count"], 4);
const getCallsBefore = server.request.mock.calls.filter(
([method]) => method === "config.get",
).length;
await runtimeConfig.discardDraft();
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(server.request.mock.calls.filter(([method]) => method === "config.get").length).toBe(
getCallsBefore + 1,
);
runtimeConfig.dispose();
});
it("refuses apply while a raw draft is dirty", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.setRaw('{\n "count": 9\n}\n');
await expect(runtimeConfig.apply()).resolves.toBe(false);
// Raw stays explicit-save-only: nothing was written, the user is told to
// resolve the raw draft first.
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("error");
expect(runtimeConfig.state.lastError).toContain("Raw editor");
runtimeConfig.dispose();
});
it("preserves JSON5 raw text when config.patch reports a no-op", async () => {
const originalRaw = "{\n // keep this operator note\n count: 1,\n}\n";
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: originalRaw,
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.patch") {
return { config: { count: 1 }, noop: true };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await expect(
runtimeConfig.patch({ raw: { count: 1 }, note: "no-op test patch" }),
).resolves.toBe(true);
expect(runtimeConfig.state.configSnapshot?.raw).toBe(originalRaw);
expect(runtimeConfig.state.configRaw).toBe(originalRaw);
runtimeConfig.dispose();
});
it("never auto-saves raw-text drafts and submits them on manual save", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const rawDraft = '{\n "count": 9,\n "handEdited": true\n}\n';
runtimeConfig.setRaw(rawDraft);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2);
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
// Manual save must submit the raw bytes, not the stale form serialization.
const savePromise = runtimeConfig.save();
await vi.advanceTimersByTimeAsync(0);
await expect(savePromise).resolves.toBe(true);
expect(server.submissions[0]?.raw).toBe(rawDraft);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
});
+628
View File
@@ -0,0 +1,628 @@
import {
asNullableRecord as asConfigRecord,
isRecord,
} from "@openclaw/normalization-core/record-coerce";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { ConfigSnapshot } from "../../api/types.ts";
import { coerceConfigFormNumberString } from "../../components/config-form.numeric.ts";
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
import { t } from "../../i18n/index.ts";
import {
cloneConfigObject,
removePathValue,
sanitizeRedactedFormForSubmit,
serializeConfigForm,
setPathValue,
} from "../config-form-utils.ts";
import { parseJson5Text, warmJson5 } from "../json5-runtime.ts";
import {
resolveAgentConfigEntryTarget,
resolveEditableSnapshotConfig,
type LoadConfigOptions,
type RuntimeConfigState,
} from "./config-state-model.ts";
const autoAllowlistedPluginIdsByState = new WeakMap<RuntimeConfigState, Set<string>>();
export function clearConfigDraftTracking(state: RuntimeConfigState): void {
autoAllowlistedPluginIdsByState.delete(state);
}
export function formatConfigMutationError(error: unknown, submittedRaw: string | null): string {
let message = String(error);
if (!submittedRaw || !(error instanceof GatewayRequestError) || !isRecord(error.details)) {
return message;
}
const issues = error.details.issues;
if (!Array.isArray(issues)) {
return message;
}
const summaryPrefix = `${error.name}: invalid config: `;
if (!message.startsWith(summaryPrefix)) {
return message;
}
const submittedForm = parseConfigRawDraft(submittedRaw);
if (!submittedForm) {
return message;
}
let offset = summaryPrefix.length;
for (const issue of issues) {
if (
!isRecord(issue) ||
typeof issue.path !== "string" ||
typeof issue.message !== "string" ||
!message.startsWith(`${issue.path}: ${issue.message}`, offset)
) {
break;
}
const displayPath = formatConfigIssuePathFromDraft(issue.path, submittedForm);
if (displayPath !== issue.path) {
message = `${message.slice(0, offset)}${displayPath}${message.slice(offset + issue.path.length)}`;
}
offset += displayPath.length + 2 + issue.message.length;
if (!message.startsWith("; ", offset)) {
break;
}
offset += 2;
}
return message;
}
function formatConfigIssuePathFromDraft(path: string, draft: unknown): string {
const segments = path.split(".");
const visit = (value: unknown, offset: number): string[] => {
if (offset === segments.length) {
return [""];
}
const segment = segments[offset];
if (segment === undefined) {
return [];
}
if (Array.isArray(value)) {
const index = Number(segment);
if (!/^\d+$/u.test(segment) || !Number.isSafeInteger(index) || !Object.hasOwn(value, index)) {
return [segments.slice(offset).join(".")];
}
return visit(value[index], offset + 1).map((tail) =>
tail ? `#${index + 1}.${tail}` : `#${index + 1}`,
);
}
if (!isRecord(value)) {
return [segments.slice(offset).join(".")];
}
const matches = Object.keys(value).flatMap((key) => {
const keySegments = key.split(".");
if (
keySegments.length > segments.length - offset ||
!keySegments.every((keySegment, index) => keySegment === segments[offset + index])
) {
return [];
}
return visit(value[key], offset + keySegments.length).map((tail) =>
tail ? `${key}.${tail}` : key,
);
});
return matches.length > 0 ? matches : [segments.slice(offset).join(".")];
};
// Flattened Gateway paths cannot distinguish overlapping dotted object keys;
// keep the machine path unless the actual draft proves one display spelling.
const displayPaths = new Set(visit(draft, 0));
return displayPaths.size === 1 ? (displayPaths.values().next().value ?? path) : path;
}
export function applyConfigSnapshot(
state: RuntimeConfigState,
snapshot: ConfigSnapshot,
options: LoadConfigOptions = {},
) {
const preservePendingChanges = state.configFormDirty && options.discardPendingChanges !== true;
if (options.discardPendingChanges === true) {
// Discard resets pending edits and stale save status, but NOT the restart
// banner: a saved-but-unapplied config still needs an apply even after
// the local draft is thrown away.
state.configAutoSaveStatus = "idle";
}
const currentRevisionHash = snapshot.configRevisionHash ?? snapshot.hash ?? null;
if (snapshot.appliedConfigHash !== undefined) {
state.configNeedsApply = currentRevisionHash !== snapshot.appliedConfigHash;
}
const draftBaseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash ?? null;
state.configSnapshot = snapshot;
const editableConfig = resolveEditableSnapshotConfig(snapshot);
const rawAvailable =
typeof snapshot.raw === "string" || Boolean(editableConfig) || Boolean(state.configForm);
if (!rawAvailable && state.configFormMode === "raw") {
state.configFormMode = "form";
}
const rawFromSnapshot: string =
typeof snapshot.raw === "string"
? snapshot.raw
: editableConfig
? serializeConfigForm(editableConfig)
: state.configRaw;
if (!preservePendingChanges) {
state.configRaw = rawFromSnapshot;
} else if (state.configFormMode !== "raw" && state.configForm) {
state.configRaw = serializeConfigForm(state.configForm);
} else if (state.configFormMode !== "raw") {
state.configRaw = rawFromSnapshot;
}
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
if (!preservePendingChanges) {
state.configForm = cloneConfigObject(editableConfig ?? {});
state.configFormOriginal = cloneConfigObject(editableConfig ?? {});
setConfigRawOriginal(state, rawFromSnapshot);
state.configFormDirty = false;
state.configFormMode = "form";
state.configDraftBaseHash = snapshot.hash ?? null;
autoAllowlistedPluginIdsByState.delete(state);
} else {
state.configDraftBaseHash = draftBaseHash;
}
}
function coerceBooleanString(value: string): boolean | string {
const trimmed = value.trim();
if (trimmed === "true") {
return true;
}
if (trimmed === "false") {
return false;
}
return value;
}
function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
if (value === null || value === undefined) {
return value;
}
if (schema.allOf && schema.allOf.length > 0) {
const { allOf, ...baseSchema } = schema;
let next: unknown = coerceFormValues(value, baseSchema);
for (const segment of allOf) {
next = coerceFormValues(next, segment);
}
return next;
}
const type = schemaType(schema);
if (schema.anyOf || schema.oneOf) {
const variants = (schema.anyOf ?? schema.oneOf ?? []).filter(
(variant) =>
!(
variant.type === "null" ||
(Array.isArray(variant.type) && variant.type.includes("null"))
),
);
if (variants.length === 1) {
const variant = variants[0];
return variant ? coerceFormValues(value, variant) : value;
}
if (typeof value === "string") {
for (const variant of variants) {
const variantType = schemaType(variant);
if (variantType === "number" || variantType === "integer") {
const coerced = coerceConfigFormNumberString(value, variantType === "integer");
if (coerced === undefined || typeof coerced === "number") {
return coerced;
}
}
if (variantType === "boolean") {
const coerced = coerceBooleanString(value);
if (typeof coerced === "boolean") {
return coerced;
}
}
}
}
for (const variant of variants) {
const variantType = schemaType(variant);
if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) {
return coerceFormValues(value, variant);
}
if (variantType === "array" && Array.isArray(value)) {
return coerceFormValues(value, variant);
}
}
return value;
}
if (type === "number" || type === "integer") {
if (typeof value === "string") {
const coerced = coerceConfigFormNumberString(value, type === "integer");
if (coerced === undefined || typeof coerced === "number") {
return coerced;
}
}
return value;
}
if (type === "boolean") {
if (typeof value === "string") {
const coerced = coerceBooleanString(value);
if (typeof coerced === "boolean") {
return coerced;
}
}
return value;
}
if (type === "string") {
return typeof value === "string" && value.length === 0 && schema.minLength ? undefined : value;
}
if (type === "object") {
if (typeof value !== "object" || Array.isArray(value)) {
return value;
}
const props = schema.properties ?? {};
const additional =
schema.additionalProperties && typeof schema.additionalProperties === "object"
? schema.additionalProperties
: null;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const propSchema = props[key] ?? additional;
const coerced = propSchema ? coerceFormValues(val, propSchema) : val;
if (coerced !== undefined) {
result[key] = coerced;
}
}
return result;
}
if (type === "array") {
if (!Array.isArray(value)) {
return value;
}
const items = schema.items;
if (Array.isArray(items)) {
return value.map((item, index) => {
const itemSchema = index < items.length ? items[index] : undefined;
return itemSchema ? coerceFormValues(item, itemSchema) : item;
});
}
return items
? value.map((item) => coerceFormValues(item, items)).filter((item) => item !== undefined)
: value;
}
return value;
}
/**
* Serialize the form state for submission to `config.set` / `config.apply`.
*
* HTML `<input>` elements produce string `.value` properties, so numeric and
* boolean config fields can leak into `configForm` as strings. We coerce
* them back to their schema-defined types before JSON serialization so the
* gateway's Zod validation always sees correctly typed values.
*/
export function serializeFormForSubmit(state: RuntimeConfigState): string {
// A clean snapshot submits its raw bytes verbatim: reserializing the parsed
// form would destroy JSON5 comments/formatting the file already has (the
// restart banner's apply right after a raw-mode save hits exactly this).
if (!state.configFormDirty && typeof state.configSnapshot?.raw === "string") {
return state.configSnapshot.raw;
}
if (state.configFormMode !== "form" || !state.configForm) {
return state.configRaw;
}
const schema = isRecord(state.configSchema) ? (state.configSchema as JsonSchema) : null;
const form = schema
? (coerceFormValues(state.configForm, schema) as Record<string, unknown>)
: state.configForm;
const sanitized = sanitizeRedactedFormForSubmit(
form,
state.configFormOriginal,
state.configRawOriginalParsed,
);
return serializeConfigForm(sanitized);
}
/**
* Adopts a successful write ack as the authoritative local snapshot BEFORE
* any reload: the submitted bytes are on disk under the acked hash, so the
* raw/hash/originals must never keep describing the pre-save file (a failed
* best-effort reload would otherwise leave stale-bytes paths alive — e.g.
* apply re-submitting the old raw, or a revert-during-reload comparing
* clean). Server-resolved values (secret redaction) still refresh via the
* follow-up reload, which is purely cosmetic from here on.
*/
export function adoptConfigSetAck(
state: RuntimeConfigState,
submittedRaw: string,
ackHash: string | null,
) {
const parsed = parseConfigRawDraft(submittedRaw);
state.configSnapshot = {
...state.configSnapshot,
raw: submittedRaw,
hash: ackHash ?? state.configSnapshot?.hash ?? null,
valid: true,
issues: [],
...(parsed ? { config: parsed, sourceConfig: parsed } : {}),
};
state.configValid = true;
state.configIssues = [];
setConfigRawOriginal(state, submittedRaw);
if (parsed) {
state.configFormOriginal = cloneConfigObject(parsed);
}
state.configDraftBaseHash = ackHash;
if (!state.configFormDirty) {
// Clean drafts snap to the persisted bytes, mirroring what a reload's
// non-preserving snapshot application would do.
state.configRaw = submittedRaw;
if (parsed) {
state.configForm = cloneConfigObject(parsed);
}
}
}
// Legacy hashless ack: when the follow-up reload returns exactly the submitted
// bytes, rebase a preserved dirty draft onto that authoritative hash. Foreign
// content matches neither and stays fail-closed.
export function reconcileHashlessWriteReload(state: RuntimeConfigState, submittedRaw: string) {
if (state.configSnapshot?.raw !== submittedRaw) {
return;
}
const hash = state.configSnapshot.hash ?? null;
if (state.configFormDirty) {
state.configDraftBaseHash = hash ?? state.configDraftBaseHash;
}
}
function syncConfigDraft(state: RuntimeConfigState, nextForm: Record<string, unknown>) {
const original = cloneConfigObject(
state.configFormOriginal ?? resolveEditableSnapshotConfig(state.configSnapshot) ?? {},
);
const nextRaw = serializeConfigForm(nextForm);
const originalRaw = serializeConfigForm(original);
state.configForm = nextForm;
state.configRaw = nextRaw;
state.configFormDirty = nextRaw !== originalRaw;
// configFormMode tracks which draft is authoritative for submission; a form
// edit supersedes any earlier raw-text draft.
state.configFormMode = "form";
resetStaleAutoSaveStatus(state);
}
/**
* Any mutation invalidates a lingering "Saved"/"Save failed" indicator: a
* dirty edit is about to reschedule, and a clean revert makes the old
* failure moot (its error is cleared too). Two states persist regardless:
* "saving" reports the in-flight request, and "conflict" marks the snapshot
* itself stale — only a reload clears it, no local edit can.
*/
function resetStaleAutoSaveStatus(state: RuntimeConfigState) {
if (state.configAutoSaveStatus === "saving" || state.configAutoSaveStatus === "conflict") {
return;
}
if (!state.configFormDirty && state.configAutoSaveStatus === "error") {
state.lastError = null;
}
state.configAutoSaveStatus = "idle";
}
function parseConfigRawDraft(raw: string): Record<string, unknown> | null {
try {
const parsed = parseJson5Text(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
// Parse the authoritative raw once at ingestion so submit-time sanitizing
// stays synchronous and never races the lazy JSON5 parser. Submit paths await
// configRawOriginalParsePending so a JSON5 config racing the first parser load
// cannot bypass redaction sanitizing.
function setConfigRawOriginal(state: RuntimeConfigState, raw: string) {
state.configRawOriginal = raw;
state.configRawOriginalParsePending = null;
try {
state.configRawOriginalParsed = asConfigRecord(parseJson5Text(raw));
return;
} catch {
state.configRawOriginalParsed = null;
}
const pending = warmJson5()
.then((json5) => {
if (state.configRawOriginal !== raw || state.configRawOriginalParsePending !== pending) {
return;
}
try {
state.configRawOriginalParsed = asConfigRecord(json5.parse(raw));
} catch {
state.configRawOriginalParsed = null;
}
})
// Never-rejecting and self-clearing: submit gates await this promise, and
// a failed chunk load must not wedge every later save of this state.
.catch(() => undefined)
.finally(() => {
if (state.configRawOriginalParsePending === pending) {
state.configRawOriginalParsePending = null;
}
});
state.configRawOriginalParsePending = pending;
}
function mutateConfigForm(
state: RuntimeConfigState,
mutate: (draft: Record<string, unknown>) => void,
) {
let base: Record<string, unknown>;
if (state.configFormDirty && state.configFormMode === "raw") {
// A dirty raw draft is authoritative. Form patches (Quick Settings shares
// this capability) may only apply on top of its parsed content — building
// on the stale parsed form would silently destroy the raw edits.
// Contract: merging onto the parsed raw draft is intentional — content is
// preserved, but the unsaved raw draft's formatting/comments are not once
// form editing resumes.
const parsedRawDraft = parseConfigRawDraft(state.configRaw);
if (!parsedRawDraft) {
// Unparseable raw draft: refuse the form edit and tell the user to
// resolve the raw buffer first; the raw draft stays authoritative.
state.configAutoSaveStatus = "error";
state.lastError = t("configView.rawDraftBlocksFormEdit");
return;
}
base = parsedRawDraft;
} else {
base = cloneConfigObject(
state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot) ?? {},
);
}
mutate(base);
syncConfigDraft(state, base);
}
function trackAutoAllowlistedPluginId(state: RuntimeConfigState, pluginId: string) {
const pluginIds = autoAllowlistedPluginIdsByState.get(state);
if (pluginIds) {
pluginIds.add(pluginId);
} else {
autoAllowlistedPluginIdsByState.set(state, new Set([pluginId]));
}
}
function untrackAutoAllowlistedPluginId(state: RuntimeConfigState, pluginId: string) {
const pluginIds = autoAllowlistedPluginIdsByState.get(state);
if (!pluginIds) {
return;
}
pluginIds.delete(pluginId);
if (pluginIds.size === 0) {
autoAllowlistedPluginIdsByState.delete(state);
}
}
function syncEnabledPluginAllowlist(
state: RuntimeConfigState,
draft: Record<string, unknown>,
path: Array<string | number>,
value: unknown,
) {
if (
path.length !== 4 ||
path[0] !== "plugins" ||
path[1] !== "entries" ||
typeof path[2] !== "string" ||
path[3] !== "enabled"
) {
return;
}
const pluginId = path[2];
const plugins =
draft.plugins && typeof draft.plugins === "object" && !Array.isArray(draft.plugins)
? (draft.plugins as Record<string, unknown>)
: null;
const allow = Array.isArray(plugins?.allow) ? plugins.allow : null;
if (!allow) {
untrackAutoAllowlistedPluginId(state, pluginId);
return;
}
if (value === true) {
if (allow.includes(pluginId)) {
return;
}
if (allow.length === 0) {
untrackAutoAllowlistedPluginId(state, pluginId);
return;
}
setPathValue(draft, ["plugins", "allow"], [...allow, pluginId]);
trackAutoAllowlistedPluginId(state, pluginId);
return;
}
const autoAllowlistedPluginIds = autoAllowlistedPluginIdsByState.get(state);
if (!autoAllowlistedPluginIds?.has(pluginId)) {
return;
}
setPathValue(
draft,
["plugins", "allow"],
allow.filter((entry) => entry !== pluginId),
);
untrackAutoAllowlistedPluginId(state, pluginId);
}
export function updateConfigFormValue(
state: RuntimeConfigState,
path: Array<string | number>,
value: unknown,
) {
mutateConfigForm(state, (draft) => {
setPathValue(draft, path, value);
if (path[0] === "plugins" && path[1] === "allow") {
autoAllowlistedPluginIdsByState.delete(state);
return;
}
syncEnabledPluginAllowlist(state, draft, path, value);
});
}
export function updateConfigRawValue(state: RuntimeConfigState, value: string) {
// Raw drafts may carry JSON5 comments; warm the parser before any
// mutateConfigForm/diff path needs it synchronously.
void warmJson5().catch(() => undefined);
state.configRaw = value;
// A raw-text edit becomes the authoritative draft; without this,
// serializeFormForSubmit would submit the stale form and drop raw edits.
state.configFormMode = "raw";
state.configFormDirty = value !== state.configRawOriginal;
resetStaleAutoSaveStatus(state);
if (state.configFormDirty) {
state.configDraftBaseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash ?? null;
} else {
state.configDraftBaseHash = state.configSnapshot?.hash ?? null;
}
}
export function resetConfigPendingChanges(state: RuntimeConfigState) {
const editableConfig = resolveEditableSnapshotConfig(state.configSnapshot);
state.configForm = cloneConfigObject(state.configFormOriginal ?? editableConfig ?? {});
state.configRaw =
state.configRawOriginal ??
serializeConfigForm(state.configFormOriginal ?? editableConfig ?? {});
state.configFormDirty = false;
state.configFormMode = "form";
state.configDraftBaseHash = state.configSnapshot?.hash ?? null;
autoAllowlistedPluginIdsByState.delete(state);
}
export function removeConfigFormValue(state: RuntimeConfigState, path: Array<string | number>) {
mutateConfigForm(state, (draft) => removePathValue(draft, path));
}
export function stageDefaultAgentConfigEntry(state: RuntimeConfigState, agentId: string): boolean {
const source = state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot);
const target = resolveAgentConfigEntryTarget(source, agentId);
if (!target) {
return false;
}
const authoredAgentId = target.path[2];
mutateConfigForm(state, (draft) => {
const agents = isRecord(draft.agents) ? draft.agents : null;
const entries = isRecord(agents?.entries) ? agents.entries : null;
if (!entries) {
return;
}
for (const [id, entry] of Object.entries(entries)) {
if (!isRecord(entry)) {
continue;
}
if (id === authoredAgentId) {
entry.default = true;
} else {
delete entry.default;
}
}
});
return true;
}
@@ -0,0 +1,646 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSnapshot } from "../../api/types.ts";
import {
CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS,
deferred,
createGatewayHarness,
createConfigServerMock,
createDeferredSetServerMock,
createConfigCapabilityHarness,
} from "./config-test-harness.ts";
import { createRuntimeConfigCapability } from "./runtime-config-capability.ts";
describe("config gateway operations", () => {
it("copies the config path when opening the file fails", async () => {
const writeText = vi.fn(async () => undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator);
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: {},
hash: "hash-1",
path: "/tmp/openclaw.json",
valid: true,
issues: [],
};
}
if (method === "config.openFile") {
return { ok: false, error: "not supported", path: "/tmp/openclaw.json" };
}
return {};
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
await runtimeConfig.openFile();
expect(writeText).toHaveBeenCalledWith("/tmp/openclaw.json");
expect(runtimeConfig.state.lastError).toContain("File path copied to clipboard");
runtimeConfig.dispose();
});
it("reports a base-hash conflict distinctly and recovers via discarding reload", async () => {
vi.useFakeTimers();
let rejectSet = true;
const server = createConfigServerMock();
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.set" && rejectSet) {
// Exact gateway contract message from requireConfigBaseHash
// (src/gateway/server-methods/config.ts).
throw new Error("config changed since last load; re-run config.get and retry");
}
return server.request(method, params);
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
expect(runtimeConfig.state.configFormDirty).toBe(true);
// No auto-rebase-and-retry: the whole-form draft would clobber the other writer.
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 5);
expect(server.submissions).toHaveLength(0);
// The Reload affordance discards the local draft and re-syncs from disk.
rejectSet = false;
await runtimeConfig.refresh({ discardPendingChanges: true });
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
expect(runtimeConfig.state.configFormDirty).toBe(false);
runtimeConfig.patchForm(["count"], 3);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(server.submissions).toEqual([
{ method: "config.set", raw: '{\n "count": 3\n}\n', baseHash: "hash-1" },
]);
runtimeConfig.dispose();
});
it("does not report Saved while edits made during the reload are still dirty", async () => {
vi.useFakeTimers();
let hashCounter = 1;
let storedRaw = '{\n "count": 1\n}\n';
let deferReload: ReturnType<typeof deferred<unknown>> | null = null;
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
const response = {
config: JSON.parse(storedRaw) as Record<string, unknown>,
raw: storedRaw,
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
};
if (deferReload) {
const pending = deferReload;
deferReload = null;
return pending.promise.then(() => response);
}
return Promise.resolve(response);
}
if (method === "config.set") {
storedRaw = (params as { raw: string }).raw;
hashCounter += 1;
return Promise.resolve({ hash: `hash-${hashCounter}` });
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const reloadGate = deferred<unknown>();
deferReload = reloadGate;
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
// config.set acked; the post-save reload is held open while a new edit lands.
runtimeConfig.patchForm(["count"], 3);
reloadGate.resolve({});
await vi.advanceTimersByTimeAsync(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.dispose();
});
it("keeps process-local needsApply when the post-save reload fails", async () => {
vi.useFakeTimers();
let failReloads = false;
let hashCounter = 1;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
if (failReloads) {
throw new Error("gateway went away");
}
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: `hash-${hashCounter}`,
configRevisionHash: `hash-${hashCounter}`,
appliedConfigHash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.set") {
hashCounter += 1;
return { hash: `hash-${hashCounter}` };
}
return {};
});
const first = createConfigCapabilityHarness(request as GatewayBrowserClient["request"]);
await first.runtimeConfig.ensureLoaded();
failReloads = true;
first.runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(first.runtimeConfig.state.configNeedsApply).toBe(true);
first.runtimeConfig.dispose();
failReloads = false;
const second = createConfigCapabilityHarness(request as GatewayBrowserClient["request"]);
await second.runtimeConfig.ensureLoaded();
expect(second.runtimeConfig.state.configNeedsApply).toBe(true);
second.runtimeConfig.dispose();
});
it("keeps saving against the ack hash while reloads fail", async () => {
vi.useFakeTimers();
let failReloads = false;
let hashCounter = 1;
const submissions: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
if (failReloads) {
throw new Error("gateway offline");
}
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
};
}
if (method === "config.set") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
submissions.push({ raw, baseHash });
hashCounter += 1;
return { hash: `hash-${hashCounter}` };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
failReloads = true;
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
runtimeConfig.patchForm(["count"], 3);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
// Each save chains onto the previous ack hash; the failed best-effort
// reloads never block or self-conflict the flow.
expect(submissions.map((entry) => entry.baseHash)).toEqual(["hash-1", "hash-2"]);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.dispose();
});
it("applies the acked bytes when the post-save reload failed", async () => {
vi.useFakeTimers();
let failReloads = false;
let hashCounter = 1;
const applySubmissions: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
if (failReloads) {
throw new Error("gateway offline");
}
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
};
}
if (method === "config.set") {
hashCounter += 1;
return { hash: `hash-${hashCounter}` };
}
if (method === "config.apply") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
applySubmissions.push({ raw, baseHash });
hashCounter += 1;
return { hash: `hash-${hashCounter}` };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
failReloads = true;
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
// The ack made the submitted bytes the local snapshot; apply must submit
// them, not the pre-save file the failed reload left behind.
await expect(runtimeConfig.apply()).resolves.toBe(true);
expect(applySubmissions).toEqual([{ raw: '{\n "count": 2\n}\n', baseHash: "hash-2" }]);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("keeps a revert made during the cosmetic reload dirty and saves it", async () => {
vi.useFakeTimers();
let hashCounter = 1;
let storedRaw = '{\n "count": 1\n}\n';
let deferReload: ReturnType<typeof deferred<unknown>> | null = null;
const submissions: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
const response = {
config: JSON.parse(storedRaw) as Record<string, unknown>,
raw: storedRaw,
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
};
if (deferReload) {
const pending = deferReload;
deferReload = null;
return pending.promise.then(() => response);
}
return Promise.resolve(response);
}
if (method === "config.set") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
submissions.push({ raw, baseHash });
storedRaw = raw;
hashCounter += 1;
return Promise.resolve({ hash: `hash-${hashCounter}` });
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const reloadGate = deferred<unknown>();
deferReload = reloadGate;
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
// The ack already rebased the originals onto the submitted bytes, so a
// revert during the still-pending reload compares dirty and reschedules.
runtimeConfig.patchForm(["count"], 1);
expect(runtimeConfig.state.configFormDirty).toBe(true);
reloadGate.resolve({});
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(2);
expect(submissions[1]).toEqual({ raw: '{\n "count": 1\n}\n', baseHash: "hash-2" });
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.dispose();
});
it("reports a conflict status when apply hits the base-hash guard", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.apply") {
throw new Error("config changed since last load; re-run config.get and retry");
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await expect(runtimeConfig.apply()).resolves.toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
runtimeConfig.dispose();
});
it("adopts config.patch acknowledgements for consecutive queued patches", async () => {
const patchBaseHashes: string[] = [];
let storedConfig: Record<string, unknown> = { count: 1 };
let hashCounter = 1;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
config: storedConfig,
raw: JSON.stringify(storedConfig),
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
};
}
if (method === "config.patch") {
const patch = params as { baseHash: string; raw: string };
patchBaseHashes.push(patch.baseHash);
storedConfig = { ...storedConfig, ...(JSON.parse(patch.raw) as Record<string, unknown>) };
hashCounter += 1;
return { config: storedConfig, hash: `hash-${hashCounter}` };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const first = runtimeConfig.patch({ raw: { first: true }, note: "first test patch" });
const second = runtimeConfig.patch({ raw: { second: true }, note: "second test patch" });
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(patchBaseHashes).toEqual(["hash-1", "hash-2"]);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-3");
expect(runtimeConfig.state.configForm).toEqual({ count: 1, first: true, second: true });
runtimeConfig.dispose();
});
it("keeps a concurrent dirty draft on its pre-patch CAS base", async () => {
vi.useFakeTimers();
const patchGate = deferred<unknown>();
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
});
}
if (method === "config.patch") {
return patchGate.promise;
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const patch = runtimeConfig.patch({ raw: { patched: true }, note: "concurrent test patch" });
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("config.patch", expect.anything()));
runtimeConfig.patchForm(["count"], 2);
patchGate.resolve({ config: { count: 1, patched: true }, hash: "hash-2" });
await expect(patch).resolves.toBe(true);
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configDraftBaseHash).toBe("hash-1");
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
runtimeConfig.resetDraft();
runtimeConfig.dispose();
});
it("orders a patch acknowledgement after an older in-flight config load", async () => {
const staleLoad = deferred<ConfigSnapshot>();
let getCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
getCalls += 1;
if (getCalls === 2) {
return staleLoad.promise;
}
return {
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.patch") {
return { config: { count: 1, patched: true }, hash: "hash-2" };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const staleRefresh = runtimeConfig.refresh();
await vi.waitFor(() => expect(getCalls).toBe(2));
await expect(
runtimeConfig.patch({ raw: { patched: true }, note: "ordered patch test" }),
).resolves.toBe(true);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
staleLoad.resolve({
config: { count: 999 },
raw: '{"count":999}',
hash: "stale-hash",
valid: true,
issues: [],
});
await staleRefresh;
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
expect(runtimeConfig.state.configForm).toEqual({ count: 1, patched: true });
runtimeConfig.dispose();
});
it("refreshes hash-only patch acknowledgements before publishing their revision", async () => {
let storedConfig: Record<string, unknown> = { count: 1 };
let hash = "hash-1";
let getCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
getCalls += 1;
return {
config: storedConfig,
raw: JSON.stringify(storedConfig),
hash,
valid: true,
issues: [],
};
}
if (method === "config.patch") {
storedConfig = { count: 1, patched: true };
hash = "hash-2";
return { hash };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await expect(
runtimeConfig.patch({ raw: { patched: true }, note: "hash-only patch test" }),
).resolves.toBe(true);
expect(getCalls).toBe(2);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
expect(runtimeConfig.state.configForm).toEqual({ count: 1, patched: true });
runtimeConfig.dispose();
});
it("records a committed hash-only patch when its acknowledgement refresh fails", async () => {
let getCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
getCalls += 1;
if (getCalls > 1) {
throw new Error("refresh unavailable");
}
return {
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.patch") {
return { hash: "hash-2" };
}
return {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await expect(
runtimeConfig.patch({ raw: { patched: true }, note: "hash-only refresh failure" }),
).resolves.toBe(false);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-1");
expect(runtimeConfig.state.lastError).toContain("refresh unavailable");
runtimeConfig.dispose();
});
it("refreshes applied revision truth after config.patch", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
getCount += 1;
const revision = getCount === 1 ? "revision-1" : "revision-2";
return {
config: { count: getCount },
raw: `{\n "count": ${getCount}\n}\n`,
hash: `hash-${getCount}`,
configRevisionHash: revision,
appliedConfigHash: revision,
valid: true,
issues: [],
};
}
return method === "config.patch" ? { hash: "hash-2" } : {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await expect(runtimeConfig.patch({ raw: { count: 2 }, note: "test patch" })).resolves.toBe(
true,
);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
expect(runtimeConfig.state.configSnapshot?.configRevisionHash).toBe("revision-2");
runtimeConfig.dispose();
});
it("rebases trailing edits after a hashless ack so the trailing save does not self-conflict", async () => {
vi.useFakeTimers();
const { request, submissions, firstSet } = createDeferredSetServerMock({ legacyAck: true });
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
// Edit while the hashless save is in flight: the follow-up reload must
// rebase the surviving draft onto the fetched post-write hash, or the
// trailing save conflicts with the write that just succeeded.
runtimeConfig.patchForm(["count"], 3);
firstSet.resolve({});
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(2);
expect(submissions[1]).toEqual({ raw: '{\n "count": 3\n}\n', baseHash: "hash-2" });
runtimeConfig.dispose();
});
it("keeps process-local needsApply after a legacy hashless ack reload", async () => {
vi.useFakeTimers();
const { request, firstSet, submissions } = createDeferredSetServerMock({ legacyAck: true });
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
firstSet.resolve({});
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("adopts the acked autosave without a follow-up reload", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const configGetCalls = () =>
server.request.mock.calls.filter(([method]) => method === "config.get").length;
const getsBefore = configGetCalls();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
await vi.advanceTimersByTimeAsync(0);
expect(server.submissions).toHaveLength(1);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
// The acked bytes + hash ARE the snapshot; a config.get here would flash
// configLoading and lock the editors between keystrokes.
expect(configGetCalls()).toBe(getsBefore);
expect(runtimeConfig.state.configSnapshot?.hash).toBe(server.currentHash());
runtimeConfig.dispose();
});
});
@@ -0,0 +1,634 @@
import { ErrorCodes } from "@openclaw/gateway-client/browser";
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot } from "../../api/types.ts";
import { copyToClipboard } from "../clipboard.ts";
import { serializeConfigForm } from "../config-form-utils.ts";
import {
adoptConfigSetAck,
applyConfigSnapshot,
clearConfigDraftTracking,
formatConfigMutationError,
reconcileHashlessWriteReload,
serializeFormForSubmit,
} from "./config-draft-model.ts";
import {
currentConfigConnectionEpoch,
isCurrentConfigConnection,
isCurrentRequest,
nextRequestVersion,
type ConfigGatewayClient,
type LoadConfigOptions,
type RuntimeConfigState,
} from "./config-state-model.ts";
function readAckHash(ack: unknown): string | null {
const hash = (ack as { hash?: unknown } | null | undefined)?.hash;
return typeof hash === "string" && hash.length > 0 ? hash : null;
}
/**
* Gateway contract: requireConfigBaseHash in
* src/gateway/server-methods/config.ts rejects writes whose baseHash no
* longer matches the file with exactly this message. A conflict means another
* writer changed openclaw.json; retrying the whole-form draft would clobber
* their edit, so callers surface a reload affordance instead.
*/
function isConfigBaseHashConflictError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes("config changed since last load");
}
function isDefinitiveConfigMutationRejection(err: unknown): boolean {
return (
err instanceof GatewayRequestError &&
(err.gatewayCode === ErrorCodes.INVALID_REQUEST || err.gatewayCode === ErrorCodes.FORBIDDEN)
);
}
export type ConfigPatchOptions = {
raw: string | Record<string, unknown>;
note: string;
/** Array paths the caller intentionally shrinks; required by the gateway's destructive-array guard. */
replacePaths?: string[];
/** Caller-owned lifecycle/access guard, rechecked at the final dispatch boundary. */
canDispatch?: () => boolean;
};
export type ConfigPatchBuildResult = { options: ConfigPatchOptions } | { error: string };
export type ConfigPatchBuilder = (
config: Readonly<Record<string, unknown>>,
) => ConfigPatchBuildResult;
type ConfigPatchAck = {
config?: unknown;
hash?: unknown;
noop?: boolean;
};
export type RuntimeConfigExternalMutationResult<T> =
| {
ok: true;
value: T;
refresh: { ok: true } | { ok: false; error: string };
}
| {
ok: false;
reason: "conflict" | "error" | "rejected" | "suspended" | "unavailable";
error: string;
};
export type RuntimeConfigExternalMutationOptions = {
waitForWritesResumed?: boolean;
canDispatch?: () => boolean;
dispatchError?: string;
};
export type RuntimeConfigDispatchOptions = {
canDispatch?: () => boolean;
};
export type ConfigMethod = "config.set" | "config.apply" | "config.patch" | "config.openFile";
export type ConfigWriteCoordinator = {
prepareDiscard: () => Promise<void>;
patchForm: (path: Array<string | number>, value: unknown) => void;
removeFormValue: (path: Array<string | number>) => void;
setRaw: (value: string) => void;
resetDraft: () => void;
discardDraft: () => Promise<void>;
setWritesSuspended: (suspended: boolean) => void;
waitForPendingWrites: () => Promise<void>;
save: (options?: RuntimeConfigDispatchOptions) => Promise<boolean>;
apply: () => Promise<boolean>;
stageDefaultAgent: (agentId: string) => boolean;
patch: (options: ConfigPatchOptions) => Promise<boolean>;
patchFromSnapshot: (build: ConfigPatchBuilder) => Promise<boolean>;
runExternalMutation: <T>(
task: (client: GatewayBrowserClient) => Promise<T>,
options?: RuntimeConfigExternalMutationOptions,
) => Promise<RuntimeConfigExternalMutationResult<T>>;
dispose: () => void;
};
export async function executeConfigExternalMutation<T>(
state: RuntimeConfigState,
client: GatewayBrowserClient,
connectionEpoch: number,
task: (client: GatewayBrowserClient) => Promise<T>,
options: RuntimeConfigExternalMutationOptions,
refresh: () => Promise<boolean>,
): Promise<RuntimeConfigExternalMutationResult<T>> {
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return {
ok: false,
reason: "unavailable",
error: "Connection changed before the configuration update started.",
};
}
if (options.canDispatch && !options.canDispatch()) {
return {
ok: false,
reason: "unavailable",
error: options.dispatchError ?? "Access changed before the configuration update started.",
};
}
let value: T;
try {
value = await task(client);
} catch (error) {
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return {
ok: false,
reason: "unavailable",
error: "Connection changed before the configuration update completed.",
};
}
return {
ok: false,
reason: isConfigBaseHashConflictError(error)
? "conflict"
: isDefinitiveConfigMutationRejection(error)
? "rejected"
: "error",
error: error instanceof Error ? error.message : String(error),
};
}
const refreshFailure = (error: string): RuntimeConfigExternalMutationResult<T> => ({
ok: true,
value,
refresh: { ok: false, error },
});
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return refreshFailure("Connection changed before the configuration update was refreshed.");
}
try {
const refreshed = await refresh();
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return refreshFailure("Connection changed before the configuration update was refreshed.");
}
if (!refreshed) {
return refreshFailure(
state.lastError ??
"The configuration update completed, but its authoritative refresh failed.",
);
}
return { ok: true, value, refresh: { ok: true } };
} catch (error) {
return refreshFailure(error instanceof Error ? error.message : String(error));
}
}
export async function loadConfig(
state: RuntimeConfigState,
options: LoadConfigOptions = {},
isCurrentLoad: () => boolean = () => true,
): Promise<boolean> {
const client = state.client;
if (!client || !state.connected) {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const version = nextRequestVersion(state, "config");
state.configLoading = true;
state.lastError = null;
state.chatError = null;
try {
const res = await client.request<ConfigSnapshot>("config.get", {});
if (!isCurrentRequest(state, "config", version, client, connectionEpoch) || !isCurrentLoad()) {
return false;
}
applyConfigSnapshot(state, res, options);
return true;
} catch (err) {
if (isCurrentRequest(state, "config", version, client, connectionEpoch)) {
state.lastError = String(err);
}
return false;
} finally {
if (isCurrentRequest(state, "config", version, client, connectionEpoch)) {
state.configLoading = false;
}
}
}
export async function loadConfigSchema(state: RuntimeConfigState) {
const client = state.client;
if (!client || !state.connected) {
return;
}
if (state.configSchemaLoading) {
return;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const version = nextRequestVersion(state, "schema");
state.configSchemaLoading = true;
try {
const res = await client.request<ConfigSchemaResponse>("config.schema", {});
if (!isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
return;
}
applyConfigSchema(state, res);
} catch (err) {
if (isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
state.lastError = String(err);
}
} finally {
if (isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
state.configSchemaLoading = false;
}
}
}
function applyConfigSchema(state: RuntimeConfigState, res: ConfigSchemaResponse) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
}
type ConfigSubmitMethod = "config.set" | "config.apply";
type ConfigSubmitBusyKey = "configSaving" | "configApplying";
async function submitConfigChange(
state: RuntimeConfigState,
method: ConfigSubmitMethod,
busyKey: ConfigSubmitBusyKey,
extraParams: Record<string, unknown> = {},
onSubmitted?: (info: { raw: string; ackHash: string | null }) => void,
canDispatch: () => boolean = () => true,
): Promise<boolean> {
const client = state.client;
if (!client || !state.connected) {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
// Claim busy before any await so a second click cannot slip past the busy
// state while a JSON5 original parse settles; finally releases it.
state[busyKey] = true;
state.lastError = null;
state.chatError = null;
let submittedFormRaw: string | null = null;
try {
if (state.configRawOriginalParsePending) {
// JSON5 originals parse asynchronously on first load; sanitize needs them.
await state.configRawOriginalParsePending;
if (!isCurrent()) {
return false;
}
}
const raw = serializeFormForSubmit(state);
// The serialized candidate includes schema coercion; a live draft can
// change while the request is pending, so never infer from it afterward.
submittedFormRaw = state.configFormMode === "form" ? raw : null;
const baseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return false;
}
if (!isCurrent() || !canDispatch()) {
return false;
}
// Dispatch-phase report (ackHash null): if the connection dies before the
// ack arrives, reconnect reconciliation still needs the submitted bytes
// to recognize its own committed write. The post-ack report below
// overwrites this with the real hash.
onSubmitted?.({ raw, ackHash: null });
const ack = await client.request(method, { raw, baseHash, ...extraParams });
// The gateway acks writes with the persisted snapshot hash. Adopt it as
// the new draft base; config.get remains the source of applied revision truth.
const ackHash = readAckHash(ack);
// Reported before the epoch check: dispose-chained teardown flushes need
// this flight's own submission even though state mutation may be blocked.
onSubmitted?.({ raw, ackHash });
if (!isCurrent()) {
return false;
}
// Same bytes-vs-submission rule as autosave: an edit made while this
// manual write was in flight must stay dirty (its autosave deferred into
// a trailing run), or adoption would snap the draft back to the older
// submitted bytes and silently discard the newer edit.
if (serializeFormForSubmit(state) === raw) {
state.configFormDirty = false;
clearConfigDraftTracking(state);
} else {
state.configFormDirty = true;
}
adoptConfigSetAck(state, raw, ackHash);
if (method === "config.apply") {
// Older gateways omit appliedConfigHash, so keep the former process-local
// behavior. New gateways replace this optimistic value on config.get.
state.configNeedsApply = false;
state.configAutoSaveStatus = "idle";
} else {
state.configNeedsApply = true;
}
// Best-effort UI refresh; correctness no longer depends on it.
await loadConfig(state);
if (!isCurrent()) {
return false;
}
if (!ackHash) {
reconcileHashlessWriteReload(state, raw);
}
if (method === "config.set") {
// "Saved" would lie next to a draft the user re-dirtied during the
// reload; the rescheduled save reports its own completion.
state.configAutoSaveStatus = state.configFormDirty ? "idle" : "saved";
}
return true;
} catch (err) {
if (isCurrent()) {
state.lastError = formatConfigMutationError(err, submittedFormRaw);
if (isConfigBaseHashConflictError(err)) {
// Applies conflict the same way saves do so the UI offers Reload.
state.configAutoSaveStatus = "conflict";
} else if (method === "config.set") {
state.configAutoSaveStatus = "error";
}
}
return false;
} finally {
if (isCurrent()) {
state[busyKey] = false;
}
}
}
/**
* Teardown flush after an in-flight save: submits the latest draft once,
* based only on that flight's own in-memory ack hash. Callers skip the flush
* entirely (fail closed) when no in-memory ack hash exists.
*/
export function teardownFlushConfigDraft(
state: RuntimeConfigState,
client: GatewayBrowserClient,
baseHash: string,
canDispatch: () => boolean,
): void {
// Must stay synchronous: page unload destroys the context before any
// deferred work runs. If a JSON5 original parse is still pending, sanitize
// passes placeholders through; the gateway restores restorable sentinels
// (restoreRedactedValues) and rejects unrestorable ones, so the worst case
// matches not flushing at all while the common case saves the draft.
if (!canDispatch()) {
return;
}
const raw = serializeFormForSubmit(state);
void client.request("config.set", { raw, baseHash }).catch(() => undefined);
}
/**
* Auto-save submission for debounced form edits. Unlike the manual
* `submitConfigChange` path it never raises `configSaving` (editors must stay
* interactive while typing) and it only clears the dirty flag when the draft
* still matches the submitted bytes — edits made while the request was in
* flight stay dirty so the trailing save picks them up.
*/
export async function autoSaveConfig(
state: RuntimeConfigState,
onAck?: (ackHash: string | null) => void,
canDispatch: () => boolean = () => true,
): Promise<boolean> {
const client = state.client;
if (!client || !state.connected || !state.configFormDirty || state.configFormMode !== "form") {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
if (state.configRawOriginalParsePending) {
// JSON5 originals parse asynchronously on first load; sanitize needs them.
// Await only when pending: teardown flushes rely on a synchronous prefix.
// Entry stays serialized across this await: runAutoSave's synchronous
// in-flight check folds concurrent triggers into one trailing save.
await state.configRawOriginalParsePending;
if (!isCurrent() || !state.configFormDirty || state.configFormMode !== "form") {
return false;
}
}
const submittedRaw = serializeFormForSubmit(state);
const baseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash;
if (!baseHash) {
state.configAutoSaveStatus = "error";
state.lastError = "Config hash missing; reload and retry.";
return false;
}
if (!isCurrent() || !canDispatch()) {
return false;
}
state.configAutoSaveStatus = "saving";
state.lastError = null;
state.chatError = null;
try {
const ack = await client.request("config.set", { raw: submittedRaw, baseHash });
// The gateway acks with the persisted snapshot hash. Applied revision
// truth arrives on config.get.
const ackHash = readAckHash(ack);
// Reported before the epoch check: dispose-chained teardown flushes need
// this flight's own ack even though state mutation below is blocked.
onAck?.(ackHash);
if (!isCurrent()) {
return false;
}
state.configNeedsApply = true;
// The submitted bytes are now the authoritative original: a draft that no
// longer matches them (mid-flight edits, or a revert back to the pre-save
// value) stays dirty so the trailing save runs. Computed before adoption
// so the comparison sees the pre-save snapshot for reverted-clean drafts.
const drained = serializeFormForSubmit(state) === submittedRaw;
if (drained) {
state.configFormDirty = false;
clearConfigDraftTracking(state);
} else {
state.configFormDirty = true;
}
adoptConfigSetAck(state, submittedRaw, ackHash);
if (!ackHash) {
// Only a hashless ack needs a reload to re-derive the snapshot. With a
// hash the adopted snapshot IS authoritative, and reloading here would
// flash configLoading and lock the editors between keystrokes.
await loadConfig(state);
if (!isCurrent()) {
return false;
}
reconcileHashlessWriteReload(state, submittedRaw);
}
// "Saved" would lie next to a still-dirty draft (edits during the
// request or reload); the trailing save reports its own completion.
state.configAutoSaveStatus = state.configFormDirty ? "idle" : "saved";
return true;
} catch (err) {
if (isCurrent()) {
state.lastError = formatConfigMutationError(err, submittedRaw);
state.configAutoSaveStatus = isConfigBaseHashConflictError(err) ? "conflict" : "error";
}
return false;
}
}
export async function saveConfig(
state: RuntimeConfigState,
onSubmitted?: (info: { raw: string; ackHash: string | null }) => void,
canDispatch?: () => boolean,
): Promise<boolean> {
return submitConfigChange(state, "config.set", "configSaving", {}, onSubmitted, canDispatch);
}
export async function applyConfig(
state: RuntimeConfigState,
canDispatch?: () => boolean,
): Promise<boolean> {
return submitConfigChange(
state,
"config.apply",
"configApplying",
{
sessionKey: state.applySessionKey,
},
undefined,
canDispatch,
);
}
export async function patchConfig(
state: RuntimeConfigState,
options: ConfigPatchOptions,
onAck?: (ack: ConfigPatchAck, snapshotAtDispatch: ConfigSnapshot) => Promise<void> | void,
): Promise<boolean> {
const client = state.client;
const currentSnapshot = state.configSnapshot;
if (!client || !state.connected || !currentSnapshot) {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const baseHash = currentSnapshot.hash;
if (!baseHash) {
state.lastError = "Config hash missing; refresh and retry.";
return false;
}
if (options.canDispatch && !options.canDispatch()) {
return false;
}
state.lastError = null;
state.chatError = null;
try {
const ack = await client.request<ConfigPatchAck>("config.patch", {
baseHash,
raw: typeof options.raw === "string" ? options.raw : JSON.stringify(options.raw),
sessionKey: state.applySessionKey,
note: options.note,
...(options.replacePaths?.length ? { replacePaths: options.replacePaths } : {}),
});
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return false;
}
const committed = ack.noop !== true;
if (committed) {
// The patch is committed once the gateway acknowledges it. Preserve
// that fact even if a legacy hash-only ack requires a fallible refresh.
state.configNeedsApply = true;
}
await onAck?.(ack, currentSnapshot);
if (committed) {
// A successful acknowledgement refresh may publish the previous
// applied revision. Keep the existing immediate apply-needed signal;
// reconcileAppliedRefresh replaces it with authoritative process truth.
state.configNeedsApply = true;
}
return true;
} catch (err) {
if (isCurrentConfigConnection(state, client, connectionEpoch)) {
state.lastError = String(err);
}
return false;
}
}
export function adoptConfigPatchAck(
state: RuntimeConfigState,
ack: ConfigPatchAck,
snapshotAtDispatch: ConfigSnapshot,
) {
const ackConfig = asConfigRecord(ack.config);
const ackHash = readAckHash(ack);
if (!ackConfig) {
return;
}
const currentSnapshot = state.configSnapshot ?? snapshotAtDispatch;
const raw =
ack.noop === true ? (currentSnapshot.raw ?? state.configRaw) : serializeConfigForm(ackConfig);
applyConfigSnapshot(state, {
...currentSnapshot,
config: ackConfig,
sourceConfig: ackConfig,
hash: ackHash ?? currentSnapshot.hash ?? null,
raw,
valid: true,
issues: [],
});
}
export async function lookupConfigSchemaPath(
state: { client: ConfigGatewayClient | null; connected: boolean },
path: string,
): Promise<unknown> {
const client = state.client;
if (!client || !state.connected) {
return null;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
try {
const result = await client.request("config.schema.lookup", { path });
return isCurrentConfigConnection(state, client, connectionEpoch) ? result : null;
} catch (error) {
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return null;
}
throw error;
}
}
export async function openConfigFile(state: RuntimeConfigState): Promise<void> {
const client = state.client;
if (!client || !state.connected) {
return;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
state.lastError = null;
state.chatError = null;
try {
const res = await client.request<{ ok: boolean; path?: string; error?: string }>(
"config.openFile",
{},
);
if (!isCurrent()) {
return;
}
if (!res.ok) {
let errorMessage = res.error || "Failed to open config file";
const path = res.path || state.configSnapshot?.path;
if (path) {
if (await copyToClipboard(path)) {
errorMessage += `\n\nFile path copied to clipboard: ${path}`;
} else {
errorMessage += `\n\nFile path: ${path}`;
}
}
if (isCurrent()) {
state.lastError = errorMessage;
}
}
} catch (err) {
if (!isCurrent()) {
return;
}
const errorMessage = String(err);
const path = state.configSnapshot?.path;
if (path) {
await copyToClipboard(path);
}
if (isCurrent()) {
state.lastError = errorMessage;
}
}
}
@@ -0,0 +1,261 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot } from "../../api/types.ts";
import { resolveAgentConfigEntryTarget } from "./config-state-model.ts";
import {
CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS,
deferred,
createGatewayHarness,
createConfigServerMock,
createConfigCapabilityHarness,
} from "./config-test-harness.ts";
import { createRuntimeConfigCapability } from "./runtime-config-capability.ts";
describe("config state model", () => {
it("preserves a dirty draft and its original base hash across refreshes", async () => {
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "config.get") {
return {};
}
getCount += 1;
return getCount === 1
? { config: { count: 1 }, hash: "hash-1", valid: true, issues: [], raw: '{"count":1}' }
: { config: { count: 3 }, hash: "hash-2", valid: true, issues: [], raw: '{"count":3}' };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await runtimeConfig.refresh();
expect(runtimeConfig.state.configForm).toEqual({ count: 2 });
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configDraftBaseHash).toBe("hash-1");
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
await runtimeConfig.refresh({ discardPendingChanges: true });
expect(runtimeConfig.state.configForm).toEqual({ count: 3 });
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configDraftBaseHash).toBe("hash-2");
runtimeConfig.dispose();
});
it("rejects stale config and schema work after reconnecting the same client", async () => {
const firstConfig = deferred<ConfigSnapshot>();
const secondConfig = deferred<ConfigSnapshot>();
const firstSchema = deferred<ConfigSchemaResponse>();
const secondSchema = deferred<ConfigSchemaResponse>();
const configRequests = [firstConfig, secondConfig];
const schemaRequests = [firstSchema, secondSchema];
const request = vi.fn((method: string) => {
const pending = method === "config.get" ? configRequests.shift() : schemaRequests.shift();
if (!pending) {
throw new Error(`unexpected request: ${method}`);
}
return pending.promise;
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
const staleConfigLoad = runtimeConfig.ensureLoaded();
const staleSchemaLoad = runtimeConfig.ensureSchemaLoaded();
publish(false);
publish(true);
const currentConfigLoad = runtimeConfig.ensureLoaded();
const currentSchemaLoad = runtimeConfig.ensureSchemaLoaded();
firstConfig.resolve({ config: { source: "stale" }, valid: true, issues: [], raw: "{}" });
firstSchema.reject(new Error("stale schema failure"));
await Promise.all([staleConfigLoad, staleSchemaLoad]);
expect(runtimeConfig.state.configSnapshot).toBeNull();
expect(runtimeConfig.state.configSchema).toBeNull();
expect(runtimeConfig.state.lastError).toBeNull();
expect(runtimeConfig.state.configLoading).toBe(true);
expect(runtimeConfig.state.configSchemaLoading).toBe(true);
secondConfig.resolve({ config: { source: "current" }, valid: true, issues: [], raw: "{}" });
secondSchema.resolve({
schema: { type: "object" },
uiHints: {},
version: "current",
generatedAt: "2026-07-09T00:00:00.000Z",
});
await Promise.all([currentConfigLoad, currentSchemaLoad]);
expect(runtimeConfig.state.configSnapshot?.config).toEqual({ source: "current" });
expect(runtimeConfig.state.configSchema).toEqual({ type: "object" });
expect(runtimeConfig.state.configSchemaVersion).toBe("current");
expect(runtimeConfig.state.configLoading).toBe(false);
expect(runtimeConfig.state.configSchemaLoading).toBe(false);
runtimeConfig.dispose();
});
it("clears needsApply only on apply; a discarding refresh keeps the banner", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
// Discarding local edits does not undo the already-saved file: the
// restart banner must survive until apply.
runtimeConfig.patchForm(["count"], 9);
await runtimeConfig.refresh({ discardPendingChanges: true });
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await expect(runtimeConfig.apply()).resolves.toBe(true);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
expect(server.submissions.at(-1)?.method).toBe("config.apply");
runtimeConfig.dispose();
});
it("derives needsApply across capability recreation from Gateway revision truth", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const first = createConfigCapabilityHarness(server.request as GatewayBrowserClient["request"]);
await first.runtimeConfig.ensureLoaded();
first.runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(first.runtimeConfig.state.configNeedsApply).toBe(true);
first.runtimeConfig.dispose();
// A fresh capability compares the persisted and applied revisions.
const second = createConfigCapabilityHarness(server.request as GatewayBrowserClient["request"]);
await second.runtimeConfig.ensureLoaded();
expect(second.runtimeConfig.state.configNeedsApply).toBe(true);
await expect(second.runtimeConfig.apply()).resolves.toBe(true);
expect(second.runtimeConfig.state.configNeedsApply).toBe(false);
second.runtimeConfig.dispose();
// After apply advances runtime truth, a third load shows no banner.
const third = createConfigCapabilityHarness(server.request as GatewayBrowserClient["request"]);
await third.runtimeConfig.ensureLoaded();
expect(third.runtimeConfig.state.configNeedsApply).toBe(false);
third.runtimeConfig.dispose();
});
it("does not invent needsApply when an older Gateway omits the applied hash", async () => {
const request = vi.fn(async (method: string) =>
method === "config.get" ? { config: {}, hash: "hash-1", valid: true, issues: [] } : {},
);
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("preserves process-local needsApply after saving through an older Gateway", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
return method === "config.set" ? { hash: "hash-2" } : {};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("treats a missing current config as drift from an applied revision", async () => {
const request = vi.fn(async (method: string) =>
method === "config.get"
? {
config: {},
hash: null,
configRevisionHash: null,
appliedConfigHash: "applied-hash",
valid: true,
issues: [],
}
: {},
);
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("finds explicit agent entries", () => {
expect(
resolveAgentConfigEntryTarget(
{
agents: {
entries: {
main: {},
assistant: { model: "openai/gpt-5.4" },
},
},
},
"assistant",
),
).toEqual({
path: ["agents", "entries", "assistant"],
entry: { model: "openai/gpt-5.4" },
});
});
it("preserves the authored key while resolving normalized agent identities", () => {
expect(
resolveAgentConfigEntryTarget(
{
agents: {
entries: {
MAIN: { model: "openai/gpt-5.4" },
},
},
},
"main",
),
).toEqual({
path: ["agents", "entries", "MAIN"],
entry: { model: "openai/gpt-5.4" },
});
});
it("does not resolve missing, blank, or blocked entry keys", () => {
const entries = JSON.parse(
'{"__proto__":{"model":"openai/gpt-5.4"},"foo bar":{"model":"openai/gpt-5.4"}}',
) as Record<string, unknown>;
const config = { agents: { entries } };
expect(resolveAgentConfigEntryTarget(config, "missing")).toBeNull();
expect(resolveAgentConfigEntryTarget(config, " ")).toBeNull();
expect(resolveAgentConfigEntryTarget(config, "__proto__")).toBeNull();
expect(resolveAgentConfigEntryTarget(config, "foo-bar")).toBeNull();
});
});
+238
View File
@@ -0,0 +1,238 @@
import {
asNullableRecord as asConfigRecord,
isRecord,
} from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
import { normalizeAgentId } from "../sessions/session-key.ts";
export type ConfigAutoSaveStatus = "idle" | "saving" | "saved" | "error" | "conflict";
export type RuntimeConfigState = {
client: GatewayBrowserClient | null;
connected: boolean;
applySessionKey: string;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
configRawOriginalParsed: Record<string, unknown> | null;
configRawOriginalParsePending: Promise<void> | null;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
configAutoSaveStatus: ConfigAutoSaveStatus;
/** True when the config file revision differs from the active Gateway runtime. */
configNeedsApply: boolean;
configSnapshot: ConfigSnapshot | null;
configDraftBaseHash?: string | null;
configSchema: unknown;
configSchemaVersion: string | null;
configSchemaLoading: boolean;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
lastError: string | null;
chatError?: string | null;
};
const requestVersionsByState = new WeakMap<
RuntimeConfigState,
{ config: number; schema: number }
>();
const connectionEpochsByState = new WeakMap<object, number>();
type RuntimeConfigGatewaySnapshot = {
client: GatewayBrowserClient | null;
phase: ApplicationGatewayPhase;
sessionKey: string;
hello?: GatewayHelloOk | null;
};
export type RuntimeConfigGateway = {
readonly snapshot: RuntimeConfigGatewaySnapshot;
subscribe: (listener: (snapshot: RuntimeConfigGatewaySnapshot) => void) => () => void;
};
export type LoadConfigOptions = {
discardPendingChanges?: boolean;
};
export type ConfigGatewayClient = {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
};
type ConfigConnectionState = {
client: ConfigGatewayClient | null;
connected: boolean;
};
export function createInitialConfigState(
snapshot?: Partial<RuntimeConfigGatewaySnapshot>,
): RuntimeConfigState {
return {
client: snapshot?.client ?? null,
connected: snapshot?.phase === "connected",
applySessionKey: snapshot?.sessionKey ?? "main",
configLoading: false,
configRaw: "{\n}\n",
configRawOriginal: "",
configRawOriginalParsed: null,
configRawOriginalParsePending: null,
configValid: null,
configIssues: [],
configSaving: false,
configApplying: false,
configAutoSaveStatus: "idle",
configNeedsApply: false,
configSnapshot: null,
configDraftBaseHash: null,
configSchema: null,
configSchemaVersion: null,
configSchemaLoading: false,
configUiHints: {},
configForm: null,
configFormOriginal: null,
configFormDirty: false,
configFormMode: "form",
configSearchQuery: "",
configActiveSection: null,
configActiveSubsection: null,
lastError: null,
};
}
export function nextRequestVersion(state: RuntimeConfigState, key: "config" | "schema"): number {
const current = requestVersionsByState.get(state) ?? { config: 0, schema: 0 };
const next = { ...current, [key]: current[key] + 1 };
requestVersionsByState.set(state, next);
return next[key];
}
export function clearConfigRequestVersions(state: RuntimeConfigState): void {
requestVersionsByState.delete(state);
}
export function currentConfigConnectionEpoch(state: object): number {
return connectionEpochsByState.get(state) ?? 0;
}
export function invalidateConfigConnection(state: object): void {
connectionEpochsByState.set(state, currentConfigConnectionEpoch(state) + 1);
}
export function isCurrentConfigConnection(
state: ConfigConnectionState,
client: ConfigGatewayClient,
connectionEpoch: number,
): boolean {
return (
state.connected &&
state.client === client &&
currentConfigConnectionEpoch(state) === connectionEpoch
);
}
export function isCurrentRequest(
state: RuntimeConfigState,
key: "config" | "schema",
version: number,
client: GatewayBrowserClient,
connectionEpoch: number,
): boolean {
return (
isCurrentConfigConnection(state, client, connectionEpoch) &&
requestVersionsByState.get(state)?.[key] === version
);
}
/** Resolves true only when a current-epoch snapshot was actually applied. */
export function resolveEditableSnapshotConfig(
snapshot: ConfigSnapshot | null | undefined,
): Record<string, unknown> | null {
return (
asConfigRecord(snapshot?.sourceConfig) ??
asConfigRecord(snapshot?.resolved) ??
asConfigRecord(snapshot?.config)
);
}
export function currentConfigObject(
state: Pick<RuntimeConfigState, "configForm" | "configSnapshot">,
): Record<string, unknown> | null {
return state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot);
}
export type AgentConfigEntryTarget = {
path: ["agents", "entries", string];
entry: Record<string, unknown>;
};
const AGENT_CONFIG_ENTRY_ID_PATTERN = /^[a-z0-9_][a-z0-9_-]{0,63}$/i;
const BLOCKED_AGENT_CONFIG_ENTRY_IDS = new Set(["__proto__", "prototype", "constructor"]);
function normalizeAgentConfigEntryId(agentId: string): string | null {
const trimmedAgentId = agentId.trim();
if (
!AGENT_CONFIG_ENTRY_ID_PATTERN.test(trimmedAgentId) ||
BLOCKED_AGENT_CONFIG_ENTRY_IDS.has(trimmedAgentId)
) {
return null;
}
const normalizedAgentId = normalizeAgentId(trimmedAgentId);
return BLOCKED_AGENT_CONFIG_ENTRY_IDS.has(normalizedAgentId) ? null : normalizedAgentId;
}
export function resolveAgentConfigEntryTarget(
config: Record<string, unknown> | null,
agentId: string,
): AgentConfigEntryTarget | null {
const normalizedAgentId = normalizeAgentConfigEntryId(agentId);
if (!normalizedAgentId) {
return null;
}
const agents = isRecord(config?.agents) ? config.agents : null;
const entries = isRecord(agents?.entries) ? agents.entries : null;
const authoredAgentId = Object.keys(entries ?? {}).find(
(candidate) =>
AGENT_CONFIG_ENTRY_ID_PATTERN.test(candidate) &&
!BLOCKED_AGENT_CONFIG_ENTRY_IDS.has(candidate) &&
normalizeAgentId(candidate) === normalizedAgentId,
);
if (!entries || !authoredAgentId || !Object.hasOwn(entries, authoredAgentId)) {
return null;
}
const entry = entries[authoredAgentId];
if (!isRecord(entry)) {
return null;
}
return {
path: ["agents", "entries", authoredAgentId],
entry,
};
}
export function agentConfigEntry(
state: RuntimeConfigState,
agentId: string,
options: { ensure?: boolean } = {},
): AgentConfigEntryTarget | null {
const normalizedAgentId = normalizeAgentConfigEntryId(agentId);
if (!normalizedAgentId) {
return null;
}
const source = state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot);
const existing = resolveAgentConfigEntryTarget(source, normalizedAgentId);
if (existing) {
return existing;
}
if (!options.ensure) {
return null;
}
const path = ["agents", "entries", normalizedAgentId] as const;
return { path: [...path], entry: {} };
}
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, vi } from "vitest";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
import { createRuntimeConfigCapability } from "./runtime-config-capability.ts";
export const CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS = 800;
export function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
export function createGatewayHarness(client: GatewayBrowserClient) {
let snapshot: {
client: GatewayBrowserClient;
phase: ApplicationGatewayPhase;
sessionKey: string;
hello?: GatewayHelloOk | null;
} = { client, phase: "connected", sessionKey: "main" };
const listeners = new Set<(next: typeof snapshot) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
publish: (
connected: boolean,
nextClient: GatewayBrowserClient = client,
hello?: GatewayHelloOk | null,
) => {
snapshot = {
client: nextClient,
phase: connected ? "connected" : "reconnecting",
sessionKey: "main",
hello,
};
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
/** Simple hash-tracking config.get/config.set/config.apply mock gateway. */
export function createConfigServerMock() {
let hashCounter = 1;
let appliedHash = "hash-1";
let storedRaw = '{\n "count": 1\n}\n';
const submissions: Array<{ method: string; raw: string; baseHash: string }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
config: JSON.parse(storedRaw) as Record<string, unknown>,
raw: storedRaw,
hash: `hash-${hashCounter}`,
configRevisionHash: `hash-${hashCounter}`,
appliedConfigHash: appliedHash,
valid: true,
issues: [],
};
}
if (method === "config.set" || method === "config.apply") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
submissions.push({ method, raw, baseHash });
storedRaw = raw;
hashCounter += 1;
if (method === "config.apply") {
appliedHash = `hash-${hashCounter}`;
}
// Like the real gateway: ack with the persisted snapshot hash.
return { hash: `hash-${hashCounter}` };
}
return {};
});
return { request, submissions, currentHash: () => `hash-${hashCounter}` };
}
/**
* createConfigServerMock variant whose FIRST config.set stays pending until
* `firstSet` resolves — for exercising mid-flight edits/reverts/teardown.
*/
export function createDeferredSetServerMock(options: { legacyAck?: boolean } = {}) {
const firstSet = deferred<unknown>();
let hashCounter = 1;
let storedRaw = '{\n "count": 1\n}\n';
const submissions: Array<{ raw: string; baseHash: string }> = [];
const applySubmissions: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
return Promise.resolve({
config: JSON.parse(storedRaw) as Record<string, unknown>,
raw: storedRaw,
hash: `hash-${hashCounter}`,
valid: true,
issues: [],
});
}
if (method === "config.set") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
submissions.push({ raw, baseHash });
storedRaw = raw;
hashCounter += 1;
const ack = options.legacyAck ? {} : { hash: `hash-${hashCounter}` };
return submissions.length === 1 ? firstSet.promise.then(() => ack) : Promise.resolve(ack);
}
if (method === "config.apply") {
const { raw, baseHash } = params as { raw: string; baseHash: string };
applySubmissions.push({ raw, baseHash });
storedRaw = raw;
hashCounter += 1;
return Promise.resolve({ hash: `hash-${hashCounter}` });
}
return Promise.resolve({});
});
return { request, submissions, applySubmissions, firstSet };
}
export function createConfigCapabilityHarness(request: GatewayBrowserClient["request"]) {
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
return { runtimeConfig: createRuntimeConfigCapability(gateway), publish };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,831 @@
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import { cloneConfigObject, serializeConfigForm } from "../config-form-utils.ts";
import {
applyConfigSnapshot,
removeConfigFormValue,
resetConfigPendingChanges,
serializeFormForSubmit,
stageDefaultAgentConfigEntry,
updateConfigFormValue,
updateConfigRawValue,
} from "./config-draft-model.ts";
import {
adoptConfigPatchAck,
applyConfig,
autoSaveConfig,
executeConfigExternalMutation,
loadConfig,
patchConfig,
saveConfig,
teardownFlushConfigDraft,
type ConfigPatchBuildResult,
type ConfigWriteCoordinator,
type ConfigMethod,
type RuntimeConfigExternalMutationOptions,
type RuntimeConfigExternalMutationResult,
} from "./config-gateway-operations.ts";
import {
currentConfigConnectionEpoch,
invalidateConfigConnection,
isCurrentConfigConnection,
nextRequestVersion,
resolveEditableSnapshotConfig,
type RuntimeConfigGateway,
type RuntimeConfigState,
} from "./config-state-model.ts";
/** Debounce window between the last form edit and its automatic config.set. */
const CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS = 800;
type ConfigWriteCoordinatorContext = {
state: RuntimeConfigState;
gateway: RuntimeConfigGateway;
publish: () => void;
run: <T>(task: () => Promise<T>) => Promise<T>;
mutate: (task: () => void) => void;
trackLoad: (key: "config" | "schema", promise: Promise<unknown>) => Promise<void>;
resetLoads: () => void;
resetConfigLoad: () => void;
canCallConfigMethod: (
method: ConfigMethod,
options?: { requireAdvertisement?: boolean },
) => boolean;
cancelAppliedRefresh: () => void;
reconcileAppliedRefresh: () => void;
disposeAppliedRefresh: () => void;
isDisposed: () => boolean;
};
export function createConfigWriteCoordinator({
state,
gateway,
publish,
run,
mutate,
trackLoad,
resetLoads,
resetConfigLoad,
canCallConfigMethod,
cancelAppliedRefresh,
reconcileAppliedRefresh,
disposeAppliedRefresh,
isDisposed,
}: ConfigWriteCoordinatorContext): ConfigWriteCoordinator {
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
let autoSaveInFlight: Promise<unknown> | null = null;
let autoSaveTrailing = false;
let autoSaveDraftConnection: { client: GatewayBrowserClient; epoch: number } | null = null;
let autoSaveRequiresExplicitSubmit = false;
let lastFlightSubmittedRaw: string | null = null;
let lastFlightAckHash: string | null = null;
let manualSubmitInFlight: Promise<unknown> | null = null;
// A write interrupted by a connection change may or may not have committed;
// remembered across the disconnect so the reconnect can reconcile against a
// fresh snapshot before autosave resumes.
let hasInterruptedWrite = false;
let interruptedWriteRaw: string | null = null;
// Blocks trailing autosaves while a discard drains pending writes; the
// drained draft is about to be thrown away, not re-written.
let suppressAutoSave = false;
// Wakes drains awaiting a request a connection change just orphaned — that
// request may never settle, and a drain stuck on it would wedge the app
// updater barrier, applies, and discards until then.
let connectionWake: (() => void) | null = null;
let connectionWakePromise: Promise<void> = Promise.resolve();
const armConnectionWake = () => {
connectionWakePromise = new Promise((resolve) => {
connectionWake = resolve;
});
};
armConnectionWake();
// App-updater interlock: config writes or gateway restarts mid-update can
// corrupt the install, so all writes pause until the updater settles.
let writesSuspended = false;
let writesResumed: (() => void) | null = null;
let writesResumedPromise: Promise<void> = Promise.resolve();
// Submission info of the pending manual SAVE (applies never register:
// a post-apply write is meaningless while the gateway restarts, so the
// teardown flush fail-closes on them).
let manualFlightInfo: { raw: string; ackHash: string | null } | null = null;
const clearAutoSaveDraftConnection = () => {
autoSaveDraftConnection = null;
autoSaveRequiresExplicitSubmit = false;
};
const captureAutoSaveDraftConnection = () => {
if (
autoSaveRequiresExplicitSubmit ||
autoSaveDraftConnection ||
!state.client ||
!state.connected ||
!state.configFormDirty ||
state.configFormMode !== "form"
) {
return;
}
autoSaveDraftConnection = {
client: state.client,
epoch: currentConfigConnectionEpoch(state),
};
};
const bindDraftToExplicitSubmit = () => {
if (!state.client || !state.connected || state.configFormMode !== "form") {
return;
}
autoSaveDraftConnection = {
client: state.client,
epoch: currentConfigConnectionEpoch(state),
};
autoSaveRequiresExplicitSubmit = false;
};
const canAutoSaveDraftOnCurrentConnection = () =>
!autoSaveRequiresExplicitSubmit &&
autoSaveDraftConnection !== null &&
autoSaveDraftConnection.client === state.client &&
autoSaveDraftConnection.epoch === currentConfigConnectionEpoch(state);
const reconcileAutoSaveDraftConnection = () => {
if (state.configFormDirty && state.configFormMode === "form") {
captureAutoSaveDraftConnection();
} else if (autoSaveInFlight === null && manualSubmitInFlight === null) {
clearAutoSaveDraftConnection();
}
};
const cancelScheduledAutoSave = () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
}
autoSaveTrailing = false;
};
const runAutoSave = () => {
if (
isDisposed() ||
suppressAutoSave ||
writesSuspended ||
!canAutoSaveDraftOnCurrentConnection() ||
!canCallConfigMethod("config.set")
) {
return;
}
if (autoSaveInFlight ?? manualSubmitInFlight) {
// Exactly one trailing save catches edits made while a write (auto or
// manual — a concurrent config.set would race the same base hash) is
// in flight; further edits fold into that same trailing run.
autoSaveTrailing = true;
return;
}
cancelAppliedRefresh();
// Captured for teardown: dispose compares the latest draft against the
// in-flight submission to decide whether a final flush is needed, and the
// flush may only CAS against this flight's own ack hash.
lastFlightSubmittedRaw = serializeFormForSubmit(state);
lastFlightAckHash = null;
const flight = run(() =>
autoSaveConfig(
state,
(ackHash) => {
lastFlightAckHash = ackHash;
},
() => canCallConfigMethod("config.set"),
),
)
.catch(() => false)
.then((saved) => {
// A connection change deregisters flights; a stale completion must
// not clear a NEW flight's registration or steal its trailing state.
if (autoSaveInFlight !== flight) {
return;
}
autoSaveInFlight = null;
reconcileAutoSaveDraftConnection();
// One trailing save catches edits (or reverts back to the pre-save
// value) made while the request was in flight. A still-armed debounce
// timer owns its own save, and failed flights never self-retry.
const wantsTrailing =
autoSaveTrailing ||
(saved &&
state.configFormDirty &&
state.configFormMode === "form" &&
autoSaveTimer === null);
autoSaveTrailing = false;
if (wantsTrailing && !isDisposed()) {
runAutoSave();
} else {
reconcileAppliedRefresh();
}
});
autoSaveInFlight = flight;
};
const flushScheduledAutoSave = () => {
if (!autoSaveTimer) {
return;
}
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
runAutoSave();
};
const scheduleAutoSave = () => {
// Only form-draft edits auto-save; raw-text drafts stay manual so a
// half-typed JSON5 buffer never gets written to disk. Suspended writes
// (app updater running) stay dirty and reschedule when suspension lifts.
if (
isDisposed() ||
writesSuspended ||
!canAutoSaveDraftOnCurrentConnection() ||
!canCallConfigMethod("config.set") ||
!state.configFormDirty ||
state.configFormMode !== "form"
) {
return;
}
// A conflict proves the snapshot is stale; retrying against the same base
// hash would fail again and mask the reload warning with "Saving…". Only
// a discard/reload (which installs a fresh snapshot) re-enables autosave.
if (state.configAutoSaveStatus === "conflict") {
return;
}
cancelAppliedRefresh();
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
autoSaveTimer = setTimeout(() => {
autoSaveTimer = null;
runAutoSave();
}, CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
};
// Manual save/apply serialize the current draft themselves; cancel any
// dangling debounce and settle an in-flight autosave first so the explicit
// write does not race it on the baseHash guard. The submit starts
// synchronously when nothing is in flight so it binds to the current
// connection epoch.
// Drains ALL pending config writes: the autosave chain (a settling flight
// can spawn a trailing save) AND any manual Save still in flight.
const drainPendingWrites = async (flushScheduledDraft = false): Promise<void> => {
while (true) {
if (flushScheduledDraft) {
flushScheduledAutoSave();
}
const flight = autoSaveInFlight ?? manualSubmitInFlight;
if (!flight) {
return;
}
// Race the connection wake: a disconnect deregisters in-flight writes,
// and a drain already awaiting one resumes from that deregistration
// instead of depending on the transport's close-time rejection order.
await Promise.race([flight, connectionWakePromise]);
if (isDisposed()) {
return;
}
if (!flushScheduledDraft) {
// save/apply submit the latest full draft themselves. Edits made
// while the preceding flight settled may have armed a fresh debounce;
// cancel it before the explicit write starts or it can race the same
// post-flight base hash.
cancelScheduledAutoSave();
}
}
};
// Discard barrier shared by discardDraft and refresh({discardPendingChanges}):
// settle pending writes with trailing saves suppressed so a late completion
// cannot trail the just-discarded bytes back to disk.
const drainWritesForDiscard = async (): Promise<void> => {
cancelScheduledAutoSave();
if (autoSaveInFlight ?? manualSubmitInFlight) {
suppressAutoSave = true;
try {
await drainPendingWrites();
} finally {
suppressAutoSave = false;
}
}
};
// Explicit ops (save/apply/patch) also serialize among THEMSELVES: two
// callers queued behind the same in-flight write would otherwise both
// finish draining and dispatch against the same base hash.
let explicitOpQueue: Promise<unknown> | null = null;
const afterPendingWritesSettled = <T>(
task: () => Promise<T>,
unavailable: T,
options: { flushScheduledDraft?: boolean; canDispatch?: () => boolean } = {},
): Promise<T> => {
if (writesSuspended) {
return Promise.resolve(unavailable);
}
const client = state.client;
const connectionEpoch = currentConfigConnectionEpoch(state);
if (options.flushScheduledDraft) {
flushScheduledAutoSave();
} else {
cancelScheduledAutoSave();
}
// Start synchronously when no explicit op is queued so the submit binds
// to the CURRENT connection epoch; only genuine queuing pays the hop.
const start = () =>
run(async () => {
// Drain before the explicit op — otherwise an apply could race a
// pending config.set on the same base hash into a CAS failure.
if (autoSaveInFlight ?? manualSubmitInFlight) {
await drainPendingWrites(options.flushScheduledDraft);
}
// The updater may have started while we drained; suspension must be a
// real barrier or an apply could restart the gateway mid-update.
if (writesSuspended || isDisposed()) {
return unavailable;
}
if (!client || !isCurrentConfigConnection(state, client, connectionEpoch)) {
return unavailable;
}
// Hello method/scope metadata can change while the client and
// connection epoch stay stable. Recheck at the dispatch boundary.
if (options.canDispatch && !options.canDispatch()) {
return unavailable;
}
manualFlightInfo = null;
const submit = task();
const settled = submit
.catch(() => unavailable)
.then(() => {
if (manualSubmitInFlight !== settled) {
return;
}
manualSubmitInFlight = null;
// Edits made during the manual flight deferred their autosave
// (runAutoSave treats manual flights as active writes); give them
// their one trailing run. runAutoSave self-guards suspension.
const wantsTrailing = autoSaveTrailing;
autoSaveTrailing = false;
if (wantsTrailing) {
runAutoSave();
}
});
manualSubmitInFlight = settled;
return await submit;
});
const queued = explicitOpQueue ? explicitOpQueue.then(start) : start();
const tail: Promise<unknown> = queued
.catch(() => false)
.then(() => {
if (explicitOpQueue === tail) {
explicitOpQueue = null;
}
});
explicitOpQueue = tail;
return queued;
};
const stopGateway = gateway.subscribe((snapshot) => {
const clientChanged = state.client !== snapshot.client;
const connected = snapshot.phase === "connected";
const connectionChanged = state.connected !== connected;
state.client = snapshot.client;
state.connected = connected;
state.applySessionKey = snapshot.sessionKey;
if (clientChanged || connectionChanged) {
const draftBelongsToPreviousConnection =
state.configFormMode === "form" &&
(state.configFormDirty || autoSaveInFlight !== null || manualSubmitInFlight !== null);
resetLoads();
// A dead prior-connection flight must not keep the reconnected owner's
// explicit-operation FIFO waiting forever.
explicitOpQueue = null;
// A reconnect may reuse the client object. Keep generations monotonic so work
// from the previous connection cannot commit into the new connection epoch.
invalidateConfigConnection(state);
cancelScheduledAutoSave();
cancelAppliedRefresh();
if (draftBelongsToPreviousConnection) {
// A retained draft belongs to the Gateway connection where the edit
// began. Preserve it across replacement, but require an explicit
// Save/Apply or reload before the new Gateway may receive it.
autoSaveRequiresExplicitSubmit = true;
}
if (autoSaveInFlight !== null || manualSubmitInFlight !== null) {
// The epoch guard already blocks these flights from mutating state;
// deregistering releases drain barriers and the trailing-save chain
// promptly instead of waiting on the transport's close-time
// rejection (protocol-client flushRequests rejects all pending
// requests on socket close, so nothing here can hang forever).
// Remember the uncertain submission for reconnect reconciliation.
hasInterruptedWrite = true;
interruptedWriteRaw =
autoSaveInFlight !== null ? lastFlightSubmittedRaw : (manualFlightInfo?.raw ?? null);
autoSaveInFlight = null;
manualSubmitInFlight = null;
autoSaveTrailing = false;
}
// Re-arm before waking so a resumed drain that loops again races the
// fresh (still-pending) signal, not the one just resolved.
const wake = connectionWake;
armConnectionWake();
wake?.();
state.configLoading = false;
state.configSchemaLoading = false;
state.configSaving = false;
state.configApplying = false;
if (state.configAutoSaveStatus === "saving") {
state.configAutoSaveStatus = "idle";
}
if (state.connected && state.client) {
if (hasInterruptedWrite) {
// The interrupted write may or may not have committed. Fetch the
// authoritative snapshot so an uncertain flight cannot leave a
// clean-looking draft or a stale base. Replacement connections
// never resume autosave for the retained draft.
const interruptedRaw = interruptedWriteRaw;
// A revert made while the write was in flight reads clean (the ack
// never rebased the originals), so the reload below would replace
// it with the committed bytes. Capture it for restoration.
const draftFormBefore =
state.configFormMode === "form" && !state.configFormDirty && state.configForm
? cloneConfigObject(state.configForm)
: null;
const draftRawBefore = draftFormBefore ? serializeConfigForm(draftFormBefore) : null;
const reconcile = run(() => loadConfig(state));
void trackLoad("config", reconcile);
void reconcile.then((loaded) => {
if (isDisposed()) {
return;
}
if (!loaded || !state.connected) {
// Reload failed or the connection flipped again: keep the
// interruption metadata so the NEXT reconnect retries
// reconciliation instead of silently taking the plain path.
reconcileAppliedRefresh();
return;
}
hasInterruptedWrite = false;
interruptedWriteRaw = null;
// If the interrupted write DID commit, the fresh snapshot is
// exactly its bytes. Rebase a surviving draft onto the fresh hash
// so the retry doesn't false-conflict against our own write. Any
// other server content keeps the old base and conflicts instead
// of clobbering a foreign writer.
if (interruptedRaw !== null && state.configSnapshot?.raw === interruptedRaw) {
const freshHash = state.configSnapshot.hash ?? null;
if (state.configSnapshot.appliedConfigHash === undefined) {
state.configNeedsApply = true;
}
if (state.configFormDirty) {
if (serializeFormForSubmit(state) === interruptedRaw) {
// The lost acknowledgement was the only missing event: the
// retained bytes are already authoritative, so no draft
// remains to submit on the replacement connection.
applyConfigSnapshot(state, state.configSnapshot, {
discardPendingChanges: true,
});
clearAutoSaveDraftConnection();
} else {
state.configDraftBaseHash = freshHash ?? state.configDraftBaseHash;
}
} else if (
draftFormBefore &&
draftRawBefore !== null &&
draftRawBefore !== interruptedRaw
) {
// Reverted-while-in-flight: the clean-looking pre-reload
// draft differs from the committed bytes, so it was a real
// revert. Restore it as a dirty draft on the fresh base so
// the rescheduled autosave writes it back.
state.configForm = draftFormBefore;
state.configRaw = draftRawBefore;
state.configFormMode = "form";
state.configFormDirty = true;
state.configDraftBaseHash = freshHash ?? state.configDraftBaseHash;
}
}
publish();
reconcileAppliedRefresh();
});
} else {
reconcileAppliedRefresh();
}
}
}
publish();
});
const queueConfigPatch = (resolveOptions: () => ConfigPatchBuildResult): Promise<boolean> => {
cancelAppliedRefresh();
return afterPendingWritesSettled(
async () => {
// A drained autosave can start its own refresh while this patch waits.
cancelAppliedRefresh();
try {
const resolved = resolveOptions();
if ("error" in resolved) {
state.lastError = resolved.error;
return false;
}
return await patchConfig(state, resolved.options, async (ack, snapshotAtDispatch) => {
// The ack is newer than every config.get that began before it.
// Detach and invalidate those loads so stale pre-patch responses
// cannot replace the acknowledged config/hash.
resetConfigLoad();
nextRequestVersion(state, "config");
state.configLoading = false;
if (asConfigRecord(ack.config)) {
adoptConfigPatchAck(state, ack, snapshotAtDispatch);
return;
}
// Older/hash-only acknowledgements do not carry enough data to
// pair their new hash with a safe local document. Force a fresh
// snapshot instead of publishing an inconsistent hash/config.
const refresh = run(() => loadConfig(state));
void trackLoad("config", refresh);
if (!(await refresh)) {
throw new Error(
state.lastError ??
"The configuration patch completed, but its authoritative refresh failed.",
);
}
});
} finally {
reconcileAppliedRefresh();
}
},
false,
{
flushScheduledDraft: true,
canDispatch: () => canCallConfigMethod("config.patch"),
},
).finally(() => {
scheduleAutoSave();
});
};
return {
prepareDiscard: drainWritesForDiscard,
patchForm: (path, value) => {
mutate(() => updateConfigFormValue(state, path, value));
reconcileAutoSaveDraftConnection();
scheduleAutoSave();
},
removeFormValue: (path) => {
mutate(() => removeConfigFormValue(state, path));
reconcileAutoSaveDraftConnection();
scheduleAutoSave();
},
setRaw: (value) => mutate(() => updateConfigRawValue(state, value)),
resetDraft: () => {
cancelScheduledAutoSave();
mutate(() => resetConfigPendingChanges(state));
clearAutoSaveDraftConnection();
reconcileAppliedRefresh();
},
discardDraft: async () => {
// Settle pending writes first (with trailing saves suppressed — the
// draft is being thrown away, not re-written) so a late ack cannot
// re-dirty or trail-write over the discard.
await drainWritesForDiscard();
if (state.connected && state.client) {
cancelAppliedRefresh();
try {
await trackLoad(
"config",
run(() => loadConfig(state, { discardPendingChanges: true })),
);
clearAutoSaveDraftConnection();
} finally {
reconcileAppliedRefresh();
}
return;
}
// Offline: a network refresh would silently no-op and strand the
// draft; fall back to a pure local reset onto the snapshot originals.
mutate(() => {
resetConfigPendingChanges(state);
// Conflict marks the snapshot itself stale; an offline reset onto
// those stale originals must NOT pretend to have reconciled — only a
// connected reload clears conflict (same invariant as elsewhere).
if (state.configAutoSaveStatus !== "conflict") {
state.configAutoSaveStatus = "idle";
state.lastError = null;
}
});
clearAutoSaveDraftConnection();
},
setWritesSuspended: (suspended) => {
if (writesSuspended === suspended) {
return;
}
writesSuspended = suspended;
if (suspended) {
cancelScheduledAutoSave();
writesResumedPromise = new Promise((resolve) => {
writesResumed = resolve;
});
} else {
const resume = writesResumed;
writesResumed = null;
resume?.();
// Edits made during the update save once it ends.
scheduleAutoSave();
}
},
waitForPendingWrites: () => {
// A debounce timer represents pending persisted intent too. Convert it
// into a tracked flight before draining so external writers cannot race
// the draft simply because the user clicked again within 800 ms.
flushScheduledAutoSave();
return drainPendingWrites(true);
},
save: (options = {}) => {
const canDispatch = () =>
canCallConfigMethod("config.set") && (options.canDispatch?.() ?? true);
return !canDispatch()
? Promise.resolve(false)
: afterPendingWritesSettled(
async () => {
bindDraftToExplicitSubmit();
cancelAppliedRefresh();
try {
const saved = await saveConfig(
state,
(info) => {
manualFlightInfo = info;
},
canDispatch,
);
reconcileAutoSaveDraftConnection();
return saved;
} finally {
reconcileAppliedRefresh();
}
},
false,
{ canDispatch },
);
},
apply: () =>
!canCallConfigMethod("config.apply")
? Promise.resolve(false)
: afterPendingWritesSettled(
async () => {
bindDraftToExplicitSubmit();
cancelAppliedRefresh();
// Checked after the drain: a raw draft whose explicit Save is in
// flight resolves clean and may apply. A raw draft that is STILL
// dirty here was never reviewed-saved — applying would implicitly
// write unreviewed raw text, so refuse and point at the Raw editor.
if (state.configFormDirty && state.configFormMode === "raw") {
state.configAutoSaveStatus = "error";
state.lastError = t("configView.rawDraftBlocksApply");
reconcileAppliedRefresh();
return false;
}
try {
const applied = await applyConfig(state, () => canCallConfigMethod("config.apply"));
reconcileAutoSaveDraftConnection();
return applied;
} finally {
reconcileAppliedRefresh();
}
},
false,
{ canDispatch: () => canCallConfigMethod("config.apply") },
),
stageDefaultAgent: (agentId) => {
if (!canCallConfigMethod("config.set")) {
return false;
}
const changed = stageDefaultAgentConfigEntry(state, agentId);
publish();
reconcileAutoSaveDraftConnection();
scheduleAutoSave();
return changed;
},
// Patches are config writes too: they must honor updater suspension and
// register as a drainable flight, or a patch could overlap update.run.
// Unlike save/apply, a patch does not submit the form draft — flush a
// scheduled autosave into a flight first (the settle below drains it) and
// re-arm the debounce after so a dirty form is never left timer-less.
patch: (options) =>
canCallConfigMethod("config.patch") && (options.canDispatch?.() ?? true)
? queueConfigPatch(() => ({ options }))
: Promise.resolve(false),
patchFromSnapshot: (build) =>
canCallConfigMethod("config.patch")
? queueConfigPatch(() => {
const config = resolveEditableSnapshotConfig(state.configSnapshot);
return config
? build(config)
: { error: "Configuration is unavailable; refresh and try again." };
})
: Promise.resolve(false),
runExternalMutation: async <T>(
task: (client: GatewayBrowserClient) => Promise<T>,
options: RuntimeConfigExternalMutationOptions = {},
): Promise<RuntimeConfigExternalMutationResult<T>> => {
const mutationClient = state.client;
const mutationConnectionEpoch = currentConfigConnectionEpoch(state);
while (true) {
if (options.waitForWritesResumed && writesSuspended && !isDisposed()) {
await writesResumedPromise;
}
const unavailable: RuntimeConfigExternalMutationResult<T> = {
ok: false,
reason: writesSuspended ? "suspended" : "unavailable",
error: writesSuspended
? "Configuration writes are temporarily suspended."
: "Configuration is unavailable; reconnect and try again.",
};
if (
!mutationClient ||
!isCurrentConfigConnection(state, mutationClient, mutationConnectionEpoch)
) {
return {
ok: false,
reason: "unavailable",
error: "Connection changed before the configuration update started.",
};
}
const result = await afterPendingWritesSettled<RuntimeConfigExternalMutationResult<T>>(
() =>
executeConfigExternalMutation(
state,
mutationClient,
mutationConnectionEpoch,
task,
options,
async () => {
// Do not join a config.get that started before the external RPC.
const refresh = run(() => loadConfig(state));
void trackLoad("config", refresh);
return await refresh;
},
),
unavailable,
{ flushScheduledDraft: true },
);
if (
!(
options.waitForWritesResumed &&
!isDisposed() &&
!result.ok &&
(result.reason === "suspended" || writesSuspended)
)
) {
return result;
}
}
},
dispose() {
writesResumed?.();
writesResumed = null;
// Free any drain awaiting a flight that will never be reconciled now;
// the isDisposed() guard exits its loop.
connectionWake?.();
// SPA teardown right after an edit must not silently drop it: fire one
// last save before timers die. Fire-and-forget — the request leaves
// synchronously (or chains once behind an in-flight save) and the
// stale-epoch guards skip all state mutation once the connection is
// invalidated below.
const client = state.client;
const canFlush =
state.connected &&
client !== null &&
state.configFormMode === "form" &&
!writesSuspended &&
canAutoSaveDraftOnCurrentConnection() &&
canCallConfigMethod("config.set");
const autoFlight = autoSaveInFlight;
const pendingFlight = autoFlight ?? manualSubmitInFlight;
cancelScheduledAutoSave();
disposeAppliedRefresh();
if (canFlush && pendingFlight) {
void pendingFlight.then(() => {
// The settled flight could not update dirty/base state past the
// epoch guard; a draft whose bytes differ from that submission is a
// newer edit and gets exactly one chained final save — never a
// parallel one. Auto flights report via lastFlight*, manual saves
// via manualFlightInfo; applies never register info (a post-apply
// write is meaningless while the gateway restarts), and without the
// flight's own ack hash there is no CAS base we can trust — both
// fail closed rather than risk clobbering a foreign write.
const submitted = autoFlight
? { raw: lastFlightSubmittedRaw, ackHash: lastFlightAckHash }
: manualFlightInfo;
const ackHash = submitted?.ackHash ?? null;
const submittedRaw = submitted?.raw ?? null;
// Bytes-vs-submission is the only trustworthy signal here: the
// epoch guard blocked the ack's rebase, so a revert back to the
// pre-save value reads configFormDirty=false while the persisted
// bytes are still the unreverted submission.
if (ackHash && submittedRaw !== null && serializeFormForSubmit(state) !== submittedRaw) {
teardownFlushConfigDraft(state, client, ackHash, () =>
canCallConfigMethod("config.set"),
);
}
});
} else if (canFlush && state.configFormDirty) {
void autoSaveConfig(state, undefined, () => canCallConfigMethod("config.set"));
}
invalidateConfigConnection(state);
state.connected = false;
state.configLoading = false;
state.configSchemaLoading = false;
state.configSaving = false;
state.configApplying = false;
stopGateway();
},
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { t } from "../../i18n/index.ts";
import type { RuntimeConfigCapability } from "./index.ts";
import type { RuntimeConfigCapability } from "./runtime-config-capability.ts";
export const MCP_SERVER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
@@ -0,0 +1,977 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { ConfigSnapshot } from "../../api/types.ts";
import {
CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS,
deferred,
createGatewayHarness,
createConfigServerMock,
createDeferredSetServerMock,
createConfigCapabilityHarness,
} from "./config-test-harness.ts";
import { createRuntimeConfigCapability } from "./runtime-config-capability.ts";
describe("runtime config capability", () => {
it("does not stage a default agent after access downgrades", async () => {
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
sourceConfig: {
agents: {
entries: {
main: {},
reviewer: { default: true },
},
},
},
hash: "hash-1",
valid: true,
issues: [],
};
}
return { hash: "hash-2" };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
publish(true, client, {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["config.get", "config.set"] },
} as GatewayHelloOk);
expect(runtimeConfig.stageDefaultAgent("main")).toBe(false);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configForm).toEqual({
agents: { entries: { main: {}, reviewer: { default: true } } },
});
runtimeConfig.dispose();
});
it("ignores a save completion from an earlier connection epoch", async () => {
const save = deferred<unknown>();
let getCount = 0;
const request = vi.fn((method: string) => {
if (method === "config.get") {
getCount += 1;
return Promise.resolve({
config: { value: getCount },
hash: `hash-${getCount}`,
valid: true,
issues: [],
});
}
if (method === "config.set") {
return save.promise;
}
return Promise.resolve({});
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["value"], 2);
const staleSave = runtimeConfig.save();
publish(false);
publish(true);
save.resolve({});
await expect(staleSave).resolves.toBe(false);
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configSaving).toBe(false);
runtimeConfig.dispose();
});
it("does not auto-save a dirty draft after reconnecting with read-only access", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const client = { request: server.request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
publish(true, client, {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["config.get", "config.set"] },
} as GatewayHelloOk);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
runtimeConfig.dispose();
});
it("rechecks operator access after the original-config parser settles", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const client = { request: server.request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
const originalParse = deferred<void>();
await runtimeConfig.ensureLoaded();
runtimeConfig.state.configRawOriginalParsePending = originalParse.promise;
runtimeConfig.patchForm(["count"], 2);
const timerAdvance = vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
await Promise.resolve();
publish(true, client, {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["config.get", "config.set"] },
} as GatewayHelloOk);
originalParse.resolve();
await timerAdvance;
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
runtimeConfig.dispose();
});
it("refreshes until a hot-reloaded revision becomes active", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "config.get") {
return {};
}
getCount += 1;
return {
config: { count: 2 },
raw: '{"count":2}',
hash: "raw-hash-2",
configRevisionHash: "revision-2",
appliedConfigHash: getCount >= 2 ? "revision-2" : "revision-1",
valid: true,
issues: [],
};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("continues mismatch polling after a transient config.get failure", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "config.get") {
return {};
}
getCount += 1;
if (getCount === 2) {
throw new Error("gateway restarting");
}
return {
config: { count: 2 },
raw: '{"count":2}',
hash: "raw-hash-2",
configRevisionHash: "revision-2",
appliedConfigHash: getCount >= 3 ? "revision-2" : "revision-1",
valid: true,
issues: [],
};
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(750);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("discards an applied-hash poll superseded by a config write", async () => {
vi.useFakeTimers();
const stalePoll = deferred<ConfigSnapshot>();
let getCount = 0;
const request = vi.fn((method: string) => {
if (method === "config.get") {
getCount += 1;
if (getCount === 2) {
return stalePoll.promise;
}
return Promise.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-0",
valid: true,
issues: [],
});
}
return Promise.resolve(method === "config.set" ? { hash: "hash-2" } : {});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
stalePoll.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-1",
valid: true,
issues: [],
});
await vi.advanceTimersByTimeAsync(0);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("does not re-arm an invalidated applied-hash poll during config.patch", async () => {
vi.useFakeTimers();
const stalePoll = deferred<ConfigSnapshot>();
const patchGate = deferred<unknown>();
let getCount = 0;
const request = vi.fn((method: string) => {
if (method === "config.get") {
getCount += 1;
if (getCount === 2) {
return stalePoll.promise;
}
return Promise.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-0",
valid: true,
issues: [],
});
}
if (method === "config.patch") {
return patchGate.promise;
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
const patchPromise = runtimeConfig.patch({ raw: { count: 2 }, note: "test patch" });
await vi.advanceTimersByTimeAsync(0);
stalePoll.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-1",
valid: true,
issues: [],
});
await vi.advanceTimersByTimeAsync(1_000);
expect(getCount).toBe(2);
patchGate.resolve({ hash: "hash-2" });
await vi.advanceTimersByTimeAsync(0);
await expect(patchPromise).resolves.toBe(true);
runtimeConfig.dispose();
});
it("keeps a stranded dirty draft unsaved until an explicit save after replacement", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const replacementClient = {
request: server.request,
} as unknown as GatewayBrowserClient;
const { runtimeConfig, publish } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
publish(false);
// The disconnect cancelled the debounce; nothing fires while offline.
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 3);
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
publish(true, replacementClient);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2);
expect(server.submissions).toHaveLength(0);
expect(runtimeConfig.state.configFormDirty).toBe(true);
await expect(runtimeConfig.save()).resolves.toBe(true);
expect(server.submissions).toEqual([
{ method: "config.set", raw: '{\n "count": 2\n}\n', baseHash: "hash-1" },
]);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("flushes a dirty draft once on dispose instead of dropping it", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
runtimeConfig.dispose();
// The teardown flush leaves synchronously; no timer needs to fire.
expect(server.submissions).toEqual([
{ method: "config.set", raw: '{\n "count": 2\n}\n', baseHash: "hash-1" },
]);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 4);
expect(server.submissions).toHaveLength(1);
});
it("does not flush clean or raw drafts on dispose", async () => {
vi.useFakeTimers();
const server = createConfigServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
server.request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.setRaw('{\n "count": 5\n}\n');
runtimeConfig.dispose();
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2);
expect(server.submissions).toHaveLength(0);
});
it("chains one final save when disposed mid-flight with a newer edit", async () => {
vi.useFakeTimers();
const { request, submissions, firstSet } = createDeferredSetServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
runtimeConfig.patchForm(["count"], 3);
runtimeConfig.dispose();
firstSet.resolve({});
await vi.advanceTimersByTimeAsync(0);
// The newer draft lands once, based on the flight's ack hash.
expect(submissions).toHaveLength(2);
expect(submissions[1]).toEqual({ raw: '{\n "count": 3\n}\n', baseHash: "hash-2" });
});
it("does not chain an extra save when disposed mid-flight without newer edits", async () => {
vi.useFakeTimers();
const { request, submissions, firstSet } = createDeferredSetServerMock();
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
runtimeConfig.dispose();
firstSet.resolve({});
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(submissions).toHaveLength(1);
});
it("classifies an external mutation interrupted by disconnect as retryable", async () => {
const mutation = deferred<unknown>();
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
});
}
if (method === "plugins.setEnabled") {
return mutation.promise;
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const resultPromise = runtimeConfig.runExternalMutation((client) =>
client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }),
);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("plugins.setEnabled", expect.anything()),
);
publish(false);
mutation.reject(new Error("socket closed"));
await expect(resultPromise).resolves.toEqual({
ok: false,
reason: "unavailable",
error: "Connection changed before the configuration update completed.",
});
runtimeConfig.dispose();
});
it("does not retarget a suspended external mutation after the gateway changes", async () => {
const requestA = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
};
}
return { ok: true };
});
const requestB = vi.fn(async () => ({ ok: true }));
const clientA = { request: requestA } as unknown as GatewayBrowserClient;
const clientB = { request: requestB } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(clientA);
const runtimeConfig = createRuntimeConfigCapability(gateway);
await runtimeConfig.ensureLoaded();
requestA.mockClear();
runtimeConfig.setWritesSuspended(true);
const resultPromise = runtimeConfig.runExternalMutation(
(client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }),
{ waitForWritesResumed: true },
);
await Promise.resolve();
publish(true, clientB);
runtimeConfig.setWritesSuspended(false);
await expect(resultPromise).resolves.toEqual({
ok: false,
reason: "unavailable",
error: "Connection changed before the configuration update started.",
});
expect(requestA).not.toHaveBeenCalled();
expect(requestB).not.toHaveBeenCalled();
runtimeConfig.dispose();
});
it("reconciles an uncertain in-flight save without autosaving its trailing draft", async () => {
vi.useFakeTimers();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
const sets: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
return Promise.resolve({
config: JSON.parse(committedRaw) as Record<string, unknown>,
raw: committedRaw,
hash,
valid: true,
issues: [],
});
}
if (method === "config.set") {
sets.push(params as { raw: string; baseHash: string });
if (sets.length === 1) {
// The server commits the first save, but the connection dies
// before the acknowledgement arrives.
committedRaw = (params as { raw: string }).raw;
hash = "hash-2";
return new Promise(() => {});
}
committedRaw = (params as { raw: string }).raw;
hash = "hash-3";
return Promise.resolve({ hash });
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
// Edit again mid-flight, then drop the connection before the ack lands.
runtimeConfig.patchForm(["count"], 3);
publish(false);
publish(true);
// Reconnect fetches the authoritative snapshot; the fresh bytes match the
// interrupted submission, so the surviving draft is rebased onto the
// committed hash without sending it through the replacement connection.
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configDraftBaseHash).toBe("hash-2");
await expect(runtimeConfig.save()).resolves.toBe(true);
expect(sets).toHaveLength(2);
expect(sets[1]).toEqual({ raw: '{\n "count": 3\n}\n', baseHash: "hash-2" });
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.dispose();
});
it("lets a reconnected explicit op bypass a dead prior-connection FIFO", async () => {
const deadSet = deferred<unknown>();
const methods: string[] = [];
const request = vi.fn((method: string) => {
methods.push(method);
if (method === "config.get") {
return Promise.resolve({ config: { count: 1 }, hash: "hash-1", valid: true, issues: [] });
}
if (method === "config.set") {
return deadSet.promise;
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
void runtimeConfig.save();
void runtimeConfig.apply();
await vi.waitFor(() => expect(methods).toContain("config.set"));
publish(false);
publish(true);
await expect(
runtimeConfig.patch({ raw: { ui: { prefs: { themeMode: "dark" } } }, note: "test" }),
).resolves.toBe(true);
expect(methods).toContain("config.patch");
expect(methods).not.toContain("config.apply");
runtimeConfig.dispose();
});
it("does not dispatch an explicit op enqueued before reconnect", async () => {
const firstPatch = deferred<unknown>();
let patchCalls = 0;
let setCalls = 0;
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({ config: { count: 1 }, hash: "hash-1", valid: true, issues: [] });
}
if (method === "config.patch") {
patchCalls += 1;
return firstPatch.promise;
}
if (method === "config.set") {
setCalls += 1;
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const stalePatch = runtimeConfig.patch({ raw: { ui: { prefs: {} } }, note: "test" });
const staleSet = runtimeConfig.save();
await vi.waitFor(() => expect(patchCalls).toBe(1));
publish(false);
publish(true);
firstPatch.resolve({});
await expect(stalePatch).resolves.toBe(false);
await expect(staleSet).resolves.toBe(false);
expect(setCalls).toBe(0);
runtimeConfig.dispose();
});
it("rechecks operator access before a queued config write dispatches", async () => {
const firstPatch = deferred<unknown>();
let patchCalls = 0;
let setCalls = 0;
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({ config: { count: 1 }, hash: "hash-1", valid: true, issues: [] });
}
if (method === "config.patch") {
patchCalls += 1;
return firstPatch.promise;
}
if (method === "config.set") {
setCalls += 1;
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
const patch = runtimeConfig.patch({ raw: { ui: { prefs: {} } }, note: "test" });
const save = runtimeConfig.save();
await vi.waitFor(() => expect(patchCalls).toBe(1));
publish(true, undefined, {
type: "hello-ok",
protocol: 4,
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["config.get", "config.patch", "config.set"] },
} as GatewayHelloOk);
firstPatch.resolve({});
await patch;
await expect(save).resolves.toBe(false);
expect(setCalls).toBe(0);
expect(runtimeConfig.canSet).toBe(false);
runtimeConfig.dispose();
});
it.each(["save", "patch"] as const)(
"rechecks the caller lifecycle before a queued config %s dispatches",
async (action) => {
const firstPatch = deferred<unknown>();
const methods: string[] = [];
let canDispatch = true;
const request = vi.fn((method: string) => {
methods.push(method);
if (method === "config.get") {
return Promise.resolve({
config: { count: 1 },
raw: '{"count":1}',
hash: "hash-1",
valid: true,
issues: [],
});
}
if (method === "config.patch" && methods.filter((entry) => entry === method).length === 1) {
return firstPatch.promise;
}
return Promise.resolve({});
});
const { runtimeConfig } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
methods.length = 0;
const activePatch = runtimeConfig.patch({ raw: { count: 2 }, note: "active" });
const queued =
action === "save"
? runtimeConfig.save({ canDispatch: () => canDispatch })
: runtimeConfig.patch({
raw: { count: 3 },
note: "queued",
canDispatch: () => canDispatch,
});
await vi.waitFor(() => expect(methods).toEqual(["config.patch"]));
canDispatch = false;
firstPatch.resolve({ config: { count: 2 }, hash: "hash-2" });
await expect(activePatch).resolves.toBe(true);
await expect(queued).resolves.toBe(false);
expect(methods).toEqual(["config.patch"]);
runtimeConfig.dispose();
},
);
it.each([
{ action: "save" as const, method: "config.set" },
{ action: "apply" as const, method: "config.apply" },
])("rechecks operator access after parsing before $method dispatch", async ({ action }) => {
const server = createConfigServerMock();
const client = { request: server.request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
const originalParse = deferred<void>();
await runtimeConfig.ensureLoaded();
runtimeConfig.state.configRawOriginalParsePending = originalParse.promise;
const result = runtimeConfig[action]();
await Promise.resolve();
publish(true, client, {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["config.get", "config.apply", "config.set"] },
} as GatewayHelloOk);
originalParse.resolve();
await expect(result).resolves.toBe(false);
expect(server.submissions).toHaveLength(0);
runtimeConfig.dispose();
});
it("frees write drains when a disconnect orphans a hung request", async () => {
vi.useFakeTimers();
const request = vi.fn((method: string) => {
if (method === "config.get") {
return Promise.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
});
}
if (method === "config.set") {
// The connection dies before this ever settles.
return new Promise(() => {});
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
let drained = false;
const drainPromise = runtimeConfig.waitForPendingWrites().then(() => {
drained = true;
});
await vi.advanceTimersByTimeAsync(0);
expect(drained).toBe(false);
// Disconnect deregisters the hung flight; a drain already awaiting it
// (e.g. the app updater barrier) must resume instead of wedging forever.
publish(false);
await drainPromise;
expect(drained).toBe(true);
runtimeConfig.dispose();
});
it("recovers a manual save whose ack was lost to a disconnect", async () => {
vi.useFakeTimers();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
const sets: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
return Promise.resolve({
config: JSON.parse(committedRaw) as Record<string, unknown>,
raw: committedRaw,
hash,
valid: true,
issues: [],
});
}
if (method === "config.set") {
sets.push(params as { raw: string; baseHash: string });
if (sets.length === 1) {
// Commits server-side, but the response never arrives.
committedRaw = (params as { raw: string }).raw;
hash = "hash-2";
return new Promise(() => {});
}
hash = "hash-3";
return Promise.resolve({ hash });
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
void runtimeConfig.save();
await vi.advanceTimersByTimeAsync(0);
expect(sets).toHaveLength(1);
publish(false);
publish(true);
await vi.advanceTimersByTimeAsync(0);
// The reconnect reload recognizes the committed bytes as ours even
// though the ack (and its manualFlightInfo hash) never arrived: the
// process-local pending state survives instead of silently disappearing.
expect(runtimeConfig.state.configNeedsApply).toBe(true);
// The matching committed bytes leave no draft to retry on the replacement.
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
expect(runtimeConfig.state.configFormDirty).toBe(false);
runtimeConfig.dispose();
});
it("retries reconciliation on the next reconnect when the reload fails", async () => {
vi.useFakeTimers();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
let failNextGet = false;
const sets: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
if (failNextGet) {
failNextGet = false;
return Promise.reject(new Error("gateway hiccup"));
}
return Promise.resolve({
config: JSON.parse(committedRaw) as Record<string, unknown>,
raw: committedRaw,
hash,
valid: true,
issues: [],
});
}
if (method === "config.set") {
sets.push(params as { raw: string; baseHash: string });
if (sets.length === 1) {
committedRaw = (params as { raw: string }).raw;
hash = "hash-2";
return new Promise(() => {});
}
hash = "hash-3";
return Promise.resolve({ hash });
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
// First reconnect's reconciliation reload fails; the interruption
// metadata must survive so the NEXT reconnect completes it instead of
// silently taking the plain path with a stale base.
failNextGet = true;
publish(false);
publish(true);
await vi.advanceTimersByTimeAsync(0);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
publish(false);
publish(true);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
expect(sets).toHaveLength(1);
expect(runtimeConfig.state.configFormDirty).toBe(false);
runtimeConfig.dispose();
});
it("restores a revert made while the interrupted write was in flight", async () => {
vi.useFakeTimers();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
const sets: Array<{ raw: string; baseHash: string }> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "config.get") {
return Promise.resolve({
config: JSON.parse(committedRaw) as Record<string, unknown>,
raw: committedRaw,
hash,
valid: true,
issues: [],
});
}
if (method === "config.set") {
sets.push(params as { raw: string; baseHash: string });
if (sets.length === 1) {
// Commits server-side; the ack is lost to the disconnect.
committedRaw = (params as { raw: string }).raw;
hash = "hash-2";
return new Promise(() => {});
}
committedRaw = (params as { raw: string }).raw;
hash = "hash-3";
return Promise.resolve({ hash });
}
return Promise.resolve({});
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
// Revert to the original while the save is in flight: the draft reads
// clean, so a plain reconnect reload would silently replace it with the
// committed bytes and drop the revert forever.
runtimeConfig.patchForm(["count"], 1);
expect(runtimeConfig.state.configFormDirty).toBe(false);
publish(false);
publish(true);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(sets).toHaveLength(1);
expect(runtimeConfig.state.configFormDirty).toBe(true);
expect(runtimeConfig.state.configDraftBaseHash).toBe("hash-2");
expect(runtimeConfig.state.configForm).toEqual({ count: 1 });
await expect(runtimeConfig.save()).resolves.toBe(true);
expect(sets).toHaveLength(2);
expect(sets[1]).toEqual({ raw: '{\n "count": 1\n}\n', baseHash: "hash-2" });
expect(runtimeConfig.state.configForm).toEqual({ count: 1 });
expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved");
runtimeConfig.dispose();
});
it("keeps the conflict status through an offline discard", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
if (method === "config.set") {
throw new Error("config changed since last load; re-run config.get and retry");
}
return {};
});
const { runtimeConfig, publish } = createConfigCapabilityHarness(
request as GatewayBrowserClient["request"],
);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
// Offline discard resets the draft locally but must not pretend the
// stale snapshot was reconciled; only a connected reload clears conflict.
publish(false);
await runtimeConfig.discardDraft();
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configRaw).toBe('{\n "count": 1\n}\n');
expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict");
runtimeConfig.dispose();
});
});
@@ -0,0 +1,245 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { canCallGatewayMethod } from "../gateway-methods.ts";
import { createAppliedConfigRefreshController } from "./applied-refresh.ts";
import { clearConfigDraftTracking } from "./config-draft-model.ts";
import {
loadConfig,
loadConfigSchema,
lookupConfigSchemaPath,
openConfigFile,
type ConfigPatchBuilder,
type ConfigWriteCoordinator,
type ConfigMethod,
type ConfigPatchOptions,
type RuntimeConfigDispatchOptions,
type RuntimeConfigExternalMutationOptions,
type RuntimeConfigExternalMutationResult,
} from "./config-gateway-operations.ts";
import {
agentConfigEntry,
clearConfigRequestVersions,
createInitialConfigState,
type AgentConfigEntryTarget,
type LoadConfigOptions,
type RuntimeConfigGateway,
type RuntimeConfigState,
} from "./config-state-model.ts";
import { createConfigWriteCoordinator } from "./config-write-coordinator.ts";
export type RuntimeConfigCapability = {
readonly state: RuntimeConfigState;
readonly canSet?: boolean;
readonly canApply?: boolean;
readonly canPatch?: boolean;
readonly canOpenFile?: boolean;
ensureLoaded: () => Promise<void>;
ensureSchemaLoaded: () => Promise<void>;
refresh: (options?: LoadConfigOptions) => Promise<void>;
refreshSchema: () => Promise<void>;
patchForm: (path: Array<string | number>, value: unknown) => void;
removeFormValue: (path: Array<string | number>) => void;
setRaw: (value: string) => void;
resetDraft: () => void;
/** Discards pending edits: reloads from disk when connected, else resets locally. */
discardDraft: () => Promise<void>;
/** Pauses/resumes all config writes (autosave + manual) while e.g. the app updater runs. */
setWritesSuspended: (suspended: boolean) => void;
/** Resolves once no config write is in flight (used as an updater barrier). */
waitForPendingWrites: () => Promise<void>;
save: (options?: RuntimeConfigDispatchOptions) => Promise<boolean>;
apply: () => Promise<boolean>;
openFile: () => Promise<void>;
/** Resolves the authored keyed entry; ensure returns a writable target without mutating. */
agentEntry: (agentId: string, options?: { ensure?: boolean }) => AgentConfigEntryTarget | null;
stageDefaultAgent: (agentId: string) => boolean;
patch: (options: ConfigPatchOptions) => Promise<boolean>;
patchFromSnapshot: (build: ConfigPatchBuilder) => Promise<boolean>;
/**
* Serializes a config-writing RPC behind this capability's pending draft,
* then refreshes the authoritative snapshot before resolving.
*/
runExternalMutation: <T>(
task: (client: GatewayBrowserClient) => Promise<T>,
options?: RuntimeConfigExternalMutationOptions,
) => Promise<RuntimeConfigExternalMutationResult<T>>;
lookupSchemaPath: (path: string) => Promise<unknown>;
subscribe: (listener: (state: RuntimeConfigState) => void) => () => void;
dispose: () => void;
};
export function createRuntimeConfigCapability(
gateway: RuntimeConfigGateway,
): RuntimeConfigCapability {
const state = createInitialConfigState(gateway.snapshot);
const listeners = new Set<(state: RuntimeConfigState) => void>();
let configLoad: Promise<void> | null = null;
let schemaLoad: Promise<void> | null = null;
let disposed = false;
const canCallConfigMethod = (
method: ConfigMethod,
options?: { requireAdvertisement?: boolean },
) =>
canCallGatewayMethod(
{
client: gateway.snapshot.client,
hello: gateway.snapshot.hello ?? null,
phase: gateway.snapshot.phase,
},
method,
"operator.admin",
options,
);
const publish = () => {
if (disposed) {
return;
}
for (const listener of listeners) {
listener(state);
}
};
const run = async <T>(task: () => Promise<T>): Promise<T> => {
try {
const result = task();
// Async config owners mutate their busy flag before the first await.
// Publish that transition so editors can lock before accepting more input.
publish();
return await result;
} finally {
publish();
}
};
const mutate = (task: () => void) => {
task();
publish();
};
const trackLoad = (key: "config" | "schema", promise: Promise<unknown>): Promise<void> => {
const next = promise
.then(() => undefined)
.finally(() => {
if (key === "config" && configLoad === next) {
configLoad = null;
} else if (key === "schema" && schemaLoad === next) {
schemaLoad = null;
}
});
if (key === "config") {
configLoad = next;
} else {
schemaLoad = next;
}
return next;
};
const loadOnce = (key: "config" | "schema", task: () => Promise<unknown>): Promise<void> => {
const current = key === "config" ? configLoad : schemaLoad;
return current ?? trackLoad(key, run(task));
};
const appliedRefresh = createAppliedConfigRefreshController({
shouldRefresh: () =>
!disposed &&
state.connected &&
state.configNeedsApply &&
state.configSnapshot?.appliedConfigHash !== undefined,
refresh: (isCurrent) => loadOnce("config", () => loadConfig(state, {}, isCurrent)),
});
const writes: ConfigWriteCoordinator = createConfigWriteCoordinator({
state,
gateway,
publish,
run,
mutate,
trackLoad,
resetLoads: () => {
configLoad = null;
schemaLoad = null;
},
resetConfigLoad: () => {
configLoad = null;
},
canCallConfigMethod,
cancelAppliedRefresh: appliedRefresh.cancel,
reconcileAppliedRefresh: appliedRefresh.reconcile,
disposeAppliedRefresh: appliedRefresh.dispose,
isDisposed: () => disposed,
});
const ensureLoaded = async () => {
if (!state.configSnapshot) {
await loadOnce("config", () => loadConfig(state));
}
appliedRefresh.reconcile();
};
const ensureSchemaLoaded = () =>
state.configSchema ? Promise.resolve() : loadOnce("schema", () => loadConfigSchema(state));
return {
get state() {
return state;
},
get canSet() {
return canCallConfigMethod("config.set");
},
get canApply() {
return canCallConfigMethod("config.apply");
},
get canPatch() {
return canCallConfigMethod("config.patch");
},
get canOpenFile() {
return canCallConfigMethod("config.openFile", { requireAdvertisement: false });
},
ensureLoaded,
ensureSchemaLoaded,
refresh: async (options) => {
if (options?.discardPendingChanges) {
await writes.prepareDiscard();
}
appliedRefresh.cancel();
try {
await trackLoad(
"config",
run(() => loadConfig(state, options)),
);
} finally {
appliedRefresh.reconcile();
}
},
refreshSchema: () =>
trackLoad(
"schema",
run(() => loadConfigSchema(state)),
),
patchForm: writes.patchForm,
removeFormValue: writes.removeFormValue,
setRaw: writes.setRaw,
resetDraft: writes.resetDraft,
discardDraft: writes.discardDraft,
setWritesSuspended: writes.setWritesSuspended,
waitForPendingWrites: writes.waitForPendingWrites,
save: writes.save,
apply: writes.apply,
openFile: () =>
canCallConfigMethod("config.openFile", { requireAdvertisement: false })
? run(() => openConfigFile(state))
: Promise.resolve(),
agentEntry: (agentId, options) => agentConfigEntry(state, agentId, options),
stageDefaultAgent: writes.stageDefaultAgent,
patch: writes.patch,
patchFromSnapshot: writes.patchFromSnapshot,
runExternalMutation: writes.runExternalMutation,
lookupSchemaPath: (path) => run(() => lookupConfigSchemaPath(state, path)),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
disposed = true;
writes.dispose();
listeners.clear();
clearConfigRequestVersions(state);
clearConfigDraftTracking(state);
},
};
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
PluginsUninstallResult,
} from "../../../../packages/gateway-protocol/src/schema/plugins.js";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { RuntimeConfigCapability } from "../config/index.ts";
import type { RuntimeConfigCapability } from "../config/runtime-config-capability.ts";
export type PluginCatalogItem = PluginCatalogEntry;
export type PluginListResult = ProtocolPluginsListResult;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { RuntimeConfigCapability } from "../config/index.ts";
import type { RuntimeConfigCapability } from "../config/runtime-config-capability.ts";
export type SkillConfigMutationOwner = Pick<RuntimeConfigCapability, "runExternalMutation">;
+1 -1
View File
@@ -4,7 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { createRuntimeConfigCapability } from "../config/index.ts";
import { createRuntimeConfigCapability } from "../config/runtime-config-capability.ts";
import { searchClawHub } from "./clawhub-search.ts";
import {
clawhubVerdictKey,
+1 -1
View File
@@ -28,7 +28,7 @@ import {
type AgentsState,
} from "../../lib/agents/index.ts";
import { DEFAULT_AGENT_PANEL, type AgentsPanel } from "../../lib/agents/panels.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
import {
createInitialCronState,
loadCronJobsPage,
+1 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import * as avatarImage from "./avatar-image.ts";
import { resetIdentityDraft, saveIdentityDraft, selectIdentityAvatar } from "./identity-actions.ts";
+1 -1
View File
@@ -4,7 +4,7 @@ import { createDeferred } from "../../../../../test/helpers/promise.js";
import { i18n } from "../../../i18n/index.ts";
import type { TranslationMap } from "../../../i18n/lib/types.ts";
import { en } from "../../../i18n/locales/en.ts";
import type { RuntimeConfigCapability } from "../../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../../lib/config/runtime-config-capability.ts";
import {
backfillDreamDiary,
copyDreamingArchivePath,
+1 -1
View File
@@ -10,7 +10,7 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.
import type { ConfigSnapshot } from "../../../api/types.ts";
import { t } from "../../../i18n/index.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import type { RuntimeConfigCapability } from "../../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../../lib/config/runtime-config-capability.ts";
import {
canCallGatewayMethod,
isGatewayMethodAdvertised,
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from "../../../components/confirm-dialog.ts";
import { renderSettingsDefaultState } from "../../../components/settings-ui.ts";
import { t } from "../../../i18n/index.ts";
import { currentConfigObject } from "../../../lib/config/index.ts";
import { currentConfigObject } from "../../../lib/config/config-state-model.ts";
import { formatTimeMs } from "../../../lib/format.ts";
import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
+1 -1
View File
@@ -3,7 +3,7 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import { stageAgentModelFallbacks, stageAgentPrimaryModel } from "./model-config.ts";
function createRuntimeConfig(sourceConfig: Record<string, unknown>) {
+4 -1
View File
@@ -6,7 +6,10 @@ import {
resolveEffectiveModelFallbacks,
resolveModelPrimary,
} from "../../lib/agents/display.ts";
import { currentConfigObject, type AgentConfigEntryTarget } from "../../lib/config/index.ts";
import {
currentConfigObject,
type AgentConfigEntryTarget,
} from "../../lib/config/config-state-model.ts";
import { normalizeStringEntries } from "../../lib/string-coerce.ts";
type RuntimeConfig = ApplicationContext["runtimeConfig"];
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeConfigCapability } from "../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import { clearAgentSkillFilter } from "./skills.ts";
describe("clearAgentSkillFilter", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
// Control UI controller manages agent skills gateway state.
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SkillStatusReport } from "../../api/types.ts";
import type { RuntimeConfigCapability } from "../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import { loadSkillStatusReport } from "../../lib/skills/index.ts";
type AgentSkillsState = {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { NostrProfile } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { createChannelCapability } from "../../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import "./channels-page.ts";
const NOSTR_PROFILE_REQUEST_TIMEOUT_MS = 30_000;
+1 -1
View File
@@ -1,5 +1,5 @@
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import type { RuntimeConfigCapability } from "../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
type ConfigRemover = Pick<RuntimeConfigCapability, "removeFormValue">;
+1 -1
View File
@@ -14,7 +14,7 @@ import type { AgentSelectOption } from "../../components/agent-select.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { AgentsListResult } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { listSelectableAgents } from "../../lib/agents/display.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
import {
getCronJobPayload,
resolveConfiguredCronModelSuggestions,
+1 -1
View File
@@ -15,7 +15,7 @@ import { showSecretRevealDialog } from "../../components/secret-reveal-dialog.ts
import { renderDocsLink } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { currentConfigObject } from "../../lib/config/config-state-model.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
import {
approveDevicePairing,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/config-state-model.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-link.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
@@ -1,5 +1,5 @@
import { t } from "../../i18n/index.ts";
import type { RuntimeConfigCapability } from "../../lib/config/index.ts";
import type { RuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import type { ModelProviderRowMessage } from "./view.ts";
export type ModelProviderConfigMutation = {
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
ModelCatalogEntry,
ModelCatalogProviderOutcome,
} from "../../api/types.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/config-state-model.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
@@ -5,7 +5,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGateway } from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import {
createApplicationContextProvider,
type ApplicationContextProvider,
@@ -5,7 +5,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGateway } from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
import type { ModelSetupRouteData } from "./model-setup-page.ts";
import "./model-setup-page.ts";
+1 -1
View File
@@ -4,7 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import type {
PluginInstallRequest,
PluginListResult,
+1 -1
View File
@@ -18,7 +18,7 @@ import type { McpServerForm } from "../../components/mcp-server-form.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/config-state-model.ts";
import {
buildAddMcpServerPatch,
buildRemoveMcpServerPatch,
+2 -4
View File
@@ -3,10 +3,8 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import {
resolveEditableSnapshotConfig,
type RuntimeConfigCapability,
} from "../../lib/config/index.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/config-state-model.ts";
import type { RuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
export type SkillWorkshopSelfLearning = {
enabled: boolean;