fix(ui): show restart banner for unapplied config (#107577)

* fix(ui): derive config apply state from gateway

* test(ui): align applied config hash coverage

* test(ui): follow config autosave flow

* fix(ui): guard applied config refreshes

* fix(ui): reconcile patched config revisions

* fix(ui): prevent stale config apply polling

* chore: leave release notes to release flow

* refactor(config): split applied revision helpers

* style(config): format gateway methods

* fix(i18n): refresh Korean native glossary hash
This commit is contained in:
Peter Steinberger
2026-07-14 08:43:35 -07:00
committed by GitHub
parent e6cec8e124
commit 2ecc6b6b6b
27 changed files with 828 additions and 277 deletions
@@ -490,6 +490,7 @@ public struct Snapshot: Codable, Sendable {
public let health: AnyCodable
public let stateversion: StateVersion
public let uptimems: Int
public let appliedconfighash: AnyCodable?
public let configpath: String?
public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
@@ -501,6 +502,7 @@ public struct Snapshot: Codable, Sendable {
health: AnyCodable,
stateversion: StateVersion,
uptimems: Int,
appliedconfighash: AnyCodable? = nil,
configpath: String? = nil,
statedir: String? = nil,
sessiondefaults: [String: AnyCodable]? = nil,
@@ -511,6 +513,7 @@ public struct Snapshot: Codable, Sendable {
self.health = health
self.stateversion = stateversion
self.uptimems = uptimems
self.appliedconfighash = appliedconfighash
self.configpath = configpath
self.statedir = statedir
self.sessiondefaults = sessiondefaults
@@ -523,6 +526,7 @@ public struct Snapshot: Codable, Sendable {
case health
case stateversion = "stateVersion"
case uptimems = "uptimeMs"
case appliedconfighash = "appliedConfigHash"
case configpath = "configPath"
case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
+6 -1
View File
@@ -113,6 +113,11 @@ above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
`node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry.
The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh`
path is not supported; use plugin surfaces.
The snapshot's optional `appliedConfigHash` is the resolved source-config revision
accepted by the active Gateway runtime. Clients can compare it with
`config.get.configRevisionHash` to determine whether a newer saved config still
needs a restart. `config.get.hash` remains the raw root-file revision used by
config write conflict guards.
While the gateway is still finishing startup sidecars, `connect` can return a
retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and
@@ -487,7 +492,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Secrets, config, update, and wizard">
- `secrets.reload` re-resolves active SecretRefs and swaps runtime secret state only on full success.
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
- `config.get` returns the current config snapshot and hash.
- `config.get` returns the current on-disk config snapshot, raw root-file `hash`, resolved `configRevisionHash`, and optional `appliedConfigHash` for the resolved revision accepted by the active Gateway runtime.
- `config.set` writes a validated config payload.
- `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.list[].skills`.
- `config.apply` validates + replaces the full config payload.
@@ -53,6 +53,8 @@ export const SnapshotSchema = closedObject({
health: HealthSnapshotSchema,
stateVersion: StateVersionSchema,
uptimeMs: Type.Integer({ minimum: 0 }),
/** Resolved source-config revision accepted by the active Gateway runtime. */
appliedConfigHash: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
configPath: Type.Optional(NonEmptyString),
stateDir: Type.Optional(NonEmptyString),
sessionDefaults: Type.Optional(SessionDefaultsSchema),
+1 -1
View File
@@ -612,7 +612,6 @@ function buildConfigMocks() {
},
},
},
// Second AI section so scoped pages demo the segmented section tabs.
models: {
type: "object",
title: "Models",
@@ -632,6 +631,7 @@ function buildConfigMocks() {
exists: true,
raw: `${JSON.stringify(config, null, 2)}\n`,
hash: "mock-config-hash",
appliedConfigHash: "mock-config-hash",
valid: true,
config,
issues: [],
+3
View File
@@ -32,9 +32,12 @@ export {
writeConfigFile,
} from "./io.js";
export {
getRuntimeConfigAppliedHash,
hashRuntimeConfigValue,
resolveConfigWriteAfterWrite,
resolveConfigWriteFollowUp,
setAppliedRuntimeConfigSnapshot,
setRuntimeConfigAppliedHash,
} from "./runtime-snapshot.js";
export type {
ConfigWriteAfterWrite,
+1 -1
View File
@@ -157,8 +157,8 @@ export {
setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler,
registerManagedRuntimeConfigWriteOwner,
};
export { setAppliedRuntimeConfigSnapshot } from "./runtime-snapshot.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
export { MissingEnvVarError } from "./env-substitution.js";
export { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";
+22
View File
@@ -2,6 +2,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
finalizeRuntimeSnapshotWrite,
getRuntimeConfigAppliedHash,
hashRuntimeConfigValue,
hasManagedRuntimeConfigWriteOwner,
getRuntimeConfigSnapshotMetadata,
getRuntimeConfigSourceSnapshot,
@@ -15,6 +17,7 @@ import {
resolveRuntimeConfigCacheKey,
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshot,
setRuntimeConfigAppliedHash,
setRuntimeConfigSnapshotRefreshHandler,
} from "./runtime-snapshot.js";
import type { OpenClawConfig } from "./types.js";
@@ -97,6 +100,25 @@ describe("runtime snapshot state", () => {
);
});
it("tracks the applied source revision independently from runtime fingerprints", () => {
expect(getRuntimeConfigAppliedHash()).toBeNull();
setRuntimeConfigAppliedHash("disk-hash-1");
setRuntimeConfigSnapshot({ gateway: { port: 18789 } });
expect(getRuntimeConfigAppliedHash()).toBe("disk-hash-1");
resetConfigRuntimeState();
expect(getRuntimeConfigAppliedHash()).toBeNull();
});
it("hashes resolved source content independently from root-file revision metadata", () => {
const first = hashRuntimeConfigValue({ logging: { level: "info" } });
const second = hashRuntimeConfigValue({ logging: { level: "debug" } });
expect(first).not.toBe(second);
expect(hashRuntimeConfigValue({ logging: { level: "info" } })).toBe(first);
});
it("selects runtime config only when input still matches the runtime source", () => {
const sourceConfig: OpenClawConfig = {
models: {
+19
View File
@@ -106,6 +106,7 @@ export type RuntimeConfigSnapshotMetadata = {
let runtimeConfigSnapshot: OpenClawConfig | null = null;
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
let runtimeConfigSnapshotMetadata: RuntimeConfigSnapshotMetadata | null = null;
let runtimeConfigAppliedHash: string | null = null;
let runtimeConfigSnapshotRevision = 0;
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
type ManagedRuntimeConfigWritePreflight = (
@@ -169,6 +170,14 @@ export function setRuntimeConfigSnapshot(
runtimeConfigSnapshotMetadata = createRuntimeConfigSnapshotMetadata(config, sourceConfig);
}
export function setAppliedRuntimeConfigSnapshot(
config: OpenClawConfig,
sourceConfig: OpenClawConfig,
): void {
setRuntimeConfigSnapshot(config, sourceConfig);
runtimeConfigAppliedHash = hashRuntimeConfigValue(sourceConfig);
}
/** Publish a newer canonical source without changing the active runtime object. */
export function setRuntimeConfigSourceSnapshotIfCurrent(params: {
expectedRevision: number;
@@ -189,6 +198,7 @@ export function resetConfigRuntimeState(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
runtimeConfigSnapshotMetadata = null;
runtimeConfigAppliedHash = null;
runtimeConfigSnapshotRevision = 0;
resetPublishedConfigRuntimeEnv();
}
@@ -209,6 +219,15 @@ export function getRuntimeConfigSnapshotMetadata(): RuntimeConfigSnapshotMetadat
return runtimeConfigSnapshotMetadata;
}
/** Resolved source-config revision accepted by the active Gateway runtime. */
export function getRuntimeConfigAppliedHash(): string | null {
return runtimeConfigAppliedHash;
}
export function setRuntimeConfigAppliedHash(hash: string | null): void {
runtimeConfigAppliedHash = hash;
}
export function resolveRuntimeConfigCacheKey(config: OpenClawConfig): string {
const metadata = runtimeConfigSnapshotMetadata;
if (metadata && config === runtimeConfigSnapshot) {
@@ -0,0 +1,27 @@
export function createAppliedConfigHashPublisher(options: {
hasPendingRestart: () => boolean;
publish: (hash: string) => void;
}) {
let deferredHash: string | null = null;
return {
hasOutstandingGatewayRestart: options.hasPendingRestart,
publishAppliedConfigHash: (hash: string) => {
// A hot-only edit can land behind restart debt. Keep the newest revision
// private until that debt retires; a replacement Gateway sets startup truth.
if (options.hasPendingRestart()) {
deferredHash = hash;
return;
}
deferredHash = null;
options.publish(hash);
},
publishDeferredAppliedConfigHash: () => {
if (deferredHash === null || options.hasPendingRestart()) {
return;
}
const hash = deferredHash;
deferredHash = null;
options.publish(hash);
},
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayReloadPlan } from "./config-reload-plan.js";
type AppliedCallback = (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
) => void | Promise<void>;
export function createConfigAppliedRevisionTracker(options: {
onConfigApplied?: AppliedCallback;
onRevisionApplied?: (hash: string) => void;
}) {
let pending: { plan: GatewayReloadPlan; hash: string } | null = null;
const flush = async (currentConfig: OpenClawConfig) => {
const owner = pending;
if (!owner) {
return;
}
await options.onConfigApplied?.(owner.plan, currentConfig);
// A superseding transaction runs later; this committed owner is runtime truth meanwhile.
options.onRevisionApplied?.(owner.hash);
if (pending === owner) {
pending = null;
}
};
return {
defer: (plan: GatewayReloadPlan, hash: string) => {
pending = { plan, hash };
},
flush,
apply: async (plan: GatewayReloadPlan, config: OpenClawConfig, hash: string) => {
if (pending?.plan === plan) {
await flush(config);
return;
}
await options.onConfigApplied?.(plan, config);
options.onRevisionApplied?.(hash);
},
};
}
+14
View File
@@ -0,0 +1,14 @@
import { redactConfigSnapshot } from "../config/redact-snapshot.js";
import { getRuntimeConfigAppliedHash, hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type { ConfigFileSnapshot } from "../config/types.openclaw.js";
export function createConfigGetResponse(
snapshot: ConfigFileSnapshot,
uiHints: Parameters<typeof redactConfigSnapshot>[1],
) {
return {
...redactConfigSnapshot(snapshot, uiHints),
configRevisionHash: hashRuntimeConfigValue(snapshot.sourceConfig),
appliedConfigHash: getRuntimeConfigAppliedHash(),
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { GatewayReloadPlan } from "./config-reload-plan.js";
export function shouldRefreshContextWindowCache(plan: GatewayReloadPlan): boolean {
return (
plan.reloadPlugins ||
plan.changedPaths.some(
(path) =>
path === "models" ||
path.startsWith("models.") ||
path === "agents" ||
path === "agents.defaults" ||
path === "agents.list" ||
path.startsWith("agents.list.") ||
path === "agents.defaults.workspace" ||
path.startsWith("agents.defaults.workspace."),
)
);
}
export function reloadPlanNeedsRecovery(plan: GatewayReloadPlan): boolean {
return (
plan.restartCron ||
plan.restartHealthMonitor ||
plan.restartGmailWatcher ||
plan.reloadPlugins ||
plan.restartChannels.size > 0 ||
shouldRefreshContextWindowCache(plan)
);
}
+14 -1
View File
@@ -9,6 +9,7 @@ import type {
ConfigWriteNotification,
OpenClawConfig,
} from "../config/config.js";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
pinActivePluginChannelRegistry,
@@ -577,6 +578,7 @@ function createReloaderHarness(
sourceConfig: OpenClawConfig,
) => Promise<() => Promise<void>>;
onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
onConfigRevisionApplied?: (hash: string) => void;
onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
onNoopConfigCommit?: (
plan: GatewayReloadPlan,
@@ -608,6 +610,7 @@ function createReloaderHarness(
(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}),
);
const onConfigAccepted = vi.fn(options.onConfigAccepted ?? (async () => {}));
const onConfigRevisionApplied = vi.fn(options.onConfigRevisionApplied ?? (() => {}));
const onEffectiveConfigUnchanged = vi.fn(
options.onEffectiveConfigUnchanged ?? (async () => async () => {}),
);
@@ -662,6 +665,7 @@ function createReloaderHarness(
: {}),
onConfigChange,
onConfigApplied,
onConfigRevisionApplied,
onConfigAccepted,
onEffectiveConfigUnchanged,
onNoopConfigCommit,
@@ -675,6 +679,7 @@ function createReloaderHarness(
watcher,
onConfigChange,
onConfigApplied,
onConfigRevisionApplied,
onConfigAccepted,
onEffectiveConfigUnchanged,
onNoopConfigCommit,
@@ -766,6 +771,9 @@ describe("startGatewayConfigReloader", () => {
expect(harness.onConfigAccepted).toHaveBeenCalledOnce();
expect(harness.onConfigApplied).not.toHaveBeenCalled();
expect(harness.onConfigRevisionApplied).toHaveBeenCalledWith(
hashRuntimeConfigValue(initialConfig),
);
expect(harness.onHotReload).not.toHaveBeenCalled();
expect(harness.onRestart).not.toHaveBeenCalled();
await harness.reloader.stop();
@@ -900,6 +908,7 @@ describe("startGatewayConfigReloader", () => {
events.push("applied");
terminalPolicy.commitConfig();
},
onConfigRevisionApplied: () => events.push("revision-applied"),
onConfigAccepted: () => {
events.push("accepted");
terminalPolicy.acceptConfig({ retireRejectedRestart: false });
@@ -918,7 +927,7 @@ describe("startGatewayConfigReloader", () => {
});
await vi.runAllTimersAsync();
expect(events).toEqual(["applied", "accepted"]);
expect(events).toEqual(["applied", "revision-applied", "accepted"]);
expect(terminalPolicy.resolve()).toMatchObject({
ok: false,
block: { kind: "sandboxed", mode: "all" },
@@ -1797,6 +1806,9 @@ describe("startGatewayConfigReloader", () => {
expect(harness.onHotReload.mock.invocationCallOrder[0]).toBeLessThan(
harness.onConfigApplied.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
expect(harness.onConfigRevisionApplied).toHaveBeenCalledWith(
hashRuntimeConfigValue(nextConfig),
);
await harness.reloader.stop();
});
@@ -1816,6 +1828,7 @@ describe("startGatewayConfigReloader", () => {
expect(harness.onConfigChange).toHaveBeenCalledTimes(1);
expect(harness.onConfigApplied).not.toHaveBeenCalled();
expect(harness.onConfigRevisionApplied).not.toHaveBeenCalled();
expect(harness.onRestart).toHaveBeenCalledTimes(1);
expect(harness.onConfigChange.mock.invocationCallOrder[0]).toBeLessThan(
harness.onRestart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
+19 -31
View File
@@ -4,7 +4,7 @@ import chokidar from "chokidar";
import type { ConfigRuntimeEnvPublication } from "../config/config-env-vars.js";
import type { ConfigWriteNotification } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js";
import { hashRuntimeConfigValue, resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js";
import type { RuntimeConfigSnapshotRefreshOptions } from "../config/runtime-snapshot.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
@@ -13,6 +13,7 @@ import {
loadInstalledPluginIndexInstallRecordsSync,
} from "../plugins/installed-plugin-index-records.js";
import { bumpSkillsSnapshotVersion } from "../skills/runtime/refresh-state.js";
import { createConfigAppliedRevisionTracker } from "./config-applied-revision.js";
import { diffConfigPaths, diffGatewayReloadPaths } from "./config-diff.js";
import {
buildGatewayReloadPlan,
@@ -154,6 +155,8 @@ export function startGatewayConfigReloader(opts: {
onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
/** Publishes runtime state after a hot or no-op config transaction. */
onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
/** Publishes the resolved source-config revision accepted by the active runtime. */
onConfigRevisionApplied?: (hash: string) => void;
/** Retires rejected lifecycle work after any newer config transaction is accepted. */
onConfigAccepted?: (
nextConfig: OpenClawConfig,
@@ -233,31 +236,14 @@ export function startGatewayConfigReloader(opts: {
let lastSourceOnlyRuntimeRefresh: RuntimeConfigSnapshotRefreshOptions | undefined;
let lastSourceOnlyRuntimeConfig: OpenClawConfig | null = null;
let lastSourceOnlySourceConfig: OpenClawConfig | null = null;
let pendingRuntimeApplicationPlan: GatewayReloadPlan | null = null;
let currentPluginInstallRecords =
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
const readPluginInstallRecords =
opts.readPluginInstallRecords ?? loadInstalledPluginIndexInstallRecords;
const flushPendingRuntimeApplication = async () => {
const pendingPlan = pendingRuntimeApplicationPlan;
if (!pendingPlan) {
return;
}
await opts.onConfigApplied?.(pendingPlan, currentConfig);
if (pendingRuntimeApplicationPlan === pendingPlan) {
pendingRuntimeApplicationPlan = null;
}
};
const applyCurrentRuntimePlan = async (
plan: GatewayReloadPlan,
nextRuntimeConfig: OpenClawConfig,
) => {
if (pendingRuntimeApplicationPlan === plan) {
await flushPendingRuntimeApplication();
return;
}
await opts.onConfigApplied?.(plan, nextRuntimeConfig);
};
const appliedRevision = createConfigAppliedRevisionTracker({
onConfigApplied: opts.onConfigApplied,
onRevisionApplied: opts.onConfigRevisionApplied,
});
const scheduleAfter = (wait: number) => {
if (stopped) {
@@ -342,6 +328,7 @@ export function startGatewayConfigReloader(opts: {
}) ?? preflightCandidate;
const nextConfig = preparedCandidate?.runtimeConfig ?? candidateRuntimeConfig;
const nextCompareConfig = preparedCandidate?.compareConfig ?? nextSourceConfig;
const nextConfigRevisionHash = hashRuntimeConfigValue(nextSourceConfig);
let nextPluginInstallRecords = currentPluginInstallRecords;
let committedRuntimeConfig: OpenClawConfig | null = null;
let publishedRuntimeEnv: ConfigRuntimeEnvPublication | undefined;
@@ -393,7 +380,7 @@ export function startGatewayConfigReloader(opts: {
currentRuntimeRefresh = ownership.runtimeRefresh;
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(runtimeConfig);
pendingRuntimeApplicationPlan = plan;
appliedRevision.defer(plan, nextConfigRevisionHash);
},
};
const configChangedPaths = diffGatewayReloadPaths(currentCompareConfig, nextCompareConfig);
@@ -437,7 +424,7 @@ export function startGatewayConfigReloader(opts: {
// Publication can be superseded after its runtime commit but before its
// lifecycle owner is applied. Finish that owner before the next candidate
// prepares state that acceptance or restart policy may discard.
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
assertCurrent();
const commitReloadBaseline = async (
options: {
@@ -449,7 +436,7 @@ export function startGatewayConfigReloader(opts: {
// A prior transaction may publish runtime state immediately before a
// newer write supersedes it. Commit that runtime owner before accepting
// a baseline-only candidate, which can discard prepared lifecycle state.
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
assertCurrent();
let rollbackAcceptedSource: (() => Promise<void>) | undefined;
try {
@@ -517,6 +504,7 @@ export function startGatewayConfigReloader(opts: {
))
: undefined;
await commitReloadBaseline(publishSource ? { publishSource } : {});
opts.onConfigRevisionApplied?.(nextConfigRevisionHash);
return;
}
@@ -552,7 +540,7 @@ export function startGatewayConfigReloader(opts: {
// marking applied so getRuntimeConfig() readers do not stay stale until restart.
await opts.onNoopConfigCommit(plan, nextConfig, ownership, nextSourceConfig);
assertCurrent();
await applyCurrentRuntimePlan(plan, nextConfig);
await appliedRevision.apply(plan, nextConfig, nextConfigRevisionHash);
await commitReloadBaseline();
return;
}
@@ -598,7 +586,7 @@ export function startGatewayConfigReloader(opts: {
throw error;
}
assertCurrent();
await applyCurrentRuntimePlan(plan, nextConfig);
await appliedRevision.apply(plan, nextConfig, nextConfigRevisionHash);
await commitReloadBaseline();
};
@@ -632,7 +620,7 @@ export function startGatewayConfigReloader(opts: {
markRuntimeCommitted: () => {},
};
await runAcceptedTransaction(async () => {
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
@@ -721,7 +709,7 @@ export function startGatewayConfigReloader(opts: {
throw new GatewayConfigReloadSupersededError();
}
if (handleMissingSnapshot(snapshot)) {
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
return;
}
if (startupInternalWriteHash && typeof snapshot.hash === "string") {
@@ -797,7 +785,7 @@ export function startGatewayConfigReloader(opts: {
markRuntimeCommitted: () => {},
};
await runAcceptedTransaction(async () => {
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
@@ -820,7 +808,7 @@ export function startGatewayConfigReloader(opts: {
}
if (!snapshot.valid) {
handleInvalidSnapshot(snapshot);
await flushPendingRuntimeApplication();
await appliedRevision.flush(currentConfig);
return;
}
await runAcceptedTransaction(async () => {
+4 -8
View File
@@ -1,5 +1,4 @@
// Config gateway methods expose config get/set/patch/apply/schema operations
// with validation, redaction restoration, secret prep, and reload planning.
// Config gateway methods: validation, redaction, secrets, reload planning.
import { isDeepStrictEqual } from "node:util";
import {
asDateTimestampMs,
@@ -31,11 +30,7 @@ import { createMergePatch, projectSourceOntoRuntimeShape } from "../../config/io
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { applyMergePatch, isMergePatchObjectKeyAllowed } from "../../config/merge-patch.js";
import { normalizeConfigPatchReplacePaths } from "../../config/patch-replace-paths.js";
import {
redactConfigObject,
redactConfigSnapshot,
restoreRedactedValues,
} from "../../config/redact-snapshot.js";
import { redactConfigObject, restoreRedactedValues } from "../../config/redact-snapshot.js";
import { loadGatewayRuntimeConfigSchema } from "../../config/runtime-schema.js";
import { lookupConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js";
@@ -52,6 +47,7 @@ import {
type PreparedSecretsRuntimeSnapshot,
} from "../../secrets/runtime.js";
import { diffConfigPaths } from "../config-diff.js";
import { createConfigGetResponse } from "../config-get-response.js";
import { resolveConfigReloadMetadata } from "../config-reload-plan.js";
import {
formatControlPlaneActor,
@@ -691,7 +687,7 @@ export const configHandlers: GatewayRequestHandlers = {
}
const snapshot = await readConfigFileSnapshot();
const schema = loadSchemaWithPlugins();
respond(true, redactConfigSnapshot(snapshot, schema.uiHints), undefined);
respond(true, createConfigGetResponse(snapshot, schema.uiHints), undefined);
},
"config.schema": ({ params, respond }) => {
if (!assertValidParams(params, validateConfigSchemaParams, "config.schema", respond)) {
@@ -1940,6 +1940,40 @@ describe("gateway restart deferral preflight", () => {
}
});
it("reports restart debt until a replacement config retires it", () => {
const requestRecoveryRestart = vi
.fn<NonNullable<ReloadHandlerParams["requestRecoveryRestart"]>>()
.mockReturnValue({ status: "failed" });
const handlers = createReloadHandlersForTest(
undefined,
undefined,
undefined,
undefined,
requestRecoveryRestart,
);
const restartPlan = {
...createHotTailPlan(),
changedPaths: ["gateway.port"],
restartGateway: true,
restartReasons: ["gateway.port"],
hotReasons: [],
} satisfies GatewayReloadPlan;
try {
expect(handlers.hasOutstandingGatewayRestart()).toBe(false);
const restart = handlers.requestGatewayRestart(restartPlan, {
gateway: { port: 19_001 },
});
restart.settle("rejected");
expect(handlers.hasOutstandingGatewayRestart()).toBe(true);
expect(handlers.acceptRestartConfig({})).toEqual({ retireRejectedRestart: true });
expect(handlers.hasOutstandingGatewayRestart()).toBe(false);
} finally {
handlers.stopRestartRetries();
}
});
it("preserves deferred hot-recovery debt across unrelated accepted config changes", async () => {
const requestRecoveryRestart = vi.fn<
NonNullable<ReloadHandlerParams["requestRecoveryRestart"]>
@@ -2065,6 +2099,7 @@ describe("gateway restart deferral preflight", () => {
acceptRestartConfig,
applyHotReload,
beginGatewayRestartLifecycle,
hasOutstandingGatewayRestart,
pauseGatewayRestartForConfigCandidate,
requestGatewayRestart,
stopRestartRetries,
@@ -2113,6 +2148,7 @@ describe("gateway restart deferral preflight", () => {
pauseGatewayRestartForConfigCandidate();
const accepted = acceptRestartConfig(configA);
expect(accepted).toEqual({ retireRejectedRestart: true });
expect(hasOutstandingGatewayRestart()).toBe(false);
} finally {
hoisted.activeTaskBlockers.length = 0;
stopRestartRetries();
+21 -29
View File
@@ -17,6 +17,7 @@ import { getConfigValueAtPath } from "../config/config-paths.js";
import {
getRuntimeConfigSnapshotMetadata,
getRuntimeConfigSourceSnapshot,
setRuntimeConfigAppliedHash,
} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isSecretRef } from "../config/types.secrets.js";
@@ -47,8 +48,13 @@ import {
import { getInspectableActiveTaskRestartBlockers } from "../tasks/task-registry.maintenance.js";
import { formatActiveTaskRestartBlocker } from "../tasks/task-restart-blocker.js";
import { isRecord } from "../utils.js";
import { createAppliedConfigHashPublisher } from "./applied-config-hash-publisher.js";
import type { ChannelHealthMonitor } from "./channel-health-monitor.js";
import type { ChannelKind } from "./config-reload-plan.js";
import {
reloadPlanNeedsRecovery,
shouldRefreshContextWindowCache,
} from "./config-reload-recovery.js";
import {
startGatewayConfigReloader,
type GatewayConfigReloadTransactionOwnership,
@@ -262,33 +268,6 @@ function resetPreparedModelRuntimeStateForHotReload(): void {
markGatewayModelCatalogStaleForReload();
}
function shouldRefreshContextWindowCache(plan: GatewayReloadPlan): boolean {
return (
plan.reloadPlugins ||
plan.changedPaths.some(
(path) =>
path === "models" ||
path.startsWith("models.") ||
path === "agents" ||
path === "agents.defaults" ||
path === "agents.list" ||
path.startsWith("agents.list.") ||
path === "agents.defaults.workspace" ||
path.startsWith("agents.defaults.workspace."),
)
);
}
function hasIrreversibleHotReloadWork(plan: GatewayReloadPlan): boolean {
return (
plan.restartCron ||
plan.restartHealthMonitor ||
plan.restartGmailWatcher ||
plan.reloadPlugins ||
plan.restartChannels.size > 0
);
}
function assertIrreversibleReloadPlanHasRecoveryOwner(
plan: GatewayReloadPlan,
restartRecoveryAvailable: boolean | undefined,
@@ -302,7 +281,7 @@ function assertIrreversibleReloadPlanHasRecoveryOwner(
// These plans retire a live service or plugin generation before replacement
// can be proven. Context cache refresh also needs recovery because it can
// reject after runtime publication; simple in-place updates stay atomic.
if (hasIrreversibleHotReloadWork(plan) || shouldRefreshContextWindowCache(plan)) {
if (reloadPlanNeedsRecovery(plan)) {
throw new GatewayReloadRequiresRecoveryOwnerError("irreversible hot reload");
}
}
@@ -1207,6 +1186,14 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
supersedeRestartRequest();
};
const appliedConfigHashPublisher = createAppliedConfigHashPublisher({
hasPendingRestart: () =>
restartRequestDetails !== null ||
pausedRestartDebt !== null ||
conservativeRestartDebt !== null,
publish: setRuntimeConfigAppliedHash,
});
const scheduleRestartEmissionRetry = (retry: {
reason: string;
intent?: GatewayRestartIntent;
@@ -1531,6 +1518,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
return {
applyHotReload,
acceptRestartConfig,
...appliedConfigHashPublisher,
beginGatewayRestartLifecycle,
pauseGatewayRestartForConfigCandidate,
publishAcceptedRestartTarget,
@@ -1584,7 +1572,9 @@ export function startManagedGatewayConfigReloader(
acceptRestartConfig,
beginGatewayRestartLifecycle,
pauseGatewayRestartForConfigCandidate,
publishAppliedConfigHash,
publishAcceptedRestartTarget,
publishDeferredAppliedConfigHash,
recordAcceptedRestartTarget,
requestGatewayRestart,
restoreConservativeRestartDebt,
@@ -1621,7 +1611,6 @@ export function startManagedGatewayConfigReloader(
channelManager: params.channelManager,
}),
});
const runManagedRestart = async (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
@@ -1819,6 +1808,7 @@ export function startManagedGatewayConfigReloader(
params.acceptTerminalConfig({
retireRejectedRestart: acceptedRestart.retireRejectedRestart,
});
publishDeferredAppliedConfigHash();
return undefined;
}
if (acceptedRestart.debt) {
@@ -1858,6 +1848,7 @@ export function startManagedGatewayConfigReloader(
params.acceptTerminalConfig({
retireRejectedRestart: acceptedRestart.retireRejectedRestart && !lateConservativeDebt,
});
publishDeferredAppliedConfigHash();
return rollbackSource;
} catch (error) {
if (lateConservativeDebt) {
@@ -1869,6 +1860,7 @@ export function startManagedGatewayConfigReloader(
}
},
onConfigApplied: (_plan, nextConfig) => params.commitTerminalConfig(nextConfig),
onConfigRevisionApplied: publishAppliedConfigHash,
onEffectiveConfigUnchanged: async (nextConfig, transactionOwnership, sourceConfig) => {
if (!transactionOwnership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
+12
View File
@@ -179,6 +179,18 @@ beforeEach(() => {
});
describe("gateway config methods", () => {
it("includes the active runtime config revision", async () => {
const current = await rpcReq<{
hash?: string;
configRevisionHash?: string;
appliedConfigHash?: string | null;
}>(requireWs(), "config.get", {});
expect(current.ok).toBe(true);
expect(current.payload).toHaveProperty("configRevisionHash");
expect(current.payload).toHaveProperty("appliedConfigHash");
});
it("rejects config.set when SecretRef resolution fails", async () => {
const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_${Date.now()}`;
deleteTestEnvValue(missingEnvVar);
+2 -2
View File
@@ -28,7 +28,7 @@ import {
readConfigFileSnapshot,
readConfigFileSnapshotForRuntimeTransaction,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
setAppliedRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../config/io.js";
import { isNixMode, normalizeStateDirEnv } from "../config/paths.js";
@@ -841,7 +841,7 @@ export async function startGatewayServer(
startupInternalWriteHash = startupSnapshot.hash ?? null;
startupLastGoodSnapshot = startupSnapshot;
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
setAppliedRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
initializePublishedConfigRuntimeEnv(startupLastGoodSnapshot.sourceConfig, {
ownedEnv: collectConfigRuntimeEnvOwnership(
startupLastGoodSnapshot.sourceConfig,
+2
View File
@@ -4,6 +4,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js";
import { createConfigIO, getRuntimeConfig } from "../../config/io.js";
import { STATE_DIR } from "../../config/paths.js";
import { getRuntimeConfigAppliedHash } from "../../config/runtime-snapshot.js";
import { resolveMainSessionKey } from "../../config/sessions.js";
import { listSystemPresence } from "../../infra/system-presence.js";
import { getUpdateAvailable } from "../../infra/update-startup.js";
@@ -36,6 +37,7 @@ export function buildGatewaySnapshot(opts?: { includeSensitive?: boolean }): Sna
health: emptyHealth,
stateVersion: { presence: presenceVersion, health: healthVersion },
uptimeMs,
appliedConfigHash: getRuntimeConfigAppliedHash(),
sessionDefaults: {
defaultAgentId,
mainKey,
+3 -5
View File
@@ -1,4 +1,3 @@
// Control UI type declarations define types contracts.
export type UpdateAvailable = import("../../../src/infra/update-startup.js").UpdateAvailable;
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SessionGoal } from "../../../src/config/sessions/types.js";
@@ -264,16 +263,15 @@ export type NostrStatus = {
profile?: NostrProfile | null;
};
type ConfigSnapshotIssue = {
path: string;
message: string;
};
type ConfigSnapshotIssue = { path: string; message: string };
export type ConfigSnapshot = {
path?: string | null;
exists?: boolean | null;
raw?: string | null;
hash?: string | null;
configRevisionHash?: string | null;
appliedConfigHash?: string | null;
parsed?: unknown;
valid?: boolean | null;
sourceConfig?: Record<string, unknown> | null;
@@ -79,7 +79,6 @@ describeControlUiE2e("Control UI Quick Config thinking persistence mocked Gatewa
expect(await lowButton.getAttribute("aria-checked")).toBe("true");
await modelCard.getByRole("radio", { name: "High", exact: true }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
const raw = requestRaw(await gateway.waitForRequest("config.set"));
expect(raw).toEqual({
+49
View File
@@ -0,0 +1,49 @@
const REFRESH_DELAYS_MS = [250, 750, 1_500, 3_000, 6_000, 30_000] as const;
export function createAppliedConfigRefreshController(options: {
shouldRefresh: () => boolean;
refresh: (isCurrent: () => boolean) => Promise<unknown>;
}) {
let timer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let generation = 0;
let disposed = false;
const cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
attempt = 0;
generation += 1;
};
const reconcile = () => {
if (disposed || !options.shouldRefresh()) {
cancel();
return;
}
if (timer) {
return;
}
const delay = REFRESH_DELAYS_MS[Math.min(attempt, REFRESH_DELAYS_MS.length - 1)];
timer = setTimeout(() => {
timer = null;
const refreshGeneration = generation;
attempt = Math.min(attempt + 1, REFRESH_DELAYS_MS.length - 1);
void options
.refresh(() => refreshGeneration === generation)
.then(
() => refreshGeneration === generation && reconcile(),
() => refreshGeneration === generation && reconcile(),
);
}, delay);
};
return {
cancel,
reconcile,
dispose: () => {
disposed = true;
cancel();
},
};
}
+251 -53
View File
@@ -46,6 +46,7 @@ afterEach(() => {
/** Simple hash-tracking config.get/config.set/config.apply mock gateway. */
function createConfigServerMock() {
let hashCounter = 1;
let appliedHash = "hash-1";
let storedRaw = '{\n "count": 1\n}\n';
const submissions: Array<{ method: string; raw: string; baseHash: string }> = [];
const request = vi.fn(async (method: string, params?: unknown) => {
@@ -54,6 +55,8 @@ function createConfigServerMock() {
config: JSON.parse(storedRaw) as Record<string, unknown>,
raw: storedRaw,
hash: `hash-${hashCounter}`,
configRevisionHash: `hash-${hashCounter}`,
appliedConfigHash: appliedHash,
valid: true,
issues: [],
};
@@ -63,6 +66,9 @@ function createConfigServerMock() {
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}` };
}
@@ -111,20 +117,6 @@ function createDeferredSetServerMock(options: { legacyAck?: boolean } = {}) {
return { request, submissions, applySubmissions, firstSet };
}
/** Map-backed localStorage stub; node/jsdom test envs lack a stable one. */
function stubLocalStorage(): Map<string, string> {
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: () => null,
length: 0,
});
return store;
}
describe("createRuntimeConfigCapability", () => {
it("preserves a dirty draft and its original base hash across refreshes", async () => {
let getCount = 0;
@@ -449,7 +441,6 @@ describe("config form auto-save", () => {
it("clears needsApply only on apply; a discarding refresh keeps the banner", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
const server = createConfigServerMock();
const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -469,13 +460,11 @@ describe("config form auto-save", () => {
expect(runtimeConfig.state.configNeedsApply).toBe(false);
expect(runtimeConfig.state.configAutoSaveStatus).toBe("idle");
expect(server.submissions.at(-1)?.method).toBe("config.apply");
expect(store.size).toBe(0);
runtimeConfig.dispose();
});
it("persists needsApply across capability recreation keyed to the saved hash", async () => {
it("derives needsApply across capability recreation from Gateway revision truth", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
const server = createConfigServerMock();
const first = createHarness(server.request as GatewayBrowserClient["request"]);
await first.runtimeConfig.ensureLoaded();
@@ -483,38 +472,231 @@ describe("config form auto-save", () => {
first.runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(first.runtimeConfig.state.configNeedsApply).toBe(true);
expect([...store.values()]).toEqual([server.currentHash()]);
first.runtimeConfig.dispose();
// A fresh capability (page reload) re-derives the banner from storage.
// A fresh capability compares the persisted and applied revisions.
const second = createHarness(server.request as GatewayBrowserClient["request"]);
await second.runtimeConfig.ensureLoaded();
expect(second.runtimeConfig.state.configNeedsApply).toBe(true);
await expect(second.runtimeConfig.apply()).resolves.toBe(true);
expect(second.runtimeConfig.state.configNeedsApply).toBe(false);
expect(store.size).toBe(0);
second.runtimeConfig.dispose();
// After apply cleared the record, a third load shows no banner.
// After apply advances runtime truth, a third load shows no banner.
const third = createHarness(server.request as GatewayBrowserClient["request"]);
await third.runtimeConfig.ensureLoaded();
expect(third.runtimeConfig.state.configNeedsApply).toBe(false);
third.runtimeConfig.dispose();
});
it("drops the persisted banner when the config hash moved out from under it", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
store.set("openclaw.config.needsApplyHash.v1", "hash-from-another-life");
const server = createConfigServerMock();
const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]);
it("does not invent needsApply when an older Gateway omits the applied hash", async () => {
const request = vi.fn(async (method: string) =>
method === "config.get" ? { config: {}, hash: "hash-1", valid: true, issues: [] } : {},
);
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(false);
// The mismatched record is deleted so a hash that cycles back to this
// value later cannot resurrect the stale banner.
expect(store.size).toBe(0);
runtimeConfig.dispose();
});
it("preserves process-local needsApply after saving through an older Gateway", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
valid: true,
issues: [],
};
}
return method === "config.set" ? { hash: "hash-2" } : {};
});
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("treats a missing current config as drift from an applied revision", async () => {
const request = vi.fn(async (method: string) =>
method === "config.get"
? {
config: {},
hash: null,
configRevisionHash: null,
appliedConfigHash: "applied-hash",
valid: true,
issues: [],
}
: {},
);
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("refreshes until a hot-reloaded revision becomes active", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "config.get") {
return {};
}
getCount += 1;
return {
config: { count: 2 },
raw: '{"count":2}',
hash: "raw-hash-2",
configRevisionHash: "revision-2",
appliedConfigHash: getCount >= 2 ? "revision-2" : "revision-1",
valid: true,
issues: [],
};
});
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("continues mismatch polling after a transient config.get failure", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "config.get") {
return {};
}
getCount += 1;
if (getCount === 2) {
throw new Error("gateway restarting");
}
return {
config: { count: 2 },
raw: '{"count":2}',
hash: "raw-hash-2",
configRevisionHash: "revision-2",
appliedConfigHash: getCount >= 3 ? "revision-2" : "revision-1",
valid: true,
issues: [],
};
});
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(750);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
runtimeConfig.dispose();
});
it("discards an applied-hash poll superseded by a config write", async () => {
vi.useFakeTimers();
const stalePoll = deferred<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 } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
stalePoll.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-1",
valid: true,
issues: [],
});
await vi.advanceTimersByTimeAsync(0);
expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2");
expect(runtimeConfig.state.configNeedsApply).toBe(true);
runtimeConfig.dispose();
});
it("does not re-arm an invalidated applied-hash poll during config.patch", async () => {
vi.useFakeTimers();
const stalePoll = deferred<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 } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
await vi.advanceTimersByTimeAsync(250);
const patchPromise = runtimeConfig.patch({ raw: { count: 2 }, note: "test patch" });
await vi.advanceTimersByTimeAsync(0);
stalePoll.resolve({
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: "hash-1",
configRevisionHash: "revision-1",
appliedConfigHash: "revision-1",
valid: true,
issues: [],
});
await vi.advanceTimersByTimeAsync(1_000);
expect(getCount).toBe(2);
patchGate.resolve({ hash: "hash-2" });
await vi.advanceTimersByTimeAsync(0);
await expect(patchPromise).resolves.toBe(true);
runtimeConfig.dispose();
});
@@ -621,7 +803,6 @@ describe("config form auto-save", () => {
it("flushes a dirty draft once on dispose instead of dropping it", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
const server = createConfigServerMock();
const { runtimeConfig } = createHarness(server.request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -634,9 +815,6 @@ describe("config form auto-save", () => {
]);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS * 4);
expect(server.submissions).toHaveLength(1);
// The ack hash lands in the restart marker even though the disposed
// capability can no longer touch its own state.
expect([...store.values()]).toEqual(["hash-2"]);
});
it("does not flush clean or raw drafts on dispose", async () => {
@@ -761,9 +939,8 @@ describe("config form auto-save", () => {
runtimeConfig.dispose();
});
it("persists the restart marker from the ack even when the reload fails", async () => {
it("keeps process-local needsApply when the post-save reload fails", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
let failReloads = false;
let hashCounter = 1;
const request = vi.fn(async (method: string) => {
@@ -775,6 +952,8 @@ describe("config form auto-save", () => {
config: { count: 1 },
raw: '{\n "count": 1\n}\n',
hash: `hash-${hashCounter}`,
configRevisionHash: `hash-${hashCounter}`,
appliedConfigHash: "hash-1",
valid: true,
issues: [],
};
@@ -792,8 +971,6 @@ describe("config form auto-save", () => {
first.runtimeConfig.patchForm(["count"], 2);
await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS);
// The ack carried the persisted hash; no reload was needed for the marker.
expect([...store.values()]).toEqual(["hash-2"]);
expect(first.runtimeConfig.state.configNeedsApply).toBe(true);
first.runtimeConfig.dispose();
@@ -873,7 +1050,6 @@ describe("config form auto-save", () => {
it("chains one final save when disposed mid-flight with a newer edit", async () => {
vi.useFakeTimers();
stubLocalStorage();
const { request, submissions, firstSet } = createDeferredSetServerMock();
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -894,7 +1070,6 @@ describe("config form auto-save", () => {
it("does not chain an extra save when disposed mid-flight without newer edits", async () => {
vi.useFakeTimers();
stubLocalStorage();
const { request, submissions, firstSet } = createDeferredSetServerMock();
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -1190,7 +1365,6 @@ describe("config form auto-save", () => {
it("skips the teardown flush when the settled flight acked without a hash", async () => {
vi.useFakeTimers();
stubLocalStorage();
const { request, submissions, firstSet } = createDeferredSetServerMock({ legacyAck: true });
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -1325,6 +1499,39 @@ describe("config form auto-save", () => {
runtimeConfig.dispose();
});
it("refreshes applied revision truth after config.patch", async () => {
vi.useFakeTimers();
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.get") {
getCount += 1;
const revision = getCount === 1 ? "revision-1" : "revision-2";
return {
config: { count: getCount },
raw: `{\n "count": ${getCount}\n}\n`,
hash: `hash-${getCount}`,
configRevisionHash: revision,
appliedConfigHash: revision,
valid: true,
issues: [],
};
}
return method === "config.patch" ? { hash: "hash-2" } : {};
});
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
await expect(runtimeConfig.patch({ raw: { count: 2 }, note: "test patch" })).resolves.toBe(
true,
);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(runtimeConfig.state.configNeedsApply).toBe(false);
expect(runtimeConfig.state.configSnapshot?.configRevisionHash).toBe("revision-2");
runtimeConfig.dispose();
});
it("flushes a pre-ack revert during disposal", async () => {
vi.useFakeTimers();
const { request, submissions, firstSet } = createDeferredSetServerMock();
@@ -1538,7 +1745,6 @@ describe("config form auto-save", () => {
it("recovers a manual save whose ack was lost to a disconnect", async () => {
vi.useFakeTimers();
stubLocalStorage();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
const sets: Array<{ raw: string; baseHash: string }> = [];
@@ -1579,7 +1785,7 @@ describe("config form auto-save", () => {
// The reconnect reload recognizes the committed bytes as ours even
// though the ack (and its manualFlightInfo hash) never arrived: the
// restart marker survives instead of silently disappearing.
// process-local pending state survives instead of silently disappearing.
expect(runtimeConfig.state.configNeedsApply).toBe(true);
// …and the still-dirty draft retries against the committed hash.
@@ -1592,7 +1798,6 @@ describe("config form auto-save", () => {
it("retries reconciliation on the next reconnect when the reload fails", async () => {
vi.useFakeTimers();
stubLocalStorage();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
let failNextGet = false;
@@ -1651,7 +1856,6 @@ describe("config form auto-save", () => {
it("restores a revert made while the interrupted write was in flight", async () => {
vi.useFakeTimers();
stubLocalStorage();
let committedRaw = '{\n "count": 1\n}\n';
let hash = "hash-1";
const sets: Array<{ raw: string; baseHash: string }> = [];
@@ -1702,9 +1906,8 @@ describe("config form auto-save", () => {
runtimeConfig.dispose();
});
it("persists the restart marker recovered from a hashless ack reload", async () => {
it("keeps process-local needsApply after a legacy hashless ack reload", async () => {
vi.useFakeTimers();
const store = stubLocalStorage();
const { request, firstSet, submissions } = createDeferredSetServerMock({ legacyAck: true });
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -1715,9 +1918,6 @@ describe("config form auto-save", () => {
expect(submissions).toHaveLength(1);
expect(runtimeConfig.state.configNeedsApply).toBe(true);
// The reload's authoritative hash stands in for the ack the legacy
// gateway never sent; without it a page reload drops the banner.
expect(store.size).toBe(1);
runtimeConfig.dispose();
});
@@ -1839,7 +2039,6 @@ describe("config form auto-save", () => {
it("chains the teardown flush behind a pending manual save", async () => {
vi.useFakeTimers();
stubLocalStorage();
const { request, submissions, firstSet } = createDeferredSetServerMock();
const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]);
await runtimeConfig.ensureLoaded();
@@ -1865,7 +2064,6 @@ describe("config form auto-save", () => {
it("skips the teardown flush behind a pending apply", async () => {
vi.useFakeTimers();
stubLocalStorage();
const firstApply = deferred<unknown>();
let setCalls = 0;
const request = vi.fn((method: string) => {
+118 -133
View File
@@ -4,7 +4,6 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
import { t } from "../../i18n/index.ts";
import { getSafeLocalStorage } from "../../local-storage.ts";
import { copyToClipboard } from "../clipboard.ts";
import {
cloneConfigObject,
@@ -13,58 +12,19 @@ import {
serializeConfigForm,
setPathValue,
} from "../config-form-utils.ts";
import { createAppliedConfigRefreshController } from "./applied-refresh.ts";
export type ConfigAutoSaveStatus = "idle" | "saving" | "saved" | "error" | "conflict";
/** Debounce window between the last form edit and its automatic config.set. */
const CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS = 800;
/**
* localStorage key recording the config hash of the last successful
* config.set that has not been applied yet. Keyed to the saved hash so the
* restart banner survives page reloads and capability recreation, and clears
* itself when the file changes out from under us.
*
* ACCEPTED LIMITATION (do not build versioned cross-tab protocols here): the
* marker is a single shared slot, so multiple tabs racing saves/applies or
* out-of-band file edits, or a gateway restart that leaves the file
* untouched can wrongly clear or keep it. The banner is advisory only;
* actual writes stay CAS-protected by the gateway's baseHash guard, so the
* worst case is a stale or missing restart hint. A gateway-reported
* appliedConfigHash (protocol follow-up) replaces this heuristic entirely.
*/
const CONFIG_NEEDS_APPLY_STORAGE_KEY = "openclaw.config.needsApplyHash.v1";
/** Reads the additive ack hash from a config.set/config.apply response. */
function readAckHash(ack: unknown): string | null {
const hash = (ack as { hash?: unknown } | null | undefined)?.hash;
return typeof hash === "string" && hash.length > 0 ? hash : null;
}
function readStoredNeedsApplyHash(): string | null {
try {
return getSafeLocalStorage()?.getItem(CONFIG_NEEDS_APPLY_STORAGE_KEY) ?? null;
} catch {
return null;
}
}
function storeNeedsApplyHash(hash: string | null): void {
try {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
if (hash) {
storage.setItem(CONFIG_NEEDS_APPLY_STORAGE_KEY, hash);
} else {
storage.removeItem(CONFIG_NEEDS_APPLY_STORAGE_KEY);
}
} catch {
// Storage-disabled contexts fall back to process-local banner state.
}
}
/**
* Gateway contract: requireConfigBaseHash in
* src/gateway/server-methods/config.ts rejects writes whose baseHash no
@@ -89,7 +49,7 @@ type ConfigState = {
configSaving: boolean;
configApplying: boolean;
configAutoSaveStatus: ConfigAutoSaveStatus;
/** True after a successful config.set until config.apply restarts the gateway. */
/** True when the config file revision differs from the active Gateway runtime. */
configNeedsApply: boolean;
configSnapshot: ConfigSnapshot | null;
configDraftBaseHash?: string | null;
@@ -172,7 +132,12 @@ type ConfigConnectionState = {
type ConfigGatewayState = Pick<
ConfigState,
"connected" | "applySessionKey" | "configSnapshot" | "lastError" | "chatError"
| "connected"
| "applySessionKey"
| "configNeedsApply"
| "configSnapshot"
| "lastError"
| "chatError"
> & {
client: ConfigGatewayClient | null;
};
@@ -249,7 +214,11 @@ function isCurrentRequest(
}
/** Resolves true only when a current-epoch snapshot was actually applied. */
async function loadConfig(state: ConfigState, options: LoadConfigOptions = {}): Promise<boolean> {
async function loadConfig(
state: ConfigState,
options: LoadConfigOptions = {},
isCurrentLoad: () => boolean = () => true,
): Promise<boolean> {
const client = state.client;
if (!client || !state.connected) {
return false;
@@ -261,7 +230,7 @@ async function loadConfig(state: ConfigState, options: LoadConfigOptions = {}):
state.chatError = null;
try {
const res = await client.request<ConfigSnapshot>("config.get", {});
if (!isCurrentRequest(state, "config", version, client, connectionEpoch)) {
if (!isCurrentRequest(state, "config", version, client, connectionEpoch) || !isCurrentLoad()) {
return false;
}
applyConfigSnapshot(state, res, options);
@@ -347,19 +316,9 @@ function applyConfigSnapshot(
// the local draft is thrown away.
state.configAutoSaveStatus = "idle";
}
// needsApply is persisted keyed to the saved ack hash (see
// CONFIG_NEEDS_APPLY_STORAGE_KEY); deriving it on every snapshot keeps the
// banner across reloads. A mismatch means the file changed out-of-band, so
// the record is deleted too — otherwise a hash that cycles back to the old
// value would resurrect a stale banner. Without a stored record
// (storage-disabled contexts) the process-local value stands.
const storedNeedsApplyHash = readStoredNeedsApplyHash();
if (storedNeedsApplyHash !== null) {
const matches = Boolean(snapshot.hash) && storedNeedsApplyHash === snapshot.hash;
state.configNeedsApply = matches;
if (!matches) {
storeNeedsApplyHash(null);
}
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;
@@ -614,25 +573,14 @@ function adoptConfigSetAck(state: ConfigState, submittedRaw: string, ackHash: st
}
}
// Legacy hashless ack: the follow-up reload fetched the authoritative
// snapshot. When it is exactly the submitted write, the fetched hash stands
// in for the ack the gateway never provided — persist the restart marker for
// set operations (or a page reload silently drops the banner), and rebase a
// preserved dirty draft off its pre-write base so the trailing save doesn't
// false-conflict with our own bytes. Foreign content matches neither and
// keeps everything fail-closed.
function reconcileHashlessWriteReload(
state: ConfigState,
submittedRaw: string,
options: { persistNeedsApply: boolean },
) {
// Legacy hashless ack: when the follow-up reload returns exactly the submitted
// bytes, rebase a preserved dirty draft onto that authoritative hash. Foreign
// content matches neither and stays fail-closed.
function reconcileHashlessWriteReload(state: ConfigState, submittedRaw: string) {
if (state.configSnapshot?.raw !== submittedRaw) {
return;
}
const hash = state.configSnapshot.hash ?? null;
if (options.persistNeedsApply && hash) {
storeNeedsApplyHash(hash);
}
if (state.configFormDirty) {
state.configDraftBaseHash = hash ?? state.configDraftBaseHash;
}
@@ -667,17 +615,12 @@ async function submitConfigChange(
// 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 (derived the
// same way config.get derives it). Persist the restart marker directly
// from the ack — the write is durable even if this connection just went
// stale — and adopt it as the new draft base.
// 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 (method === "config.set" && ackHash) {
storeNeedsApplyHash(ackHash);
}
if (!isCurrent()) {
return false;
}
@@ -693,9 +636,8 @@ async function submitConfigChange(
}
adoptConfigSetAck(state, raw, ackHash);
if (method === "config.apply") {
// Applied config is now live; drop the persisted restart marker before
// the reload re-derives needsApply from storage.
storeNeedsApplyHash(null);
// 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 {
@@ -707,10 +649,9 @@ async function submitConfigChange(
return false;
}
if (!ackHash) {
reconcileHashlessWriteReload(state, raw, { persistNeedsApply: method === "config.set" });
reconcileHashlessWriteReload(state, raw);
}
if (method === "config.set") {
state.configNeedsApply = true;
// "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";
@@ -736,10 +677,8 @@ async function submitConfigChange(
/**
* Teardown flush after an in-flight save: submits the latest draft once,
* based ONLY on that flight's own in-memory ack hash. Never the shared
* localStorage marker another tab may have written it, and a wrong-but-
* current hash there could CAS-clobber a foreign write. Callers skip the
* flush entirely (fail closed) when no in-memory ack hash exists.
* based only on that flight's own in-memory ack hash. Callers skip the flush
* entirely (fail closed) when no in-memory ack hash exists.
*/
function teardownFlushConfigDraft(
state: ConfigState,
@@ -747,15 +686,7 @@ function teardownFlushConfigDraft(
baseHash: string,
): void {
const raw = serializeFormForSubmit(state);
void client
.request("config.set", { raw, baseHash })
.then((ack) => {
const ackHash = readAckHash(ack);
if (ackHash) {
storeNeedsApplyHash(ackHash);
}
})
.catch(() => undefined);
void client.request("config.set", { raw, baseHash }).catch(() => undefined);
}
/**
@@ -787,16 +718,12 @@ async function autoSaveConfig(
state.chatError = null;
try {
const ack = await client.request("config.set", { raw: submittedRaw, baseHash });
// The gateway acks with the persisted snapshot hash (derived the same way
// config.get derives it). Persist the restart marker directly from the
// ack — the write is durable even if this connection just went stale.
// 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 (ackHash) {
storeNeedsApplyHash(ackHash);
}
if (!isCurrent()) {
return false;
}
@@ -821,9 +748,8 @@ async function autoSaveConfig(
if (!isCurrent()) {
return false;
}
reconcileHashlessWriteReload(state, submittedRaw, { persistNeedsApply: true });
reconcileHashlessWriteReload(state, submittedRaw);
}
state.configNeedsApply = true;
// "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";
@@ -899,14 +825,20 @@ async function patchConfig(
state.lastError = null;
state.chatError = null;
try {
await client.request("config.patch", {
const ack = await client.request<{ noop?: boolean }>("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 } : {}),
});
return isCurrentConfigConnection(state, client, connectionEpoch);
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return false;
}
if (ack.noop !== true) {
state.configNeedsApply = true;
}
return true;
} catch (err) {
if (isCurrentConfigConnection(state, client, connectionEpoch)) {
state.lastError = String(err);
@@ -1289,6 +1221,16 @@ export function createRuntimeConfigCapability(
}
autoSaveTrailing = false;
};
const appliedRefresh = createAppliedConfigRefreshController({
shouldRefresh: () =>
!disposed &&
state.connected &&
state.configNeedsApply &&
state.configSnapshot?.appliedConfigHash !== undefined,
refresh: (isCurrent) => loadOnce("config", () => loadConfig(state, {}, isCurrent)),
});
const cancelAppliedRefresh = appliedRefresh.cancel;
const reconcileAppliedRefresh = appliedRefresh.reconcile;
const runAutoSave = () => {
if (disposed || suppressAutoSave || writesSuspended) {
return;
@@ -1300,6 +1242,7 @@ export function createRuntimeConfigCapability(
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.
@@ -1330,6 +1273,8 @@ export function createRuntimeConfigCapability(
autoSaveTrailing = false;
if (wantsTrailing && !disposed) {
runAutoSave();
} else {
reconcileAppliedRefresh();
}
});
autoSaveInFlight = flight;
@@ -1347,6 +1292,7 @@ export function createRuntimeConfigCapability(
if (state.configAutoSaveStatus === "conflict") {
return;
}
cancelAppliedRefresh();
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
@@ -1445,8 +1391,12 @@ export function createRuntimeConfigCapability(
explicitOpQueue = tail;
return queued;
};
const ensureLoaded = () =>
state.configSnapshot ? Promise.resolve() : loadOnce("config", () => loadConfig(state));
const ensureLoaded = async () => {
if (!state.configSnapshot) {
await loadOnce("config", () => loadConfig(state));
}
reconcileAppliedRefresh();
};
const ensureSchemaLoaded = () =>
state.configSchema ? Promise.resolve() : loadOnce("schema", () => loadConfigSchema(state));
const stopGateway = gateway.subscribe((snapshot) => {
@@ -1462,6 +1412,7 @@ export function createRuntimeConfigCapability(
// from the previous connection cannot commit into the new connection epoch.
invalidateConfigConnection(state);
cancelScheduledAutoSave();
cancelAppliedRefresh();
if (autoSaveInFlight !== null || manualSubmitInFlight !== null) {
// The epoch guard already blocks these flights from mutating state;
// deregistering releases drain barriers and the trailing-save chain
@@ -1519,22 +1470,21 @@ export function createRuntimeConfigCapability(
// A dirty draft may still reschedule; a stale base surfaces
// as a conflict with its Reload recovery, never a clobber.
scheduleAutoSave();
reconcileAppliedRefresh();
return;
}
hasInterruptedWrite = false;
interruptedWriteRaw = null;
// If the interrupted write DID commit, the fresh snapshot is
// exactly its bytes: restore the saved-but-unapplied marker its
// lost ack would have stored, and 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.
// 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 (freshHash) {
storeNeedsApplyHash(freshHash);
if (state.configSnapshot.appliedConfigHash === undefined) {
state.configNeedsApply = true;
}
state.configNeedsApply = true;
if (state.configFormDirty) {
state.configDraftBaseHash = freshHash ?? state.configDraftBaseHash;
} else if (
@@ -1555,9 +1505,11 @@ export function createRuntimeConfigCapability(
}
publish();
scheduleAutoSave();
reconcileAppliedRefresh();
});
} else {
scheduleAutoSave();
reconcileAppliedRefresh();
}
}
}
@@ -1574,10 +1526,15 @@ export function createRuntimeConfigCapability(
if (options?.discardPendingChanges) {
await drainWritesForDiscard();
}
return trackLoad(
"config",
run(() => loadConfig(state, options)),
);
cancelAppliedRefresh();
try {
await trackLoad(
"config",
run(() => loadConfig(state, options)),
);
} finally {
reconcileAppliedRefresh();
}
},
refreshSchema: () =>
trackLoad(
@@ -1596,6 +1553,7 @@ export function createRuntimeConfigCapability(
resetDraft: () => {
cancelScheduledAutoSave();
mutate(() => resetConfigPendingChanges(state));
reconcileAppliedRefresh();
},
discardDraft: async () => {
// Settle pending writes first (with trailing saves suppressed — the
@@ -1603,10 +1561,16 @@ export function createRuntimeConfigCapability(
// re-dirty or trail-write over the discard.
await drainWritesForDiscard();
if (state.connected && state.client) {
return trackLoad(
"config",
run(() => loadConfig(state, { discardPendingChanges: true })),
);
cancelAppliedRefresh();
try {
await trackLoad(
"config",
run(() => loadConfig(state, { discardPendingChanges: true })),
);
} 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.
@@ -1635,13 +1599,19 @@ export function createRuntimeConfigCapability(
},
waitForPendingWrites: () => drainPendingWrites(),
save: () =>
afterPendingWritesSettled(() =>
saveConfig(state, (info) => {
manualFlightInfo = info;
}),
),
afterPendingWritesSettled(async () => {
cancelAppliedRefresh();
try {
return await saveConfig(state, (info) => {
manualFlightInfo = info;
});
} finally {
reconcileAppliedRefresh();
}
}),
apply: () =>
afterPendingWritesSettled(async () => {
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
@@ -1649,9 +1619,14 @@ export function createRuntimeConfigCapability(
if (state.configFormDirty && state.configFormMode === "raw") {
state.configAutoSaveStatus = "error";
state.lastError = t("configView.rawDraftBlocksApply");
reconcileAppliedRefresh();
return false;
}
return applyConfig(state);
try {
return await applyConfig(state);
} finally {
reconcileAppliedRefresh();
}
}),
openFile: () => run(() => openConfigFile(state)),
ensureAgentEntry: (agentId) => {
@@ -1672,11 +1647,20 @@ export function createRuntimeConfigCapability(
// 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) => {
cancelAppliedRefresh();
if (autoSaveTimer) {
cancelScheduledAutoSave();
runAutoSave();
}
return afterPendingWritesSettled(() => patchConfig(state, options)).finally(() => {
return afterPendingWritesSettled(async () => {
// A drained autosave can start its own refresh while this patch waits.
cancelAppliedRefresh();
try {
return await patchConfig(state, options);
} finally {
reconcileAppliedRefresh();
}
}).finally(() => {
scheduleAutoSave();
});
},
@@ -1701,6 +1685,7 @@ export function createRuntimeConfigCapability(
const autoFlight = autoSaveInFlight;
const pendingFlight = autoFlight ?? manualSubmitInFlight;
cancelScheduledAutoSave();
appliedRefresh.dispose();
if (canFlush && pendingFlight) {
void pendingFlight.then(() => {
// The settled flight could not update dirty/base state past the
@@ -49,19 +49,29 @@ describe("mock gateway stateful config", () => {
};
const initial = await request("get-1", "config.get", {});
expect(initial).toMatchObject({ raw, hash: "mock-config-hash-0" });
expect(initial).toMatchObject({
raw,
hash: "fixture-hash",
configRevisionHash: "fixture-hash",
appliedConfigHash: "fixture-hash",
});
expect(initial.config).toEqual({ logging: { level: "info" } });
const nextRaw = raw.replace("info", "debug");
const set = await request("set-1", "config.set", {
raw: nextRaw,
baseHash: "mock-config-hash-0",
baseHash: "fixture-hash",
});
// Acks carry the persisted hash, mirroring the real gateway contract.
expect(set).toEqual({ ok: true, hash: "mock-config-hash-1" });
const reloaded = await request("get-2", "config.get", {});
expect(reloaded).toMatchObject({ raw: nextRaw, hash: "mock-config-hash-1" });
expect(reloaded).toMatchObject({
raw: nextRaw,
hash: "mock-config-hash-1",
configRevisionHash: "mock-config-hash-1",
appliedConfigHash: "fixture-hash",
});
expect(reloaded.config).toEqual({ logging: { level: "debug" } });
const applied = await request("apply-1", "config.apply", {
@@ -69,7 +79,11 @@ describe("mock gateway stateful config", () => {
baseHash: "mock-config-hash-1",
});
expect(applied).toEqual({ ok: true, hash: "mock-config-hash-2" });
expect((await request("get-3", "config.get", {})).hash).toBe("mock-config-hash-2");
expect(await request("get-3", "config.get", {})).toMatchObject({
hash: "mock-config-hash-2",
configRevisionHash: "mock-config-hash-2",
appliedConfigHash: "mock-config-hash-2",
});
socket.close();
});
@@ -95,4 +109,43 @@ describe("mock gateway stateful config", () => {
expect(response?.payload).toEqual({ custom: true });
socket.close();
});
it("hydrates legacy persisted config state without losing revision hashes", async () => {
const raw = '{"logging":{"level":"info"}}';
const script = createControlUiMockGatewayInitScript({
methodResponses: {
"config.get": {
raw,
config: { logging: { level: "info" } },
hash: "fixture-hash",
appliedConfigHash: "fixture-applied-hash",
valid: true,
issues: [],
},
},
});
window.sessionStorage.clear();
window.sessionStorage.setItem(
"openclaw.control-ui-e2e.configState",
JSON.stringify({ raw, revision: 2 }),
);
// oxlint-disable-next-line typescript/no-implied-eval -- Executes the generated init script standalone, proving it captures no module closures.
new Function(script)();
const socket = new WebSocket("ws://mock-gateway");
const frames: ResponseFrame[] = [];
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String((event as MessageEvent).data)) as ResponseFrame);
});
await flushMockTimers();
socket.send(JSON.stringify({ type: "req", id: "get-1", method: "config.get", params: {} }));
await flushMockTimers();
expect(frames.find((frame) => frame.id === "get-1")?.payload).toMatchObject({
hash: "fixture-hash",
configRevisionHash: "fixture-hash",
appliedConfigHash: "fixture-applied-hash",
});
socket.close();
});
});
+72 -7
View File
@@ -383,13 +383,43 @@ function installControlUiMockGateway(input: {
const configured = scenario.methodResponses["config.get"];
return isRecord(configured) && typeof configured.raw === "string" ? configured : null;
})();
let configState: { raw: string; revision: number } | null = baseConfigResponse
? { raw: baseConfigResponse.raw as string, revision: 0 }
const initialConfigHash =
typeof baseConfigResponse?.hash === "string" ? baseConfigResponse.hash : "mock-config-hash-0";
const initialAppliedConfigHash =
typeof baseConfigResponse?.appliedConfigHash === "string"
? baseConfigResponse.appliedConfigHash
: initialConfigHash;
let lastConfiguredConfigHash = initialConfigHash;
let configState: {
raw: string;
revision: number;
hash: string;
appliedHash: string;
} | null = baseConfigResponse
? {
raw: baseConfigResponse.raw as string,
revision: 0,
hash: initialConfigHash,
appliedHash: initialAppliedConfigHash,
}
: null;
try {
const rawConfigState = configState ? window.sessionStorage.getItem(configStateKey) : null;
if (rawConfigState) {
configState = JSON.parse(rawConfigState) as typeof configState;
const stored = JSON.parse(rawConfigState) as unknown;
if (
isRecord(stored) &&
typeof stored.raw === "string" &&
typeof stored.revision === "number"
) {
configState = {
raw: stored.raw,
revision: stored.revision,
hash: typeof stored.hash === "string" ? stored.hash : initialConfigHash,
appliedHash:
typeof stored.appliedHash === "string" ? stored.appliedHash : initialAppliedConfigHash,
};
}
}
} catch {
// Storage-disabled browser contexts still get the scenario fixture.
@@ -404,7 +434,11 @@ function installControlUiMockGateway(input: {
}
function mockConfigHash(): string {
return `mock-config-hash-${configState?.revision ?? 0}`;
return configState?.hash ?? initialConfigHash;
}
function mockAppliedConfigHash(): string {
return configState?.appliedHash ?? initialAppliedConfigHash;
}
function persistGroupsState(): void {
@@ -565,16 +599,37 @@ function installControlUiMockGateway(input: {
}
if (configState && baseConfigResponse) {
if (method === "config.get") {
let parsedConfig: unknown = baseConfigResponse.config;
const configured = configuredResponse(method, params);
const configuredConfig = isRecord(configured.value) ? configured.value : baseConfigResponse;
if (
typeof configuredConfig.raw === "string" &&
typeof configuredConfig.hash === "string" &&
configuredConfig.hash !== lastConfiguredConfigHash
) {
lastConfiguredConfigHash = configuredConfig.hash;
configState = {
raw: configuredConfig.raw,
revision: configState.revision,
hash: configuredConfig.hash,
appliedHash:
typeof configuredConfig.appliedConfigHash === "string"
? configuredConfig.appliedConfigHash
: configuredConfig.hash,
};
persistConfigState();
}
let parsedConfig: unknown = configuredConfig.config;
try {
parsedConfig = JSON.parse(configState.raw) as unknown;
} catch {
// JSON5-only raw keeps the last parseable config object.
}
return {
...baseConfigResponse,
...configuredConfig,
config: parsedConfig,
hash: mockConfigHash(),
configRevisionHash: mockConfigHash(),
appliedConfigHash: mockAppliedConfigHash(),
raw: configState.raw,
};
}
@@ -592,7 +647,17 @@ function installControlUiMockGateway(input: {
}
const raw = isRecord(params) && typeof params.raw === "string" ? params.raw : null;
if (raw !== null) {
configState = { raw, revision: configState.revision + 1 };
const revision = configState.revision + 1;
const hash = `mock-config-hash-${revision}`;
configState = {
raw,
revision,
hash,
appliedHash:
method === "config.apply"
? hash
: (configState.appliedHash ?? initialAppliedConfigHash),
};
persistConfigState();
}
// Like the real gateway, ack with the persisted snapshot hash.