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