From 2ecc6b6b6b0cdba79b91c45f900695579a4dc256 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 08:43:35 -0700 Subject: [PATCH] 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 --- .../OpenClawProtocol/GatewayModels.swift | 4 + docs/gateway/protocol.md | 7 +- .../gateway-protocol/src/schema/snapshot.ts | 2 + scripts/control-ui-mock-dev.ts | 2 +- src/config/config.ts | 3 + src/config/io.ts | 2 +- src/config/runtime-snapshot.test.ts | 22 ++ src/config/runtime-snapshot.ts | 19 ++ src/gateway/applied-config-hash-publisher.ts | 27 ++ src/gateway/config-applied-revision.ts | 40 +++ src/gateway/config-get-response.ts | 14 + src/gateway/config-reload-recovery.ts | 29 ++ src/gateway/config-reload.test.ts | 15 +- src/gateway/config-reload.ts | 50 ++- src/gateway/server-methods/config.ts | 12 +- src/gateway/server-reload-handlers.test.ts | 36 +++ src/gateway/server-reload-handlers.ts | 50 ++- src/gateway/server.config-patch.test.ts | 12 + src/gateway/server.impl.ts | 4 +- src/gateway/server/health-state.ts | 2 + ui/src/api/types.ts | 8 +- ...fig-quick-thinking-persistence.e2e.test.ts | 1 - ui/src/lib/config/applied-refresh.ts | 49 +++ ui/src/lib/config/index.test.ts | 304 +++++++++++++++--- ui/src/lib/config/index.ts | 251 +++++++-------- .../control-ui-e2e.mock-gateway.test.ts | 61 +++- ui/src/test-helpers/control-ui-e2e.ts | 79 ++++- 27 files changed, 828 insertions(+), 277 deletions(-) create mode 100644 src/gateway/applied-config-hash-publisher.ts create mode 100644 src/gateway/config-applied-revision.ts create mode 100644 src/gateway/config-get-response.ts create mode 100644 src/gateway/config-reload-recovery.ts create mode 100644 ui/src/lib/config/applied-refresh.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0a34516c56d9..e2f2aa054088 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 96665b1366aa..1dd24f1d6987 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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 - `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. diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index 8dfa2368f23c..c2fc53654709 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -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), diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 96eb94d3e985..20a781b72f68 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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: [], diff --git a/src/config/config.ts b/src/config/config.ts index 59937e896b48..0774dc87db0b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -32,9 +32,12 @@ export { writeConfigFile, } from "./io.js"; export { + getRuntimeConfigAppliedHash, hashRuntimeConfigValue, resolveConfigWriteAfterWrite, resolveConfigWriteFollowUp, + setAppliedRuntimeConfigSnapshot, + setRuntimeConfigAppliedHash, } from "./runtime-snapshot.js"; export type { ConfigWriteAfterWrite, diff --git a/src/config/io.ts b/src/config/io.ts index d47760b7ddeb..f8c8a98dfe4d 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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"; diff --git a/src/config/runtime-snapshot.test.ts b/src/config/runtime-snapshot.test.ts index c473c0bd5ce2..da2bdc5c6f56 100644 --- a/src/config/runtime-snapshot.test.ts +++ b/src/config/runtime-snapshot.test.ts @@ -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: { diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index 2d666faae33e..9917a1089833 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -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) { diff --git a/src/gateway/applied-config-hash-publisher.ts b/src/gateway/applied-config-hash-publisher.ts new file mode 100644 index 000000000000..933ce1e02f73 --- /dev/null +++ b/src/gateway/applied-config-hash-publisher.ts @@ -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); + }, + }; +} diff --git a/src/gateway/config-applied-revision.ts b/src/gateway/config-applied-revision.ts new file mode 100644 index 000000000000..f048e9760807 --- /dev/null +++ b/src/gateway/config-applied-revision.ts @@ -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; + +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); + }, + }; +} diff --git a/src/gateway/config-get-response.ts b/src/gateway/config-get-response.ts new file mode 100644 index 000000000000..d99fc83eba24 --- /dev/null +++ b/src/gateway/config-get-response.ts @@ -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[1], +) { + return { + ...redactConfigSnapshot(snapshot, uiHints), + configRevisionHash: hashRuntimeConfigValue(snapshot.sourceConfig), + appliedConfigHash: getRuntimeConfigAppliedHash(), + }; +} diff --git a/src/gateway/config-reload-recovery.ts b/src/gateway/config-reload-recovery.ts new file mode 100644 index 000000000000..b6fc51d58669 --- /dev/null +++ b/src/gateway/config-reload-recovery.ts @@ -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) + ); +} diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index bd3b9a5fb722..7e673edce4e2 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -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>; onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; + onConfigRevisionApplied?: (hash: string) => void; onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; 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, diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index ac6a9d2ae6b2..e61c399e39ee 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -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; /** Publishes runtime state after a hot or no-op config transaction. */ onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; + /** 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) | 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 () => { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index b369744e694b..6c554d47a185 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -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)) { diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 41a89c1afc15..14c12bf96c33 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -1940,6 +1940,40 @@ describe("gateway restart deferral preflight", () => { } }); + it("reports restart debt until a replacement config retires it", () => { + const requestRecoveryRestart = vi + .fn>() + .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 @@ -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(); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index a40c3f1d0a44..b07fc10eac2d 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -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(); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 31b6d405e193..6cd6577d5c3a 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -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); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 5ba9c5384979..c7a7241ada95 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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, diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 03eda4a36fd2..f4d92ed509e9 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -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, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 9c700a9d0f06..3e00c00bfcf3 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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 | null; diff --git a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts index 544a54a8d059..19165c881bbf 100644 --- a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts +++ b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts @@ -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({ diff --git a/ui/src/lib/config/applied-refresh.ts b/ui/src/lib/config/applied-refresh.ts new file mode 100644 index 000000000000..8222507b09d9 --- /dev/null +++ b/ui/src/lib/config/applied-refresh.ts @@ -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; +}) { + let timer: ReturnType | 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(); + }, + }; +} diff --git a/ui/src/lib/config/index.test.ts b/ui/src/lib/config/index.test.ts index a9171fdc80d1..03e588b3ef52 100644 --- a/ui/src/lib/config/index.test.ts +++ b/ui/src/lib/config/index.test.ts @@ -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, 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 { - const store = new Map(); - 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(); + let getCount = 0; + const request = vi.fn((method: string) => { + if (method === "config.get") { + getCount += 1; + if (getCount === 2) { + return stalePoll.promise; + } + return Promise.resolve({ + config: { count: 1 }, + raw: '{\n "count": 1\n}\n', + hash: "hash-1", + configRevisionHash: "revision-1", + appliedConfigHash: "revision-0", + valid: true, + issues: [], + }); + } + return Promise.resolve(method === "config.set" ? { hash: "hash-2" } : {}); + }); + const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); + await runtimeConfig.ensureLoaded(); + + await vi.advanceTimersByTimeAsync(250); + runtimeConfig.patchForm(["count"], 2); + await vi.advanceTimersByTimeAsync(CONFIG_FORM_AUTO_SAVE_DEBOUNCE_MS); + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + + stalePoll.resolve({ + config: { count: 1 }, + raw: '{\n "count": 1\n}\n', + hash: "hash-1", + configRevisionHash: "revision-1", + appliedConfigHash: "revision-1", + valid: true, + issues: [], + }); + await vi.advanceTimersByTimeAsync(0); + + expect(runtimeConfig.state.configSnapshot?.hash).toBe("hash-2"); + expect(runtimeConfig.state.configNeedsApply).toBe(true); + runtimeConfig.dispose(); + }); + + it("does not re-arm an invalidated applied-hash poll during config.patch", async () => { + vi.useFakeTimers(); + const stalePoll = deferred(); + const patchGate = deferred(); + let getCount = 0; + const request = vi.fn((method: string) => { + if (method === "config.get") { + getCount += 1; + if (getCount === 2) { + return stalePoll.promise; + } + return Promise.resolve({ + config: { count: 1 }, + raw: '{\n "count": 1\n}\n', + hash: "hash-1", + configRevisionHash: "revision-1", + appliedConfigHash: "revision-0", + valid: true, + issues: [], + }); + } + if (method === "config.patch") { + return patchGate.promise; + } + return Promise.resolve({}); + }); + const { runtimeConfig } = createHarness(request as GatewayBrowserClient["request"]); + await runtimeConfig.ensureLoaded(); + + await vi.advanceTimersByTimeAsync(250); + const patchPromise = runtimeConfig.patch({ raw: { count: 2 }, note: "test patch" }); + await vi.advanceTimersByTimeAsync(0); + + stalePoll.resolve({ + config: { count: 1 }, + raw: '{\n "count": 1\n}\n', + hash: "hash-1", + configRevisionHash: "revision-1", + appliedConfigHash: "revision-1", + valid: true, + issues: [], + }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(getCount).toBe(2); + patchGate.resolve({ hash: "hash-2" }); + await vi.advanceTimersByTimeAsync(0); + await expect(patchPromise).resolves.toBe(true); runtimeConfig.dispose(); }); @@ -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(); let setCalls = 0; const request = vi.fn((method: string) => { diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts index a01e040ce5c1..cf4ba24ff662 100644 --- a/ui/src/lib/config/index.ts +++ b/ui/src/lib/config/index.ts @@ -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 { +async function loadConfig( + state: ConfigState, + options: LoadConfigOptions = {}, + isCurrentLoad: () => boolean = () => true, +): Promise { 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("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 diff --git a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts index 8d15e6b01ea3..ed253dc32548 100644 --- a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts +++ b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts @@ -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(); + }); }); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 32b717fe9371..016672f9c50d 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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.