mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
e6cec8e124
commit
2ecc6b6b6b
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user