diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index a6b1d12256dc..b53bf36bdd22 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index e6488cdc02d9..82426d02697f 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -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"; diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index 60a9d1b3162b..e276b45cc210 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -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"; diff --git a/ui/src/app/server-prefs.test-support.ts b/ui/src/app/server-prefs.test-support.ts index 254fb2a5d9e5..52a9210f4883 100644 --- a/ui/src/app/server-prefs.test-support.ts +++ b/ui/src/app/server-prefs.test-support.ts @@ -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< diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index e75564d24703..c5e88f64f0f3 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -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, diff --git a/ui/src/components/mcp-servers-card.ts b/ui/src/components/mcp-servers-card.ts index f0c3cf4da6f8..c22059b6134b 100644 --- a/ui/src/components/mcp-servers-card.ts +++ b/ui/src/components/mcp-servers-card.ts @@ -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, diff --git a/ui/src/components/settings-save-indicator.ts b/ui/src/components/settings-save-indicator.ts index 8784211b6836..378b94bf3e26 100644 --- a/ui/src/components/settings-save-indicator.ts +++ b/ui/src/components/settings-save-indicator.ts @@ -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; diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index 1cc09e65353e..852d377af2ae 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -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 = { diff --git a/ui/src/lib/config/config-draft-model.test.ts b/ui/src/lib/config/config-draft-model.test.ts new file mode 100644 index 000000000000..53d82ad412c4 --- /dev/null +++ b/ui/src/lib/config/config-draft-model.test.ts @@ -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(); + 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 | 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(); + }); +}); diff --git a/ui/src/lib/config/config-draft-model.ts b/ui/src/lib/config/config-draft-model.ts new file mode 100644 index 000000000000..4f3d516ea7cc --- /dev/null +++ b/ui/src/lib/config/config-draft-model.ts @@ -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>(); + +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 = {}; + for (const [key, val] of Object.entries(value as Record)) { + 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 `` 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) + : 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) { + 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 | null { + try { + const parsed = parseJson5Text(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : 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) => void, +) { + let base: Record; + 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, + path: Array, + 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) + : 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, + 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) { + 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; +} diff --git a/ui/src/lib/config/config-gateway-operations.test.ts b/ui/src/lib/config/config-gateway-operations.test.ts new file mode 100644 index 000000000000..296de55eab75 --- /dev/null +++ b/ui/src/lib/config/config-gateway-operations.test.ts @@ -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> | null = null; + const request = vi.fn((method: string, params?: unknown) => { + if (method === "config.get") { + const response = { + config: JSON.parse(storedRaw) as Record, + 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(); + 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> | 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, + 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(); + 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 = { 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) }; + 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(); + 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(); + 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 = { 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(); + }); +}); diff --git a/ui/src/lib/config/config-gateway-operations.ts b/ui/src/lib/config/config-gateway-operations.ts new file mode 100644 index 000000000000..fec1a7d2ce3a --- /dev/null +++ b/ui/src/lib/config/config-gateway-operations.ts @@ -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; + 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>, +) => ConfigPatchBuildResult; +type ConfigPatchAck = { + config?: unknown; + hash?: unknown; + noop?: boolean; +}; + +export type RuntimeConfigExternalMutationResult = + | { + 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; + patchForm: (path: Array, value: unknown) => void; + removeFormValue: (path: Array) => void; + setRaw: (value: string) => void; + resetDraft: () => void; + discardDraft: () => Promise; + setWritesSuspended: (suspended: boolean) => void; + waitForPendingWrites: () => Promise; + save: (options?: RuntimeConfigDispatchOptions) => Promise; + apply: () => Promise; + stageDefaultAgent: (agentId: string) => boolean; + patch: (options: ConfigPatchOptions) => Promise; + patchFromSnapshot: (build: ConfigPatchBuilder) => Promise; + runExternalMutation: ( + task: (client: GatewayBrowserClient) => Promise, + options?: RuntimeConfigExternalMutationOptions, + ) => Promise>; + dispose: () => void; +}; + +export async function executeConfigExternalMutation( + state: RuntimeConfigState, + client: GatewayBrowserClient, + connectionEpoch: number, + task: (client: GatewayBrowserClient) => Promise, + options: RuntimeConfigExternalMutationOptions, + refresh: () => Promise, +): Promise> { + 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 => ({ + 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 { + 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("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("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 = {}, + onSubmitted?: (info: { raw: string; ackHash: string | null }) => void, + canDispatch: () => boolean = () => true, +): Promise { + 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 { + 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 { + return submitConfigChange(state, "config.set", "configSaving", {}, onSubmitted, canDispatch); +} + +export async function applyConfig( + state: RuntimeConfigState, + canDispatch?: () => boolean, +): Promise { + 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, +): Promise { + 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("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 { + 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 { + 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; + } + } +} diff --git a/ui/src/lib/config/config-state-model.test.ts b/ui/src/lib/config/config-state-model.test.ts new file mode 100644 index 000000000000..b4a32aa6420b --- /dev/null +++ b/ui/src/lib/config/config-state-model.test.ts @@ -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(); + const secondConfig = deferred(); + const firstSchema = deferred(); + const secondSchema = deferred(); + 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; + const config = { agents: { entries } }; + + expect(resolveAgentConfigEntryTarget(config, "missing")).toBeNull(); + expect(resolveAgentConfigEntryTarget(config, " ")).toBeNull(); + expect(resolveAgentConfigEntryTarget(config, "__proto__")).toBeNull(); + expect(resolveAgentConfigEntryTarget(config, "foo-bar")).toBeNull(); + }); +}); diff --git a/ui/src/lib/config/config-state-model.ts b/ui/src/lib/config/config-state-model.ts new file mode 100644 index 000000000000..c89fe7079276 --- /dev/null +++ b/ui/src/lib/config/config-state-model.ts @@ -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 | null; + configRawOriginalParsePending: Promise | 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 | null; + configFormOriginal: Record | 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(); + +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(method: string, params?: unknown): Promise; +}; + +type ConfigConnectionState = { + client: ConfigGatewayClient | null; + connected: boolean; +}; + +export function createInitialConfigState( + snapshot?: Partial, +): 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 | null { + return ( + asConfigRecord(snapshot?.sourceConfig) ?? + asConfigRecord(snapshot?.resolved) ?? + asConfigRecord(snapshot?.config) + ); +} + +export function currentConfigObject( + state: Pick, +): Record | null { + return state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot); +} +export type AgentConfigEntryTarget = { + path: ["agents", "entries", string]; + entry: Record; +}; + +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 | 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: {} }; +} diff --git a/ui/src/lib/config/config-test-harness.ts b/ui/src/lib/config/config-test-harness.ts new file mode 100644 index 000000000000..d655cd51e2f1 --- /dev/null +++ b/ui/src/lib/config/config-test-harness.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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, + 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(); + 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, + 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 }; +} diff --git a/ui/src/lib/config/config-write-coordinator.test.ts b/ui/src/lib/config/config-write-coordinator.test.ts new file mode 100644 index 000000000000..aaa6c321824c --- /dev/null +++ b/ui/src/lib/config/config-write-coordinator.test.ts @@ -0,0 +1,1012 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ConfigSnapshot } from "../../api/types.ts"; +import { + CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS, + deferred, + createConfigServerMock, + createDeferredSetServerMock, + createConfigCapabilityHarness, +} from "./config-test-harness.ts"; + +describe("config write coordinator", () => { + it("debounces form edits into one config.set and marks needsApply", async () => { + vi.useFakeTimers(); + const server = createConfigServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + server.request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 2); + runtimeConfig.patchForm(["count"], 3); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS - 1); + expect(server.submissions).toHaveLength(0); + expect(runtimeConfig.state.configFormDirty).toBe(true); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle"); + + await vi.advanceTimersByTimeAsync(1); + expect(server.submissions).toEqual([ + { method: "config.set", raw: '{\n "count": 3\n}\n', baseHash: "hash-1" }, + ]); + expect(runtimeConfig.state.configFormDirty).toBe(false); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); + expect(runtimeConfig.state.configNeedsApply).toBe(true); + // The post-save reload rebased the clean draft onto the new hash. + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + runtimeConfig.dispose(); + }); + + it("keeps mid-flight edits dirty and queues exactly one trailing save", 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); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("saving"); + + // Edits during the in-flight save stay dirty and fold into one trailing save. + runtimeConfig.patchForm(["count"], 3); + runtimeConfig.patchForm(["count"], 4); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + expect(submissions).toHaveLength(1); + expect(runtimeConfig.state.configFormDirty).toBe(true); + + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + expect(submissions).toHaveLength(2); + expect(submissions[1]).toEqual({ raw: '{\n "count": 4\n}\n', baseHash: "hash-2" }); + expect(runtimeConfig.state.configFormDirty).toBe(false); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); + runtimeConfig.dispose(); + }); + + it("surfaces auto-save failures without retry-looping", 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(setCalls).toBe(1); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("error"); + expect(runtimeConfig.state.configFormDirty).toBe(true); + expect(runtimeConfig.state.configNeedsApply).toBe(false); + expect(runtimeConfig.state.lastError).toContain("disk full"); + + // No retry loop; only the next edit reschedules a save. + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 10); + expect(setCalls).toBe(1); + runtimeConfig.patchForm(["count"], 3); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + expect(setCalls).toBe(2); + runtimeConfig.dispose(); + }); + + it("flushes the pending debounce before apply and leaves no dangling save", async () => { + vi.useFakeTimers(); + const server = createConfigServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + server.request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 7); + // Apply serializes the current form itself; the scheduled autosave is + // cancelled and never fires afterwards. + await expect(runtimeConfig.apply()).resolves.toBe(true); + expect(server.submissions).toEqual([ + { method: "config.apply", raw: '{\n "count": 7\n}\n', baseHash: "hash-1" }, + ]); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); + expect(server.submissions).toHaveLength(1); + runtimeConfig.dispose(); + }); + + it("runs one trailing save when a field is reverted during the flight", 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); + + // Revert to the pre-save value while the save is in flight: against the + // old original this looks clean, but the submitted bytes are now the + // authoritative original, so the revert must still be written back. + runtimeConfig.patchForm(["count"], 1); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + + 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("drains the whole autosave chain before an explicit apply", async () => { + vi.useFakeTimers(); + const { request, submissions, applySubmissions, 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); + + // Edit while the save is in flight, then apply: the trailing save must + // land first and the apply must chain onto ITS ack hash (no CAS failure). + runtimeConfig.patchForm(["count"], 3); + const applyPromise = runtimeConfig.apply(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await expect(applyPromise).resolves.toBe(true); + + expect(submissions).toEqual([ + { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, + { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, + ]); + expect(applySubmissions).toEqual([{ raw: '{\n "count": 3\n}\n', baseHash: "hash-3" }]); + runtimeConfig.dispose(); + }); + + it("drains an in-flight manual save before an explicit apply", async () => { + vi.useFakeTimers(); + const { request, submissions, applySubmissions, firstSet } = createDeferredSetServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + const rawDraft = '{\n "count": 5\n}\n'; + runtimeConfig.setRaw(rawDraft); + const savePromise = runtimeConfig.save(); + await vi.advanceTimersByTimeAsync(0); + expect(submissions).toEqual([{ raw: rawDraft, baseHash: "hash-1" }]); + + // Apply while the manual save is still in flight: it must wait for the + // save's ack and chain onto its hash instead of racing the same base. + const applyPromise = runtimeConfig.apply(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await expect(savePromise).resolves.toBe(true); + await expect(applyPromise).resolves.toBe(true); + + expect(applySubmissions).toEqual([{ raw: rawDraft, baseHash: "hash-2" }]); + runtimeConfig.dispose(); + }); + + it("skips the teardown flush when the settled flight acked without a hash", 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); + + // Without the flight's own ack hash there is no trusted CAS base for the + // final flush; failing closed beats clobbering a foreign write. + runtimeConfig.patchForm(["count"], 3); + runtimeConfig.dispose(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + expect(submissions).toHaveLength(1); + }); + + it("drains in-flight saves before a discard without trailing the discarded bytes", 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); + + // A mid-flight edit would normally spawn a trailing save; a discard must + // wait for the flight and then throw the draft away instead. + runtimeConfig.patchForm(["count"], 3); + const discardPromise = runtimeConfig.discardDraft(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await discardPromise; + + expect(submissions).toHaveLength(1); + expect(runtimeConfig.state.configFormDirty).toBe(false); + // The draft ends clean against the acked/reloaded state, not the old bytes. + expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); + runtimeConfig.dispose(); + }); + + it("suspends config writes while the app updater runs and resumes after", async () => { + vi.useFakeTimers(); + const server = createConfigServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + server.request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.setWritesSuspended(true); + runtimeConfig.patchForm(["count"], 2); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 3); + expect(server.submissions).toHaveLength(0); + expect(runtimeConfig.state.configFormDirty).toBe(true); + + // Manual writes refuse too: config writes mid-update can corrupt the install. + await expect(runtimeConfig.save()).resolves.toBe(false); + await expect(runtimeConfig.apply()).resolves.toBe(false); + expect(server.submissions).toHaveLength(0); + + // Edits made during the update save once it ends. + runtimeConfig.setWritesSuspended(false); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + expect(server.submissions).toEqual([ + { method: "config.set", raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, + ]); + runtimeConfig.dispose(); + }); + + it("treats config.patch as a suspendable, drainable write", async () => { + vi.useFakeTimers(); + const patchGate = deferred(); + const patches: unknown[] = []; + const request = vi.fn((method: string, params?: unknown) => { + 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.patch") { + patches.push(params); + return patchGate.promise; + } + return Promise.resolve({}); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + // Suspended (app updater running): patches refuse like save/apply — a + // patch is a config write too and could overlap the install. + runtimeConfig.setWritesSuspended(true); + await expect( + runtimeConfig.patch({ raw: { count: 5 }, note: "test suspended patch" }), + ).resolves.toBe(false); + expect(patches).toHaveLength(0); + runtimeConfig.setWritesSuspended(false); + + // Once in flight, the updater barrier must wait for it. + const patchPromise = runtimeConfig.patch({ raw: { count: 5 }, note: "test in-flight patch" }); + await vi.advanceTimersByTimeAsync(0); + expect(patches).toHaveLength(1); + let drained = false; + const drainPromise = runtimeConfig.waitForPendingWrites().then(() => { + drained = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(drained).toBe(false); + patchGate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await drainPromise; + await expect(patchPromise).resolves.toBe(true); + runtimeConfig.dispose(); + }); + + it("flushes a scheduled autosave when a write barrier runs before the debounce", async () => { + vi.useFakeTimers(); + const setGate = deferred(); + const methods: string[] = []; + const request = vi.fn((method: string, params?: unknown) => { + methods.push(method); + 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") { + expect(params).toMatchObject({ baseHash: "hash-1" }); + return setGate.promise; + } + return Promise.resolve({}); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 2); + let drained = false; + const drain = runtimeConfig.waitForPendingWrites().then(() => { + drained = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(methods.filter((method) => method === "config.set")).toHaveLength(1); + expect(drained).toBe(false); + + setGate.resolve({ hash: "hash-2" }); + await drain; + expect(drained).toBe(true); + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + runtimeConfig.dispose(); + }); + + it("serializes external mutations after scheduled drafts and refreshes before resolving", async () => { + vi.useFakeTimers(); + const order: string[] = []; + let storedConfig: Record = { count: 1 }; + let hash = "hash-1"; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + order.push("config.get"); + return { + config: storedConfig, + raw: JSON.stringify(storedConfig), + hash, + valid: true, + issues: [], + }; + } + if (method === "config.set") { + order.push("config.set"); + storedConfig = JSON.parse((params as { raw: string }).raw) as Record; + hash = "hash-2"; + return { hash }; + } + if (method === "plugins.setEnabled") { + order.push("plugins.setEnabled"); + storedConfig = { ...storedConfig, pluginEnabled: true }; + hash = "hash-3"; + return { ok: true }; + } + return {}; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + order.length = 0; + + runtimeConfig.patchForm(["count"], 2); + const result = await runtimeConfig.runExternalMutation((client) => + client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), + ); + + expect(result).toEqual({ + ok: true, + value: { ok: true }, + refresh: { ok: true }, + }); + expect(order).toEqual(["config.set", "plugins.setEnabled", "config.get"]); + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-3"); + expect(runtimeConfig.state.configForm).toEqual({ count: 2, pluginEnabled: true }); + runtimeConfig.dispose(); + }); + + it("rechecks external mutation access after pending config writes settle", async () => { + vi.useFakeTimers(); + const firstSet = deferred(); + 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.set") { + return firstSet.promise; + } + return Promise.resolve({ ok: true }); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + methods.length = 0; + + runtimeConfig.patchForm(["count"], 2); + const result = runtimeConfig.runExternalMutation( + (client) => client.request("agents.update", { agentId: "main", name: "Agent Smith" }), + { + canDispatch: () => canDispatch, + dispatchError: "Access changed before the agent identity update started.", + }, + ); + await vi.waitFor(() => expect(methods).toEqual(["config.set"])); + canDispatch = false; + firstSet.resolve({ hash: "hash-2" }); + + await expect(result).resolves.toEqual({ + ok: false, + reason: "unavailable", + error: "Access changed before the agent identity update started.", + }); + expect(methods).toEqual(["config.set"]); + runtimeConfig.dispose(); + }); + + it("forces a post-mutation refresh instead of joining a pre-existing config load", async () => { + const staleLoad = deferred(); + let getCalls = 0; + let storedConfig: Record = { count: 1 }; + let hash = "hash-1"; + const request = vi.fn(async (method: string) => { + if (method === "config.get") { + getCalls += 1; + if (getCalls === 2) { + return staleLoad.promise; + } + return { + config: storedConfig, + raw: JSON.stringify(storedConfig), + hash, + valid: true, + issues: [], + }; + } + if (method === "plugins.setEnabled") { + storedConfig = { count: 1, pluginEnabled: true }; + hash = "hash-2"; + return { ok: true }; + } + return {}; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + const overlappingRefresh = runtimeConfig.refresh(); + await vi.waitFor(() => expect(getCalls).toBe(2)); + const result = await runtimeConfig.runExternalMutation((client) => + client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), + ); + + expect(result.ok).toBe(true); + expect(getCalls).toBe(3); + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + expect(runtimeConfig.state.configForm).toEqual({ count: 1, pluginEnabled: true }); + + staleLoad.resolve({ + config: { count: 999 }, + raw: '{"count":999}', + hash: "stale-hash", + valid: true, + issues: [], + }); + await overlappingRefresh; + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + runtimeConfig.dispose(); + }); + + it("preserves a committed external mutation when its authoritative refresh fails", async () => { + let getCalls = 0; + const request = vi.fn(async (method: string) => { + if (method === "config.get") { + getCalls += 1; + if (getCalls === 1) { + return { + config: { count: 1 }, + raw: '{"count":1}', + hash: "hash-1", + valid: true, + issues: [], + }; + } + throw new Error("refresh unavailable"); + } + if (method === "plugins.setEnabled") { + return { ok: true }; + } + return {}; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + const result = await runtimeConfig.runExternalMutation((client) => + client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), + ); + + expect(result).toEqual({ + ok: true, + value: { ok: true }, + refresh: { ok: false, error: "Error: refresh unavailable" }, + }); + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-1"); + runtimeConfig.dispose(); + }); + + it("distinguishes definitive external mutation rejections from transient errors", async () => { + const request = vi.fn(async (method: string) => { + if (method === "config.get") { + return { + config: { count: 1 }, + raw: '{"count":1}', + hash: "hash-1", + valid: true, + issues: [], + }; + } + return {}; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + await expect( + runtimeConfig.runExternalMutation(async () => { + throw new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "invalid config", + }); + }), + ).resolves.toEqual({ + ok: false, + reason: "rejected", + error: "invalid config", + }); + await expect( + runtimeConfig.runExternalMutation(async () => { + throw new Error("socket closed"); + }), + ).resolves.toEqual({ + ok: false, + reason: "error", + error: "socket closed", + }); + runtimeConfig.dispose(); + }); + + it("queues background external mutations until write suspension ends", async () => { + const methods: string[] = []; + const request = vi.fn(async (method: string) => { + methods.push(method); + if (method === "config.get") { + return { + config: { count: 1 }, + raw: '{"count":1}', + hash: "hash-1", + valid: true, + issues: [], + }; + } + return { ok: true }; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + methods.length = 0; + runtimeConfig.setWritesSuspended(true); + + const resultPromise = runtimeConfig.runExternalMutation( + (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), + { waitForWritesResumed: true }, + ); + await Promise.resolve(); + expect(methods).toEqual([]); + + runtimeConfig.setWritesSuspended(false); + await expect(resultPromise).resolves.toEqual({ + ok: true, + value: { ok: true }, + refresh: { ok: true }, + }); + expect(methods).toEqual(["config.patch", "config.get"]); + runtimeConfig.dispose(); + }); + + it("preserves queued mutation waiters when suspension is repeated", async () => { + const methods: string[] = []; + const request = vi.fn(async (method: string) => { + methods.push(method); + if (method === "config.get") { + return { + config: { count: 1 }, + raw: '{"count":1}', + hash: "hash-1", + valid: true, + issues: [], + }; + } + return { ok: true }; + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + methods.length = 0; + runtimeConfig.setWritesSuspended(true); + + const resultPromise = runtimeConfig.runExternalMutation( + (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), + { waitForWritesResumed: true }, + ); + await Promise.resolve(); + runtimeConfig.setWritesSuspended(true); + runtimeConfig.setWritesSuspended(false); + + await expect(resultPromise).resolves.toEqual({ + ok: true, + value: { ok: true }, + refresh: { ok: true }, + }); + expect(methods).toEqual(["config.patch", "config.get"]); + runtimeConfig.dispose(); + }); + + it("retries a background mutation when suspension begins during its write drain", async () => { + vi.useFakeTimers(); + const firstSet = deferred(); + const methods: string[] = []; + const request = vi.fn((method: string) => { + methods.push(method); + if (method === "config.get") { + return Promise.resolve({ + config: { count: 1 }, + raw: '{"count":1}', + hash: methods.filter((entry) => entry === "config.set").length ? "hash-2" : "hash-1", + valid: true, + issues: [], + }); + } + if (method === "config.set") { + return firstSet.promise; + } + return Promise.resolve({ ok: true }); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + methods.length = 0; + + runtimeConfig.patchForm(["count"], 2); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + const resultPromise = runtimeConfig.runExternalMutation( + (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), + { waitForWritesResumed: true }, + ); + runtimeConfig.setWritesSuspended(true); + firstSet.resolve({ hash: "hash-2" }); + await vi.advanceTimersByTimeAsync(0); + expect(methods).toEqual(["config.set"]); + + runtimeConfig.setWritesSuspended(false); + await expect(resultPromise).resolves.toEqual({ + ok: true, + value: { ok: true }, + refresh: { ok: true }, + }); + expect(methods).toEqual(["config.set", "config.patch", "config.get"]); + runtimeConfig.dispose(); + }); + + it("flushes a pre-ack revert during disposal", 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); + + // Revert to the original value while the save is in flight: dirty reads + // false (originals not yet rebased onto the submission), but the bytes + // differ from the submitted ones — dropping this flush would persist the + // unreverted value. + runtimeConfig.patchForm(["count"], 1); + expect(runtimeConfig.state.configFormDirty).toBe(false); + runtimeConfig.dispose(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + + expect(submissions).toHaveLength(2); + expect(submissions[1]).toEqual({ raw: '{\n "count": 1\n}\n', baseHash: "hash-2" }); + }); + + it("does not write when an edit is reverted within the debounce window", async () => { + vi.useFakeTimers(); + const server = createConfigServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + server.request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + // Dirtiness is canonical-vs-canonical on the form objects, so a revert to + // the original value reads clean and never rewrites the file (which would + // destroy JSON5 comments/formatting for a semantic no-op). + runtimeConfig.patchForm(["count"], 2); + runtimeConfig.patchForm(["count"], 1); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); + + expect(server.submissions).toHaveLength(0); + expect(runtimeConfig.state.configFormDirty).toBe(false); + runtimeConfig.dispose(); + }); + + it("serializes explicit ops queued behind the same in-flight write", async () => { + vi.useFakeTimers(); + const { request, submissions, applySubmissions, 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); + + // Both ops queue behind the hung autosave; they must dispatch one after + // another, each against the base its predecessor produced — not both + // against the drained flight's base. + const savePromise = runtimeConfig.save(); + const applyPromise = runtimeConfig.apply(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await expect(savePromise).resolves.toBe(true); + await expect(applyPromise).resolves.toBe(true); + + expect(submissions).toHaveLength(2); + expect(submissions[1]?.baseHash).toBe("hash-2"); + expect(applySubmissions).toHaveLength(1); + expect(applySubmissions[0]?.baseHash).toBe("hash-3"); + runtimeConfig.dispose(); + }); + + it("cancels a debounce armed while an explicit save drains another write", 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); + + const save = runtimeConfig.save(); + runtimeConfig.patchForm(["count"], 3); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await expect(save).resolves.toBe(true); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); + + expect(submissions).toEqual([ + { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, + { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, + ]); + runtimeConfig.dispose(); + }); + + it("defers autosaves behind a manual write and keeps the newer edit", async () => { + vi.useFakeTimers(); + const { request, submissions, firstSet } = createDeferredSetServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 2); + const savePromise = runtimeConfig.save(); + await vi.advanceTimersByTimeAsync(0); + expect(submissions).toHaveLength(1); + + // Edit while the manual save is pending: no concurrent config.set may + // start (it would race the same base hash), and the manual completion + // must not snap the draft back to its older submitted bytes. + runtimeConfig.patchForm(["count"], 3); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); + expect(submissions).toHaveLength(1); + + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await savePromise; + await vi.advanceTimersByTimeAsync(0); + + expect(submissions).toHaveLength(2); + expect(submissions[1]).toEqual({ raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }); + expect(runtimeConfig.state.configForm).toEqual({ count: 3 }); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); + runtimeConfig.dispose(); + }); + + it("drains in-flight saves before a discarding refresh", 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); + + // Same barrier as discardDraft: the settling flight must not trail the + // just-discarded edit back to disk after the refresh. + runtimeConfig.patchForm(["count"], 3); + const refreshPromise = runtimeConfig.refresh({ discardPendingChanges: true }); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await refreshPromise; + + expect(submissions).toHaveLength(1); + expect(runtimeConfig.state.configFormDirty).toBe(false); + expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); + runtimeConfig.dispose(); + }); + + it("flushes a scheduled form autosave before config.patch and re-arms after", async () => { + vi.useFakeTimers(); + const order: string[] = []; + let hashCounter = 1; + const request = vi.fn((method: string) => { + if (method === "config.get") { + return Promise.resolve({ + config: { count: 1 }, + raw: '{\n "count": 1\n}\n', + hash: `hash-${hashCounter}`, + valid: true, + issues: [], + }); + } + if (method === "config.set" || method === "config.patch") { + order.push(method); + hashCounter += 1; + return Promise.resolve({ hash: `hash-${hashCounter}` }); + } + return Promise.resolve({}); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + // Patch during the debounce window: the draft must be flushed as a real + // save before the patch, not silently dropped with its timer. + runtimeConfig.patchForm(["count"], 2); + let patchBaseCount: unknown; + await expect( + runtimeConfig.patchFromSnapshot((config) => { + patchBaseCount = config.count; + return { + options: { raw: { other: true }, note: "test patch after autosave" }, + }; + }), + ).resolves.toBe(true); + + expect(order).toEqual(["config.set", "config.patch"]); + expect(patchBaseCount).toBe(2); + expect(runtimeConfig.state.configFormDirty).toBe(false); + runtimeConfig.dispose(); + }); + + it("chains the teardown flush behind a pending manual save", async () => { + vi.useFakeTimers(); + const { request, submissions, firstSet } = createDeferredSetServerMock(); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 2); + const savePromise = runtimeConfig.save(); + await vi.advanceTimersByTimeAsync(0); + expect(submissions).toHaveLength(1); + + runtimeConfig.patchForm(["count"], 3); + runtimeConfig.dispose(); + firstSet.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await savePromise; + + // Exactly one chained flush, based on the manual save's own ack hash — + // never a parallel write against the same base. + expect(submissions).toEqual([ + { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, + { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, + ]); + }); + + it("skips the teardown flush behind a pending apply", async () => { + vi.useFakeTimers(); + const firstApply = deferred(); + let setCalls = 0; + 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") { + setCalls += 1; + return Promise.resolve({ hash: "hash-9" }); + } + if (method === "config.apply") { + return firstApply.promise.then(() => ({ hash: "hash-2" })); + } + return Promise.resolve({}); + }); + const { runtimeConfig } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + + runtimeConfig.patchForm(["count"], 2); + const applyPromise = runtimeConfig.apply(); + await vi.advanceTimersByTimeAsync(0); + + // The gateway is about to restart; a post-apply write is meaningless. + runtimeConfig.patchForm(["count"], 3); + runtimeConfig.dispose(); + firstApply.resolve({}); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + await applyPromise; + expect(setCalls).toBe(0); + }); +}); diff --git a/ui/src/lib/config/config-write-coordinator.ts b/ui/src/lib/config/config-write-coordinator.ts new file mode 100644 index 000000000000..055246353adb --- /dev/null +++ b/ui/src/lib/config/config-write-coordinator.ts @@ -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: (task: () => Promise) => Promise; + mutate: (task: () => void) => void; + trackLoad: (key: "config" | "schema", promise: Promise) => Promise; + 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 | null = null; + let autoSaveInFlight: Promise | 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 | 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 = 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 = 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 => { + 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 => { + 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 | null = null; + const afterPendingWritesSettled = ( + task: () => Promise, + unavailable: T, + options: { flushScheduledDraft?: boolean; canDispatch?: () => boolean } = {}, + ): Promise => { + 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 = 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 => { + 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 ( + task: (client: GatewayBrowserClient) => Promise, + options: RuntimeConfigExternalMutationOptions = {}, + ): Promise> => { + const mutationClient = state.client; + const mutationConnectionEpoch = currentConfigConnectionEpoch(state); + while (true) { + if (options.waitForWritesResumed && writesSuspended && !isDisposed()) { + await writesResumedPromise; + } + const unavailable: RuntimeConfigExternalMutationResult = { + 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>( + () => + 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(); + }, + }; +} diff --git a/ui/src/lib/config/index.test.ts b/ui/src/lib/config/index.test.ts deleted file mode 100644 index 909b783f3f72..000000000000 --- a/ui/src/lib/config/index.test.ts +++ /dev/null @@ -1,3629 +0,0 @@ -// @vitest-environment node -// Control UI tests cover config behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - GatewayRequestError, - type GatewayBrowserClient, - type GatewayHelloOk, -} from "../../api/gateway.ts"; -import type { ConfigSchemaResponse, ConfigSnapshot } from "../../api/types.ts"; -import type { ApplicationGatewayPhase } from "../../app/gateway.ts"; -import { createRuntimeConfigCapability, resolveAgentConfigEntryTarget } from "./index.ts"; - -const CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS = 800; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, reject, resolve }; -} - -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. */ -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, - 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. - */ -function createDeferredSetServerMock(options: { legacyAck?: boolean } = {}) { - const firstSet = deferred(); - 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, - 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 }; -} - -describe("createRuntimeConfigCapability", () => { - 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("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("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("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("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("ignores a save completion from an earlier connection epoch", async () => { - const save = deferred(); - 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("rejects stale config and schema work after reconnecting the same client", async () => { - const firstConfig = deferred(); - const secondConfig = deferred(); - const firstSchema = deferred(); - const secondSchema = deferred(); - 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(); - }); -}); - -describe("config form auto-save", () => { - function createHarness(request: GatewayBrowserClient["request"]) { - const client = { request } as unknown as GatewayBrowserClient; - const { gateway, publish } = createGatewayHarness(client); - return { runtimeConfig: createRuntimeConfigCapability(gateway), publish }; - } - - it("debounces form edits into one config.set and marks needsApply", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - runtimeConfig.patchForm(["count"], 3); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS - 1); - expect(server.submissions).toHaveLength(0); - expect(runtimeConfig.state.configFormDirty).toBe(true); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle"); - - await vi.advanceTimersByTimeAsync(1); - expect(server.submissions).toEqual([ - { method: "config.set", raw: '{\n "count": 3\n}\n', baseHash: "hash-1" }, - ]); - expect(runtimeConfig.state.configFormDirty).toBe(false); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); - expect(runtimeConfig.state.configNeedsApply).toBe(true); - // The post-save reload rebased the clean draft onto the new hash. - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); - 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(); - - 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("keeps mid-flight edits dirty and queues exactly one trailing save", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("saving"); - - // Edits during the in-flight save stay dirty and fold into one trailing save. - runtimeConfig.patchForm(["count"], 3); - runtimeConfig.patchForm(["count"], 4); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - expect(runtimeConfig.state.configFormDirty).toBe(true); - - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - expect(submissions).toHaveLength(2); - expect(submissions[1]).toEqual({ raw: '{\n "count": 4\n}\n', baseHash: "hash-2" }); - expect(runtimeConfig.state.configFormDirty).toBe(false); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); - runtimeConfig.dispose(); - }); - - it("surfaces auto-save failures without retry-looping", 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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(setCalls).toBe(1); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("error"); - expect(runtimeConfig.state.configFormDirty).toBe(true); - expect(runtimeConfig.state.configNeedsApply).toBe(false); - expect(runtimeConfig.state.lastError).toContain("disk full"); - - // No retry loop; only the next edit reschedules a save. - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 10); - expect(setCalls).toBe(1); - runtimeConfig.patchForm(["count"], 3); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(setCalls).toBe(2); - 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 } = createHarness(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 } = createHarness(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 } = createHarness(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 } = createHarness(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(); - 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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - let write: Promise | 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("clears needsApply only on apply; a discarding refresh keeps the banner", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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 = createHarness(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 = createHarness(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 = createHarness(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 } = createHarness(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 } = createHarness(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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - expect(runtimeConfig.state.configNeedsApply).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 } = createHarness(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 } = createHarness(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(); - 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 } = createHarness(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(); - const patchGate = deferred(); - 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 } = createHarness(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("flushes the pending debounce before apply and leaves no dangling save", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 7); - // Apply serializes the current form itself; the scheduled autosave is - // cancelled and never fires afterwards. - await expect(runtimeConfig.apply()).resolves.toBe(true); - expect(server.submissions).toEqual([ - { method: "config.apply", raw: '{\n "count": 7\n}\n', baseHash: "hash-1" }, - ]); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); - expect(server.submissions).toHaveLength(1); - 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 } = createHarness( - 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("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 } = createHarness(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("resets a stale Saved/error status as soon as a new edit lands", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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("flushes a dirty draft once on dispose instead of dropping it", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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 } = createHarness(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("applies a clean snapshot's raw bytes verbatim instead of reserializing", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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("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> | null = null; - const request = vi.fn((method: string, params?: unknown) => { - if (method === "config.get") { - const response = { - config: JSON.parse(storedRaw) as Record, - 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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - const reloadGate = deferred(); - 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("merges a form patch on top of a parseable dirty raw draft", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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 } = createHarness(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("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 = createHarness(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 = createHarness(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 } = createHarness(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("runs one trailing save when a field is reverted during the flight", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // Revert to the pre-save value while the save is in flight: against the - // old original this looks clean, but the submitted bytes are now the - // authoritative original, so the revert must still be written back. - runtimeConfig.patchForm(["count"], 1); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - - 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("chains one final save when disposed mid-flight with a newer edit", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(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 } = createHarness(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("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 } = createHarness(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> | 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, - 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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - const reloadGate = deferred(); - 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("drains the whole autosave chain before an explicit apply", async () => { - vi.useFakeTimers(); - const { request, submissions, applySubmissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(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 save is in flight, then apply: the trailing save must - // land first and the apply must chain onto ITS ack hash (no CAS failure). - runtimeConfig.patchForm(["count"], 3); - const applyPromise = runtimeConfig.apply(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await expect(applyPromise).resolves.toBe(true); - - expect(submissions).toEqual([ - { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, - { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, - ]); - expect(applySubmissions).toEqual([{ raw: '{\n "count": 3\n}\n', baseHash: "hash-3" }]); - 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 } = createHarness(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 } = createHarness(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("drains an in-flight manual save before an explicit apply", async () => { - vi.useFakeTimers(); - const { request, submissions, applySubmissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - const rawDraft = '{\n "count": 5\n}\n'; - runtimeConfig.setRaw(rawDraft); - const savePromise = runtimeConfig.save(); - await vi.advanceTimersByTimeAsync(0); - expect(submissions).toEqual([{ raw: rawDraft, baseHash: "hash-1" }]); - - // Apply while the manual save is still in flight: it must wait for the - // save's ack and chain onto its hash instead of racing the same base. - const applyPromise = runtimeConfig.apply(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await expect(savePromise).resolves.toBe(true); - await expect(applyPromise).resolves.toBe(true); - - expect(applySubmissions).toEqual([{ raw: rawDraft, baseHash: "hash-2" }]); - runtimeConfig.dispose(); - }); - - it("discards offline drafts locally instead of no-op refreshing", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig, publish } = createHarness( - 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("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 } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - await expect(runtimeConfig.apply()).resolves.toBe(false); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict"); - runtimeConfig.dispose(); - }); - - it("skips the teardown flush when the settled flight acked without a hash", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock({ legacyAck: true }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // Without the flight's own ack hash there is no trusted CAS base for the - // final flush; failing closed beats clobbering a foreign write. - runtimeConfig.patchForm(["count"], 3); - runtimeConfig.dispose(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - }); - - it("drains in-flight saves before a discard without trailing the discarded bytes", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // A mid-flight edit would normally spawn a trailing save; a discard must - // wait for the flight and then throw the draft away instead. - runtimeConfig.patchForm(["count"], 3); - const discardPromise = runtimeConfig.discardDraft(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await discardPromise; - - expect(submissions).toHaveLength(1); - expect(runtimeConfig.state.configFormDirty).toBe(false); - // The draft ends clean against the acked/reloaded state, not the old bytes. - expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); - runtimeConfig.dispose(); - }); - - it("refuses apply while a raw draft is dirty", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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("suspends config writes while the app updater runs and resumes after", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.setWritesSuspended(true); - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 3); - expect(server.submissions).toHaveLength(0); - expect(runtimeConfig.state.configFormDirty).toBe(true); - - // Manual writes refuse too: config writes mid-update can corrupt the install. - await expect(runtimeConfig.save()).resolves.toBe(false); - await expect(runtimeConfig.apply()).resolves.toBe(false); - expect(server.submissions).toHaveLength(0); - - // Edits made during the update save once it ends. - runtimeConfig.setWritesSuspended(false); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(server.submissions).toEqual([ - { method: "config.set", raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, - ]); - runtimeConfig.dispose(); - }); - - it("treats config.patch as a suspendable, drainable write", async () => { - vi.useFakeTimers(); - const patchGate = deferred(); - const patches: unknown[] = []; - const request = vi.fn((method: string, params?: unknown) => { - 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.patch") { - patches.push(params); - return patchGate.promise; - } - return Promise.resolve({}); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - // Suspended (app updater running): patches refuse like save/apply — a - // patch is a config write too and could overlap the install. - runtimeConfig.setWritesSuspended(true); - await expect( - runtimeConfig.patch({ raw: { count: 5 }, note: "test suspended patch" }), - ).resolves.toBe(false); - expect(patches).toHaveLength(0); - runtimeConfig.setWritesSuspended(false); - - // Once in flight, the updater barrier must wait for it. - const patchPromise = runtimeConfig.patch({ raw: { count: 5 }, note: "test in-flight patch" }); - await vi.advanceTimersByTimeAsync(0); - expect(patches).toHaveLength(1); - let drained = false; - const drainPromise = runtimeConfig.waitForPendingWrites().then(() => { - drained = true; - }); - await vi.advanceTimersByTimeAsync(0); - expect(drained).toBe(false); - patchGate.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await drainPromise; - await expect(patchPromise).resolves.toBe(true); - runtimeConfig.dispose(); - }); - - it("flushes a scheduled autosave when a write barrier runs before the debounce", async () => { - vi.useFakeTimers(); - const setGate = deferred(); - const methods: string[] = []; - const request = vi.fn((method: string, params?: unknown) => { - methods.push(method); - 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") { - expect(params).toMatchObject({ baseHash: "hash-1" }); - return setGate.promise; - } - return Promise.resolve({}); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - let drained = false; - const drain = runtimeConfig.waitForPendingWrites().then(() => { - drained = true; - }); - await vi.advanceTimersByTimeAsync(0); - - expect(methods.filter((method) => method === "config.set")).toHaveLength(1); - expect(drained).toBe(false); - - setGate.resolve({ hash: "hash-2" }); - await drain; - expect(drained).toBe(true); - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); - runtimeConfig.dispose(); - }); - - it("adopts config.patch acknowledgements for consecutive queued patches", async () => { - const patchBaseHashes: string[] = []; - let storedConfig: Record = { 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) }; - hashCounter += 1; - return { config: storedConfig, hash: `hash-${hashCounter}` }; - } - return {}; - }); - const { runtimeConfig } = createHarness(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("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 } = createHarness(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("keeps a concurrent dirty draft on its pre-patch CAS base", async () => { - vi.useFakeTimers(); - const patchGate = deferred(); - 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 } = createHarness(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(); - 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 } = createHarness(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 = { 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 } = createHarness(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 } = createHarness(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("serializes external mutations after scheduled drafts and refreshes before resolving", async () => { - vi.useFakeTimers(); - const order: string[] = []; - let storedConfig: Record = { count: 1 }; - let hash = "hash-1"; - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "config.get") { - order.push("config.get"); - return { - config: storedConfig, - raw: JSON.stringify(storedConfig), - hash, - valid: true, - issues: [], - }; - } - if (method === "config.set") { - order.push("config.set"); - storedConfig = JSON.parse((params as { raw: string }).raw) as Record; - hash = "hash-2"; - return { hash }; - } - if (method === "plugins.setEnabled") { - order.push("plugins.setEnabled"); - storedConfig = { ...storedConfig, pluginEnabled: true }; - hash = "hash-3"; - return { ok: true }; - } - return {}; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - order.length = 0; - - runtimeConfig.patchForm(["count"], 2); - const result = await runtimeConfig.runExternalMutation((client) => - client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), - ); - - expect(result).toEqual({ - ok: true, - value: { ok: true }, - refresh: { ok: true }, - }); - expect(order).toEqual(["config.set", "plugins.setEnabled", "config.get"]); - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-3"); - expect(runtimeConfig.state.configForm).toEqual({ count: 2, pluginEnabled: true }); - runtimeConfig.dispose(); - }); - - it("rechecks external mutation access after pending config writes settle", async () => { - vi.useFakeTimers(); - const firstSet = deferred(); - 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.set") { - return firstSet.promise; - } - return Promise.resolve({ ok: true }); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - methods.length = 0; - - runtimeConfig.patchForm(["count"], 2); - const result = runtimeConfig.runExternalMutation( - (client) => client.request("agents.update", { agentId: "main", name: "Agent Smith" }), - { - canDispatch: () => canDispatch, - dispatchError: "Access changed before the agent identity update started.", - }, - ); - await vi.waitFor(() => expect(methods).toEqual(["config.set"])); - canDispatch = false; - firstSet.resolve({ hash: "hash-2" }); - - await expect(result).resolves.toEqual({ - ok: false, - reason: "unavailable", - error: "Access changed before the agent identity update started.", - }); - expect(methods).toEqual(["config.set"]); - runtimeConfig.dispose(); - }); - - it("forces a post-mutation refresh instead of joining a pre-existing config load", async () => { - const staleLoad = deferred(); - let getCalls = 0; - let storedConfig: Record = { count: 1 }; - let hash = "hash-1"; - const request = vi.fn(async (method: string) => { - if (method === "config.get") { - getCalls += 1; - if (getCalls === 2) { - return staleLoad.promise; - } - return { - config: storedConfig, - raw: JSON.stringify(storedConfig), - hash, - valid: true, - issues: [], - }; - } - if (method === "plugins.setEnabled") { - storedConfig = { count: 1, pluginEnabled: true }; - hash = "hash-2"; - return { ok: true }; - } - return {}; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - const overlappingRefresh = runtimeConfig.refresh(); - await vi.waitFor(() => expect(getCalls).toBe(2)); - const result = await runtimeConfig.runExternalMutation((client) => - client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), - ); - - expect(result.ok).toBe(true); - expect(getCalls).toBe(3); - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); - expect(runtimeConfig.state.configForm).toEqual({ count: 1, pluginEnabled: true }); - - staleLoad.resolve({ - config: { count: 999 }, - raw: '{"count":999}', - hash: "stale-hash", - valid: true, - issues: [], - }); - await overlappingRefresh; - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); - runtimeConfig.dispose(); - }); - - it("preserves a committed external mutation when its authoritative refresh fails", async () => { - let getCalls = 0; - const request = vi.fn(async (method: string) => { - if (method === "config.get") { - getCalls += 1; - if (getCalls === 1) { - return { - config: { count: 1 }, - raw: '{"count":1}', - hash: "hash-1", - valid: true, - issues: [], - }; - } - throw new Error("refresh unavailable"); - } - if (method === "plugins.setEnabled") { - return { ok: true }; - } - return {}; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - const result = await runtimeConfig.runExternalMutation((client) => - client.request("plugins.setEnabled", { pluginId: "memory-core", enabled: true }), - ); - - expect(result).toEqual({ - ok: true, - value: { ok: true }, - refresh: { ok: false, error: "Error: refresh unavailable" }, - }); - expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-1"); - runtimeConfig.dispose(); - }); - - it("distinguishes definitive external mutation rejections from transient errors", async () => { - const request = vi.fn(async (method: string) => { - if (method === "config.get") { - return { - config: { count: 1 }, - raw: '{"count":1}', - hash: "hash-1", - valid: true, - issues: [], - }; - } - return {}; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - await expect( - runtimeConfig.runExternalMutation(async () => { - throw new GatewayRequestError({ - code: "INVALID_REQUEST", - message: "invalid config", - }); - }), - ).resolves.toEqual({ - ok: false, - reason: "rejected", - error: "invalid config", - }); - await expect( - runtimeConfig.runExternalMutation(async () => { - throw new Error("socket closed"); - }), - ).resolves.toEqual({ - ok: false, - reason: "error", - error: "socket closed", - }); - runtimeConfig.dispose(); - }); - - it("classifies an external mutation interrupted by disconnect as retryable", async () => { - const mutation = deferred(); - 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 } = createHarness(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("queues background external mutations until write suspension ends", async () => { - const methods: string[] = []; - const request = vi.fn(async (method: string) => { - methods.push(method); - if (method === "config.get") { - return { - config: { count: 1 }, - raw: '{"count":1}', - hash: "hash-1", - valid: true, - issues: [], - }; - } - return { ok: true }; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - methods.length = 0; - runtimeConfig.setWritesSuspended(true); - - const resultPromise = runtimeConfig.runExternalMutation( - (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), - { waitForWritesResumed: true }, - ); - await Promise.resolve(); - expect(methods).toEqual([]); - - runtimeConfig.setWritesSuspended(false); - await expect(resultPromise).resolves.toEqual({ - ok: true, - value: { ok: true }, - refresh: { ok: true }, - }); - expect(methods).toEqual(["config.patch", "config.get"]); - runtimeConfig.dispose(); - }); - - it("preserves queued mutation waiters when suspension is repeated", async () => { - const methods: string[] = []; - const request = vi.fn(async (method: string) => { - methods.push(method); - if (method === "config.get") { - return { - config: { count: 1 }, - raw: '{"count":1}', - hash: "hash-1", - valid: true, - issues: [], - }; - } - return { ok: true }; - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - methods.length = 0; - runtimeConfig.setWritesSuspended(true); - - const resultPromise = runtimeConfig.runExternalMutation( - (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), - { waitForWritesResumed: true }, - ); - await Promise.resolve(); - runtimeConfig.setWritesSuspended(true); - runtimeConfig.setWritesSuspended(false); - - await expect(resultPromise).resolves.toEqual({ - ok: true, - value: { ok: true }, - refresh: { ok: true }, - }); - expect(methods).toEqual(["config.patch", "config.get"]); - 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("retries a background mutation when suspension begins during its write drain", async () => { - vi.useFakeTimers(); - const firstSet = deferred(); - const methods: string[] = []; - const request = vi.fn((method: string) => { - methods.push(method); - if (method === "config.get") { - return Promise.resolve({ - config: { count: 1 }, - raw: '{"count":1}', - hash: methods.filter((entry) => entry === "config.set").length ? "hash-2" : "hash-1", - valid: true, - issues: [], - }); - } - if (method === "config.set") { - return firstSet.promise; - } - return Promise.resolve({ ok: true }); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - methods.length = 0; - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - const resultPromise = runtimeConfig.runExternalMutation( - (client) => client.request("config.patch", { raw: '{"ui":{"prefs":{"locale":"de"}}}' }), - { waitForWritesResumed: true }, - ); - runtimeConfig.setWritesSuspended(true); - firstSet.resolve({ hash: "hash-2" }); - await vi.advanceTimersByTimeAsync(0); - expect(methods).toEqual(["config.set"]); - - runtimeConfig.setWritesSuspended(false); - await expect(resultPromise).resolves.toEqual({ - ok: true, - value: { ok: true }, - refresh: { ok: true }, - }); - expect(methods).toEqual(["config.set", "config.patch", "config.get"]); - 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 } = createHarness(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("flushes a pre-ack revert during disposal", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // Revert to the original value while the save is in flight: dirty reads - // false (originals not yet rebased onto the submission), but the bytes - // differ from the submitted ones — dropping this flush would persist the - // unreverted value. - runtimeConfig.patchForm(["count"], 1); - expect(runtimeConfig.state.configFormDirty).toBe(false); - runtimeConfig.dispose(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - - expect(submissions).toHaveLength(2); - expect(submissions[1]).toEqual({ raw: '{\n "count": 1\n}\n', baseHash: "hash-2" }); - }); - - 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, - 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 } = createHarness(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("does not write when an edit is reverted within the debounce window", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - // Dirtiness is canonical-vs-canonical on the form objects, so a revert to - // the original value reads clean and never rewrites the file (which would - // destroy JSON5 comments/formatting for a semantic no-op). - runtimeConfig.patchForm(["count"], 2); - runtimeConfig.patchForm(["count"], 1); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); - - expect(server.submissions).toHaveLength(0); - expect(runtimeConfig.state.configFormDirty).toBe(false); - runtimeConfig.dispose(); - }); - - it("serializes explicit ops queued behind the same in-flight write", async () => { - vi.useFakeTimers(); - const { request, submissions, applySubmissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // Both ops queue behind the hung autosave; they must dispatch one after - // another, each against the base its predecessor produced — not both - // against the drained flight's base. - const savePromise = runtimeConfig.save(); - const applyPromise = runtimeConfig.apply(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await expect(savePromise).resolves.toBe(true); - await expect(applyPromise).resolves.toBe(true); - - expect(submissions).toHaveLength(2); - expect(submissions[1]?.baseHash).toBe("hash-2"); - expect(applySubmissions).toHaveLength(1); - expect(applySubmissions[0]?.baseHash).toBe("hash-3"); - runtimeConfig.dispose(); - }); - - it("cancels a debounce armed while an explicit save drains another write", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - const save = runtimeConfig.save(); - runtimeConfig.patchForm(["count"], 3); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await expect(save).resolves.toBe(true); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); - - expect(submissions).toEqual([ - { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, - { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, - ]); - runtimeConfig.dispose(); - }); - - it("lets a reconnected explicit op bypass a dead prior-connection FIFO", async () => { - const deadSet = deferred(); - 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 } = createHarness(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(); - 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 } = createHarness(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(); - 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 } = createHarness(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(); - 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 } = createHarness(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(); - - 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("defers autosaves behind a manual write and keeps the newer edit", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - const savePromise = runtimeConfig.save(); - await vi.advanceTimersByTimeAsync(0); - expect(submissions).toHaveLength(1); - - // Edit while the manual save is pending: no concurrent config.set may - // start (it would race the same base hash), and the manual completion - // must not snap the draft back to its older submitted bytes. - runtimeConfig.patchForm(["count"], 3); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 2); - expect(submissions).toHaveLength(1); - - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await savePromise; - await vi.advanceTimersByTimeAsync(0); - - expect(submissions).toHaveLength(2); - expect(submissions[1]).toEqual({ raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }); - expect(runtimeConfig.state.configForm).toEqual({ count: 3 }); - expect(runtimeConfig.state.configAutoSaveStatus).toBe("saved"); - 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 } = createHarness(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("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 } = createHarness(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("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, - 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 } = createHarness(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, - 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 } = createHarness(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, - 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 } = createHarness(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 process-local needsApply after a legacy hashless ack reload", async () => { - vi.useFakeTimers(); - const { request, firstSet, submissions } = createDeferredSetServerMock({ legacyAck: true }); - const { runtimeConfig } = createHarness(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("drains in-flight saves before a discarding refresh", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - expect(submissions).toHaveLength(1); - - // Same barrier as discardDraft: the settling flight must not trail the - // just-discarded edit back to disk after the refresh. - runtimeConfig.patchForm(["count"], 3); - const refreshPromise = runtimeConfig.refresh({ discardPendingChanges: true }); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await refreshPromise; - - expect(submissions).toHaveLength(1); - expect(runtimeConfig.state.configFormDirty).toBe(false); - expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); - runtimeConfig.dispose(); - }); - - it("flushes a scheduled form autosave before config.patch and re-arms after", async () => { - vi.useFakeTimers(); - const order: string[] = []; - let hashCounter = 1; - const request = vi.fn((method: string) => { - if (method === "config.get") { - return Promise.resolve({ - config: { count: 1 }, - raw: '{\n "count": 1\n}\n', - hash: `hash-${hashCounter}`, - valid: true, - issues: [], - }); - } - if (method === "config.set" || method === "config.patch") { - order.push(method); - hashCounter += 1; - return Promise.resolve({ hash: `hash-${hashCounter}` }); - } - return Promise.resolve({}); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - // Patch during the debounce window: the draft must be flushed as a real - // save before the patch, not silently dropped with its timer. - runtimeConfig.patchForm(["count"], 2); - let patchBaseCount: unknown; - await expect( - runtimeConfig.patchFromSnapshot((config) => { - patchBaseCount = config.count; - return { - options: { raw: { other: true }, note: "test patch after autosave" }, - }; - }), - ).resolves.toBe(true); - - expect(order).toEqual(["config.set", "config.patch"]); - expect(patchBaseCount).toBe(2); - expect(runtimeConfig.state.configFormDirty).toBe(false); - runtimeConfig.dispose(); - }); - - it("adopts the acked autosave without a follow-up reload", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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(); - }); - - 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 } = createHarness(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(); - }); - - it("chains the teardown flush behind a pending manual save", async () => { - vi.useFakeTimers(); - const { request, submissions, firstSet } = createDeferredSetServerMock(); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - const savePromise = runtimeConfig.save(); - await vi.advanceTimersByTimeAsync(0); - expect(submissions).toHaveLength(1); - - runtimeConfig.patchForm(["count"], 3); - runtimeConfig.dispose(); - firstSet.resolve({}); - await vi.advanceTimersByTimeAsync(0); - await savePromise; - - // Exactly one chained flush, based on the manual save's own ack hash — - // never a parallel write against the same base. - expect(submissions).toEqual([ - { raw: '{\n "count": 2\n}\n', baseHash: "hash-1" }, - { raw: '{\n "count": 3\n}\n', baseHash: "hash-2" }, - ]); - }); - - it("skips the teardown flush behind a pending apply", async () => { - vi.useFakeTimers(); - const firstApply = deferred(); - let setCalls = 0; - 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") { - setCalls += 1; - return Promise.resolve({ hash: "hash-9" }); - } - if (method === "config.apply") { - return firstApply.promise.then(() => ({ hash: "hash-2" })); - } - return Promise.resolve({}); - }); - const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); - await runtimeConfig.ensureLoaded(); - - runtimeConfig.patchForm(["count"], 2); - const applyPromise = runtimeConfig.apply(); - await vi.advanceTimersByTimeAsync(0); - - // The gateway is about to restart; a post-apply write is meaningless. - runtimeConfig.patchForm(["count"], 3); - runtimeConfig.dispose(); - firstApply.resolve({}); - await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); - await applyPromise; - expect(setCalls).toBe(0); - }); - - it("never auto-saves raw-text drafts and submits them on manual save", async () => { - vi.useFakeTimers(); - const server = createConfigServerMock(); - const { runtimeConfig } = createHarness(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(); - }); -}); - -describe("agent config helpers", () => { - 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; - const config = { agents: { entries } }; - - expect(resolveAgentConfigEntryTarget(config, "missing")).toBeNull(); - expect(resolveAgentConfigEntryTarget(config, " ")).toBeNull(); - expect(resolveAgentConfigEntryTarget(config, "__proto__")).toBeNull(); - expect(resolveAgentConfigEntryTarget(config, "foo-bar")).toBeNull(); - }); -}); - -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts deleted file mode 100644 index 27659a5e9124..000000000000 --- a/ui/src/lib/config/index.ts +++ /dev/null @@ -1,2374 +0,0 @@ -// Control UI runtime config capability and shared config-domain mutations. -import { ErrorCodes } from "@openclaw/gateway-client/browser"; -import { - asNullableRecord as asConfigRecord, - isRecord, -} from "@openclaw/normalization-core/record-coerce"; -import { - GatewayRequestError, - type GatewayBrowserClient, - type GatewayHelloOk, -} from "../../api/gateway.ts"; -import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts"; -import type { ApplicationGatewayPhase } from "../../app/gateway.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 { copyToClipboard } from "../clipboard.ts"; -import { - cloneConfigObject, - removePathValue, - sanitizeRedactedFormForSubmit, - serializeConfigForm, - setPathValue, -} from "../config-form-utils.ts"; -import { canCallGatewayMethod } from "../gateway-methods.ts"; -import { parseJson5Text, warmJson5 } from "../json5-runtime.ts"; -import { normalizeAgentId } from "../sessions/session-key.ts"; -import { createAppliedConfigRefreshController } from "./applied-refresh.ts"; - -export type ConfigAutoSaveStatus = "idle" | "saving" | "saved" | "error" | "conflict"; - -type RuntimeConfigExternalMutationResult = - | { - ok: true; - value: T; - refresh: { ok: true } | { ok: false; error: string }; - } - | { - ok: false; - reason: "conflict" | "error" | "rejected" | "suspended" | "unavailable"; - error: string; - }; - -type RuntimeConfigExternalMutationOptions = { - waitForWritesResumed?: boolean; - canDispatch?: () => boolean; - dispatchError?: string; -}; - -type RuntimeConfigDispatchOptions = { - canDispatch?: () => boolean; -}; - -/** Debounce window between the last form edit and its automatic config.set. */ -const CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS = 800; - -/** Reads the additive ack hash from a config.set/config.apply response. */ -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) - ); -} - -type ConfigState = { - client: GatewayBrowserClient | null; - connected: boolean; - applySessionKey: string; - configLoading: boolean; - configRaw: string; - configRawOriginal: string; - configRawOriginalParsed: Record | null; - configRawOriginalParsePending: Promise | 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 | null; - configFormOriginal: Record | null; - configFormDirty: boolean; - configFormMode: "form" | "raw"; - configSearchQuery: string; - configActiveSection: string | null; - configActiveSubsection: string | null; - lastError: string | null; - chatError?: string | null; -}; - -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; -} - -const autoAllowlistedPluginIdsByState = new WeakMap>(); -const requestVersionsByState = new WeakMap(); -const connectionEpochsByState = new WeakMap(); - -type RuntimeConfigGatewaySnapshot = { - client: GatewayBrowserClient | null; - phase: ApplicationGatewayPhase; - sessionKey: string; - hello?: GatewayHelloOk | null; -}; - -type RuntimeConfigGateway = { - readonly snapshot: RuntimeConfigGatewaySnapshot; - subscribe: (listener: (snapshot: RuntimeConfigGatewaySnapshot) => void) => () => void; -}; - -export type RuntimeConfigCapability = { - readonly state: ConfigState; - readonly canSet?: boolean; - readonly canApply?: boolean; - readonly canPatch?: boolean; - readonly canOpenFile?: boolean; - ensureLoaded: () => Promise; - ensureSchemaLoaded: () => Promise; - refresh: (options?: LoadConfigOptions) => Promise; - refreshSchema: () => Promise; - patchForm: (path: Array, value: unknown) => void; - removeFormValue: (path: Array) => void; - setRaw: (value: string) => void; - resetDraft: () => void; - /** Discards pending edits: reloads from disk when connected, else resets locally. */ - discardDraft: () => Promise; - /** 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; - save: (options?: RuntimeConfigDispatchOptions) => Promise; - apply: () => Promise; - openFile: () => Promise; - /** 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; - patchFromSnapshot: (build: ConfigPatchBuilder) => Promise; - /** - * Serializes a config-writing RPC behind this capability's pending draft, - * then refreshes the authoritative snapshot before resolving. - */ - runExternalMutation: ( - task: (client: GatewayBrowserClient) => Promise, - options?: RuntimeConfigExternalMutationOptions, - ) => Promise>; - lookupSchemaPath: (path: string) => Promise; - subscribe: (listener: (state: ConfigState) => void) => () => void; - dispose: () => void; -}; - -type LoadConfigOptions = { - discardPendingChanges?: boolean; -}; - -type ConfigPatchOptions = { - raw: string | Record; - 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; -}; - -type ConfigPatchBuildResult = { options: ConfigPatchOptions } | { error: string }; -type ConfigPatchBuilder = (config: Readonly>) => ConfigPatchBuildResult; -type ConfigPatchAck = { - config?: unknown; - hash?: unknown; - noop?: boolean; -}; - -type ConfigGatewayClient = { - request(method: string, params?: unknown): Promise; -}; - -type ConfigConnectionState = { - client: ConfigGatewayClient | null; - connected: boolean; -}; - -function createInitialConfigState(snapshot?: Partial): ConfigState { - 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, - }; -} - -function nextRequestVersion(state: ConfigState, 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]; -} - -function currentConfigConnectionEpoch(state: object): number { - return connectionEpochsByState.get(state) ?? 0; -} - -function invalidateConfigConnection(state: object): void { - connectionEpochsByState.set(state, currentConfigConnectionEpoch(state) + 1); -} - -function isCurrentConfigConnection( - state: ConfigConnectionState, - client: ConfigGatewayClient, - connectionEpoch: number, -): boolean { - return ( - state.connected && - state.client === client && - currentConfigConnectionEpoch(state) === connectionEpoch - ); -} - -function isCurrentRequest( - state: ConfigState, - 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. */ -async function loadConfig( - state: ConfigState, - options: LoadConfigOptions = {}, - isCurrentLoad: () => boolean = () => true, -): Promise { - 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("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; - } - } -} - -async function loadConfigSchema(state: ConfigState) { - 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("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: ConfigState, res: ConfigSchemaResponse) { - state.configSchema = res.schema ?? null; - state.configUiHints = res.uiHints ?? {}; - state.configSchemaVersion = res.version ?? null; -} - -export function resolveEditableSnapshotConfig( - snapshot: ConfigSnapshot | null | undefined, -): Record | null { - return ( - asConfigRecord(snapshot?.sourceConfig) ?? - asConfigRecord(snapshot?.resolved) ?? - asConfigRecord(snapshot?.config) - ); -} - -export function currentConfigObject( - state: Pick, -): Record | null { - return state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot); -} - -function applyConfigSnapshot( - state: ConfigState, - 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 asJsonSchema(value: unknown): JsonSchema | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as JsonSchema; -} - -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 = {}; - for (const [key, val] of Object.entries(value as Record)) { - 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 `` 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. - */ -function serializeFormForSubmit(state: ConfigState): 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 = asJsonSchema(state.configSchema); - const form = schema - ? (coerceFormValues(state.configForm, schema) as Record) - : state.configForm; - const sanitized = sanitizeRedactedFormForSubmit( - form, - state.configFormOriginal, - state.configRawOriginalParsed, - ); - return serializeConfigForm(sanitized); -} - -type ConfigSubmitMethod = "config.set" | "config.apply"; -type ConfigSubmitBusyKey = "configSaving" | "configApplying"; - -/** - * 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. - */ -function adoptConfigSetAck(state: ConfigState, 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. -function reconcileHashlessWriteReload(state: ConfigState, submittedRaw: string) { - if (state.configSnapshot?.raw !== submittedRaw) { - return; - } - const hash = state.configSnapshot.hash ?? null; - if (state.configFormDirty) { - state.configDraftBaseHash = hash ?? state.configDraftBaseHash; - } -} - -async function submitConfigChange( - state: ConfigState, - method: ConfigSubmitMethod, - busyKey: ConfigSubmitBusyKey, - extraParams: Record = {}, - onSubmitted?: (info: { raw: string; ackHash: string | null }) => void, - canDispatch: () => boolean = () => true, -): Promise { - 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; - autoAllowlistedPluginIdsByState.delete(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. - */ -function teardownFlushConfigDraft( - state: ConfigState, - 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. - */ -async function autoSaveConfig( - state: ConfigState, - onAck?: (ackHash: string | null) => void, - canDispatch: () => boolean = () => true, -): Promise { - 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; - autoAllowlistedPluginIdsByState.delete(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; - } -} - -function syncConfigDraft(state: ConfigState, nextForm: Record) { - 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: ConfigState) { - if (state.configAutoSaveStatus === "saving" || state.configAutoSaveStatus === "conflict") { - return; - } - if (!state.configFormDirty && state.configAutoSaveStatus === "error") { - state.lastError = null; - } - state.configAutoSaveStatus = "idle"; -} - -async function saveConfig( - state: ConfigState, - onSubmitted?: (info: { raw: string; ackHash: string | null }) => void, - canDispatch?: () => boolean, -): Promise { - return submitConfigChange(state, "config.set", "configSaving", {}, onSubmitted, canDispatch); -} - -async function applyConfig(state: ConfigState, canDispatch?: () => boolean): Promise { - return submitConfigChange( - state, - "config.apply", - "configApplying", - { - sessionKey: state.applySessionKey, - }, - undefined, - canDispatch, - ); -} - -async function patchConfig( - state: ConfigState, - options: ConfigPatchOptions, - onAck?: (ack: ConfigPatchAck, snapshotAtDispatch: ConfigSnapshot) => Promise | void, -): Promise { - 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("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; - } -} - -function adoptConfigPatchAck( - state: ConfigState, - 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) - : ackConfig - ? serializeConfigForm(ackConfig) - : (currentSnapshot.raw ?? state.configRaw); - applyConfigSnapshot(state, { - ...currentSnapshot, - ...(ackConfig ? { config: ackConfig, sourceConfig: ackConfig } : {}), - hash: ackHash ?? currentSnapshot.hash ?? null, - raw, - valid: true, - issues: [], - }); -} - -async function lookupConfigSchemaPath( - state: { client: ConfigGatewayClient | null; connected: boolean }, - path: string, -): Promise { - 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; - } -} - -function parseConfigRawDraft(raw: string): Record | null { - try { - const parsed = parseJson5Text(raw); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : 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: ConfigState, 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: ConfigState, mutate: (draft: Record) => void) { - let base: Record; - 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: ConfigState, pluginId: string) { - const pluginIds = autoAllowlistedPluginIdsByState.get(state); - if (pluginIds) { - pluginIds.add(pluginId); - } else { - autoAllowlistedPluginIdsByState.set(state, new Set([pluginId])); - } -} - -function untrackAutoAllowlistedPluginId(state: ConfigState, pluginId: string) { - const pluginIds = autoAllowlistedPluginIdsByState.get(state); - if (!pluginIds) { - return; - } - pluginIds.delete(pluginId); - if (pluginIds.size === 0) { - autoAllowlistedPluginIdsByState.delete(state); - } -} - -function syncEnabledPluginAllowlist( - state: ConfigState, - draft: Record, - path: Array, - 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) - : 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); -} - -function updateConfigFormValue(state: ConfigState, path: Array, 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); - }); -} - -function updateConfigRawValue(state: ConfigState, 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; - } -} - -function resetConfigPendingChanges(state: ConfigState) { - 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); -} - -function removeConfigFormValue(state: ConfigState, path: Array) { - mutateConfigForm(state, (draft) => removePathValue(draft, path)); -} - -export type AgentConfigEntryTarget = { - path: ["agents", "entries", string]; - entry: Record; -}; - -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 | 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, - }; -} - -function agentConfigEntry( - state: ConfigState, - 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: {} }; -} - -function stageDefaultAgentConfigEntry(state: ConfigState, 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; -} - -async function openConfigFile(state: ConfigState): Promise { - 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; - } - } -} - -export function createRuntimeConfigCapability( - gateway: RuntimeConfigGateway, -): RuntimeConfigCapability { - const state = createInitialConfigState(gateway.snapshot); - const listeners = new Set<(state: ConfigState) => void>(); - let configLoad: Promise | null = null; - let schemaLoad: Promise | null = null; - let disposed = false; - let autoSaveTimer: ReturnType | null = null; - let autoSaveInFlight: Promise | 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 | 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 = 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 = 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 canCallConfigMethod = ( - method: "config.set" | "config.apply" | "config.patch" | "config.openFile", - options?: { requireAdvertisement?: boolean }, - ) => - canCallGatewayMethod( - { - client: gateway.snapshot.client, - hello: gateway.snapshot.hello ?? null, - phase: gateway.snapshot.phase, - }, - method, - "operator.admin", - options, - ); - 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 publish = () => { - if (disposed) { - return; - } - for (const listener of listeners) { - listener(state); - } - }; - const run = async (task: () => Promise): Promise => { - 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): Promise => { - 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): Promise => { - const current = key === "config" ? configLoad : schemaLoad; - return current ?? trackLoad(key, run(task)); - }; - const cancelScheduledAutoSave = () => { - if (autoSaveTimer) { - clearTimeout(autoSaveTimer); - autoSaveTimer = null; - } - autoSaveTrailing = false; - }; - const appliedRefresh = createAppliedConfigRefreshController({ - shouldRefresh: () => - !disposed && - state.connected && - state.configNeedsApply && - state.configSnapshot?.appliedConfigHash !== undefined, - refresh: (isCurrent) => loadOnce("config", () => loadConfig(state, {}, isCurrent)), - }); - const cancelAppliedRefresh = appliedRefresh.cancel; - const reconcileAppliedRefresh = appliedRefresh.reconcile; - const runAutoSave = () => { - if ( - disposed || - 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 && !disposed) { - 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 ( - disposed || - 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 => { - 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 (disposed) { - 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 => { - 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 | null = null; - const afterPendingWritesSettled = ( - task: () => Promise, - unavailable: T, - options: { flushScheduledDraft?: boolean; canDispatch?: () => boolean } = {}, - ): Promise => { - 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 || disposed) { - 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 = queued - .catch(() => false) - .then(() => { - if (explicitOpQueue === tail) { - explicitOpQueue = null; - } - }); - explicitOpQueue = tail; - return queued; - }; - const ensureLoaded = async () => { - if (!state.configSnapshot) { - await loadOnce("config", () => loadConfig(state)); - } - reconcileAppliedRefresh(); - }; - const ensureSchemaLoaded = () => - state.configSchema ? Promise.resolve() : loadOnce("schema", () => loadConfigSchema(state)); - 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); - configLoad = null; - schemaLoad = null; - // 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 (disposed) { - 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 => { - 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. - configLoad = null; - 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 { - 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 drainWritesForDiscard(); - } - cancelAppliedRefresh(); - try { - await trackLoad( - "config", - run(() => loadConfig(state, options)), - ); - } finally { - reconcileAppliedRefresh(); - } - }, - refreshSchema: () => - trackLoad( - "schema", - run(() => loadConfigSchema(state)), - ), - 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") }, - ), - openFile: () => - canCallConfigMethod("config.openFile", { requireAdvertisement: false }) - ? run(() => openConfigFile(state)) - : Promise.resolve(), - agentEntry: (agentId, options) => agentConfigEntry(state, agentId, options), - 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 ( - task: (client: GatewayBrowserClient) => Promise, - options: RuntimeConfigExternalMutationOptions = {}, - ): Promise> => { - const mutationClient = state.client; - const mutationConnectionEpoch = currentConfigConnectionEpoch(state); - while (true) { - if (options.waitForWritesResumed && writesSuspended && !disposed) { - await writesResumedPromise; - } - const unavailable: RuntimeConfigExternalMutationResult = { - 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>( - async (): Promise> => { - if (!isCurrentConfigConnection(state, mutationClient, mutationConnectionEpoch)) { - return unavailable; - } - 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(mutationClient); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!isCurrentConfigConnection(state, mutationClient, mutationConnectionEpoch)) { - 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: message, - }; - } - if (!isCurrentConfigConnection(state, mutationClient, mutationConnectionEpoch)) { - return { - ok: true, - value, - refresh: { - ok: false, - error: "Connection changed before the configuration update was refreshed.", - }, - }; - } - try { - // Do not join an older config.get that started before the external - // RPC. Start a fresh versioned load so only post-mutation state can - // satisfy this method's authoritative-refresh contract. - const refresh = run(() => loadConfig(state)); - void trackLoad("config", refresh); - const refreshed = await refresh; - if (!isCurrentConfigConnection(state, mutationClient, mutationConnectionEpoch)) { - return { - ok: true, - value, - refresh: { - ok: false, - error: "Connection changed before the configuration update was refreshed.", - }, - }; - } - if (!refreshed) { - return { - ok: true, - value, - refresh: { - ok: false, - error: - state.lastError ?? - "The configuration update completed, but its authoritative refresh failed.", - }, - }; - } - return { ok: true, value, refresh: { ok: true } }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: true, - value, - refresh: { ok: false, error: message }, - }; - } - }, - unavailable, - { flushScheduledDraft: true }, - ); - if ( - !( - options.waitForWritesResumed && - !disposed && - !result.ok && - (result.reason === "suspended" || writesSuspended) - ) - ) { - return result; - } - } - }, - lookupSchemaPath: (path) => run(() => lookupConfigSchemaPath(state, path)), - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - dispose() { - disposed = true; - writesResumed?.(); - writesResumed = null; - // Free any drain awaiting a flight that will never be reconciled now; - // the disposed 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(); - appliedRefresh.dispose(); - 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(); - listeners.clear(); - requestVersionsByState.delete(state); - autoAllowlistedPluginIdsByState.delete(state); - }, - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/lib/config/mcp-servers.ts b/ui/src/lib/config/mcp-servers.ts index fd3eee8aec2f..31ff201961f7 100644 --- a/ui/src/lib/config/mcp-servers.ts +++ b/ui/src/lib/config/mcp-servers.ts @@ -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._-]*$/; diff --git a/ui/src/lib/config/runtime-config-capability.test.ts b/ui/src/lib/config/runtime-config-capability.test.ts new file mode 100644 index 000000000000..97ab998d7878 --- /dev/null +++ b/ui/src/lib/config/runtime-config-capability.test.ts @@ -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(); + 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(); + + 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(); + 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(); + const patchGate = deferred(); + 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(); + 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, + 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(); + 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(); + 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(); + 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(); + 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(); + + 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, + 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, + 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, + 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(); + }); +}); diff --git a/ui/src/lib/config/runtime-config-capability.ts b/ui/src/lib/config/runtime-config-capability.ts new file mode 100644 index 000000000000..46eb38e9a44c --- /dev/null +++ b/ui/src/lib/config/runtime-config-capability.ts @@ -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; + ensureSchemaLoaded: () => Promise; + refresh: (options?: LoadConfigOptions) => Promise; + refreshSchema: () => Promise; + patchForm: (path: Array, value: unknown) => void; + removeFormValue: (path: Array) => void; + setRaw: (value: string) => void; + resetDraft: () => void; + /** Discards pending edits: reloads from disk when connected, else resets locally. */ + discardDraft: () => Promise; + /** 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; + save: (options?: RuntimeConfigDispatchOptions) => Promise; + apply: () => Promise; + openFile: () => Promise; + /** 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; + patchFromSnapshot: (build: ConfigPatchBuilder) => Promise; + /** + * Serializes a config-writing RPC behind this capability's pending draft, + * then refreshes the authoritative snapshot before resolving. + */ + runExternalMutation: ( + task: (client: GatewayBrowserClient) => Promise, + options?: RuntimeConfigExternalMutationOptions, + ) => Promise>; + lookupSchemaPath: (path: string) => Promise; + 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 | null = null; + let schemaLoad: Promise | 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 (task: () => Promise): Promise => { + 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): Promise => { + 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): Promise => { + 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); + }, + }; +} diff --git a/ui/src/lib/plugins/index.ts b/ui/src/lib/plugins/index.ts index ef311eb2d1f4..bd70559dd4d8 100644 --- a/ui/src/lib/plugins/index.ts +++ b/ui/src/lib/plugins/index.ts @@ -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; diff --git a/ui/src/lib/skills/config-mutations.ts b/ui/src/lib/skills/config-mutations.ts index 0d6a270fd289..db4bbc9bfdd2 100644 --- a/ui/src/lib/skills/config-mutations.ts +++ b/ui/src/lib/skills/config-mutations.ts @@ -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; diff --git a/ui/src/lib/skills/index.test.ts b/ui/src/lib/skills/index.test.ts index d7dbdfd70486..76f8004450d2 100644 --- a/ui/src/lib/skills/index.test.ts +++ b/ui/src/lib/skills/index.test.ts @@ -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, diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 4e3339d129a2..097e2024627a 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -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, diff --git a/ui/src/pages/agents/identity-actions.test.ts b/ui/src/pages/agents/identity-actions.test.ts index f4a264e1925e..b5ca922a5757 100644 --- a/ui/src/pages/agents/identity-actions.test.ts +++ b/ui/src/pages/agents/identity-actions.test.ts @@ -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"; diff --git a/ui/src/pages/agents/memory/dreaming.test.ts b/ui/src/pages/agents/memory/dreaming.test.ts index db0eaa44d150..cb34109a0d36 100644 --- a/ui/src/pages/agents/memory/dreaming.test.ts +++ b/ui/src/pages/agents/memory/dreaming.test.ts @@ -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, diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 88834cfcbb7a..2bab23e0c0f6 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -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, diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index 9f2b336e7be6..9393d2dcb18e 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -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"; diff --git a/ui/src/pages/agents/model-config.test.ts b/ui/src/pages/agents/model-config.test.ts index d7260e58a3d8..f69eca9a11c7 100644 --- a/ui/src/pages/agents/model-config.test.ts +++ b/ui/src/pages/agents/model-config.test.ts @@ -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) { diff --git a/ui/src/pages/agents/model-config.ts b/ui/src/pages/agents/model-config.ts index 2131f2ac17b2..e22e674dafca 100644 --- a/ui/src/pages/agents/model-config.ts +++ b/ui/src/pages/agents/model-config.ts @@ -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"]; diff --git a/ui/src/pages/agents/skills.test.ts b/ui/src/pages/agents/skills.test.ts index 93c6caac2714..00b047e22082 100644 --- a/ui/src/pages/agents/skills.test.ts +++ b/ui/src/pages/agents/skills.test.ts @@ -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", () => { diff --git a/ui/src/pages/agents/skills.ts b/ui/src/pages/agents/skills.ts index 6d42afe9749d..c7bbf44eb45c 100644 --- a/ui/src/pages/agents/skills.ts +++ b/ui/src/pages/agents/skills.ts @@ -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 = { diff --git a/ui/src/pages/channels/channels-page.test.ts b/ui/src/pages/channels/channels-page.test.ts index 37c70ea9464e..66a8ca8a6a09 100644 --- a/ui/src/pages/channels/channels-page.test.ts +++ b/ui/src/pages/channels/channels-page.test.ts @@ -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; diff --git a/ui/src/pages/config/memory-defaults.ts b/ui/src/pages/config/memory-defaults.ts index 401a20b066f1..084ec46c4aa1 100644 --- a/ui/src/pages/config/memory-defaults.ts +++ b/ui/src/pages/config/memory-defaults.ts @@ -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; diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts index b68ce0388375..e05439046397 100644 --- a/ui/src/pages/config/memory-page.ts +++ b/ui/src/pages/config/memory-page.ts @@ -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 { diff --git a/ui/src/pages/cron/form-suggestions.ts b/ui/src/pages/cron/form-suggestions.ts index ef805d1aa769..29ebc594b489 100644 --- a/ui/src/pages/cron/form-suggestions.ts +++ b/ui/src/pages/cron/form-suggestions.ts @@ -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, diff --git a/ui/src/pages/devices/devices-page.ts b/ui/src/pages/devices/devices-page.ts index 4ab076e0a8a3..75a26d57d10f 100644 --- a/ui/src/pages/devices/devices-page.ts +++ b/ui/src/pages/devices/devices-page.ts @@ -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, diff --git a/ui/src/pages/labs/labs-page.ts b/ui/src/pages/labs/labs-page.ts index 3c626dfb5d4a..19ca0c3a67e0 100644 --- a/ui/src/pages/labs/labs-page.ts +++ b/ui/src/pages/labs/labs-page.ts @@ -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"; diff --git a/ui/src/pages/model-providers/config-mutation.ts b/ui/src/pages/model-providers/config-mutation.ts index e1e0eb7223e3..a0dc03a11d82 100644 --- a/ui/src/pages/model-providers/config-mutation.ts +++ b/ui/src/pages/model-providers/config-mutation.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 = { diff --git a/ui/src/pages/model-providers/load.ts b/ui/src/pages/model-providers/load.ts index 4c21a52a07cc..85ac9b136181 100644 --- a/ui/src/pages/model-providers/load.ts +++ b/ui/src/pages/model-providers/load.ts @@ -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, diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts index 5f3827981a6d..f34ff4a843a4 100644 --- a/ui/src/pages/model-setup/model-setup-page.test.ts +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -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, diff --git a/ui/src/pages/model-setup/model-setup-reconnect.test.ts b/ui/src/pages/model-setup/model-setup-reconnect.test.ts index dd337c3c6a83..9cd84c057d3a 100644 --- a/ui/src/pages/model-setup/model-setup-reconnect.test.ts +++ b/ui/src/pages/model-setup/model-setup-reconnect.test.ts @@ -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"; diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 8597d246b2b4..433f0c3830cd 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -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, diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 181d9113ecfd..353666edd85b 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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, diff --git a/ui/src/pages/skill-workshop/self-learning.ts b/ui/src/pages/skill-workshop/self-learning.ts index bc3a1a620fb3..7843e1051458 100644 --- a/ui/src/pages/skill-workshop/self-learning.ts +++ b/ui/src/pages/skill-workshop/self-learning.ts @@ -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;