Skip to content

Commit 2ecc6b6

Browse files
authored
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
1 parent e6cec8e commit 2ecc6b6

27 files changed

Lines changed: 827 additions & 276 deletions

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ public struct Snapshot: Codable, Sendable {
490490
public let health: AnyCodable
491491
public let stateversion: StateVersion
492492
public let uptimems: Int
493+
public let appliedconfighash: AnyCodable?
493494
public let configpath: String?
494495
public let statedir: String?
495496
public let sessiondefaults: [String: AnyCodable]?
@@ -501,6 +502,7 @@ public struct Snapshot: Codable, Sendable {
501502
health: AnyCodable,
502503
stateversion: StateVersion,
503504
uptimems: Int,
505+
appliedconfighash: AnyCodable? = nil,
504506
configpath: String? = nil,
505507
statedir: String? = nil,
506508
sessiondefaults: [String: AnyCodable]? = nil,
@@ -511,6 +513,7 @@ public struct Snapshot: Codable, Sendable {
511513
self.health = health
512514
self.stateversion = stateversion
513515
self.uptimems = uptimems
516+
self.appliedconfighash = appliedconfighash
514517
self.configpath = configpath
515518
self.statedir = statedir
516519
self.sessiondefaults = sessiondefaults
@@ -523,6 +526,7 @@ public struct Snapshot: Codable, Sendable {
523526
case health
524527
case stateversion = "stateVersion"
525528
case uptimems = "uptimeMs"
529+
case appliedconfighash = "appliedConfigHash"
526530
case configpath = "configPath"
527531
case statedir = "stateDir"
528532
case sessiondefaults = "sessionDefaults"

docs/gateway/protocol.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
113113
`node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry.
114114
The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh`
115115
path is not supported; use plugin surfaces.
116+
The snapshot's optional `appliedConfigHash` is the resolved source-config revision
117+
accepted by the active Gateway runtime. Clients can compare it with
118+
`config.get.configRevisionHash` to determine whether a newer saved config still
119+
needs a restart. `config.get.hash` remains the raw root-file revision used by
120+
config write conflict guards.
116121

117122
While the gateway is still finishing startup sidecars, `connect` can return a
118123
retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and
@@ -487,7 +492,7 @@ methods. Treat this as feature discovery, not a full enumeration of
487492
<Accordion title="Secrets, config, update, and wizard">
488493
- `secrets.reload` re-resolves active SecretRefs and swaps runtime secret state only on full success.
489494
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
490-
- `config.get` returns the current config snapshot and hash.
495+
- `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.
491496
- `config.set` writes a validated config payload.
492497
- `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`.
493498
- `config.apply` validates + replaces the full config payload.

packages/gateway-protocol/src/schema/snapshot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export const SnapshotSchema = closedObject({
5353
health: HealthSnapshotSchema,
5454
stateVersion: StateVersionSchema,
5555
uptimeMs: Type.Integer({ minimum: 0 }),
56+
/** Resolved source-config revision accepted by the active Gateway runtime. */
57+
appliedConfigHash: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
5658
configPath: Type.Optional(NonEmptyString),
5759
stateDir: Type.Optional(NonEmptyString),
5860
sessionDefaults: Type.Optional(SessionDefaultsSchema),

scripts/control-ui-mock-dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,6 @@ function buildConfigMocks() {
612612
},
613613
},
614614
},
615-
// Second AI section so scoped pages demo the segmented section tabs.
616615
models: {
617616
type: "object",
618617
title: "Models",
@@ -632,6 +631,7 @@ function buildConfigMocks() {
632631
exists: true,
633632
raw: `${JSON.stringify(config, null, 2)}\n`,
634633
hash: "mock-config-hash",
634+
appliedConfigHash: "mock-config-hash",
635635
valid: true,
636636
config,
637637
issues: [],

src/config/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ export {
3232
writeConfigFile,
3333
} from "./io.js";
3434
export {
35+
getRuntimeConfigAppliedHash,
3536
hashRuntimeConfigValue,
3637
resolveConfigWriteAfterWrite,
3738
resolveConfigWriteFollowUp,
39+
setAppliedRuntimeConfigSnapshot,
40+
setRuntimeConfigAppliedHash,
3841
} from "./runtime-snapshot.js";
3942
export type {
4043
ConfigWriteAfterWrite,

src/config/io.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ export {
157157
setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler,
158158
registerManagedRuntimeConfigWriteOwner,
159159
};
160+
export { setAppliedRuntimeConfigSnapshot } from "./runtime-snapshot.js";
160161

161-
// Re-export for backwards compatibility
162162
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
163163
export { MissingEnvVarError } from "./env-substitution.js";
164164
export { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";

src/config/runtime-snapshot.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { afterEach, describe, expect, it, vi } from "vitest";
33
import {
44
finalizeRuntimeSnapshotWrite,
5+
getRuntimeConfigAppliedHash,
6+
hashRuntimeConfigValue,
57
hasManagedRuntimeConfigWriteOwner,
68
getRuntimeConfigSnapshotMetadata,
79
getRuntimeConfigSourceSnapshot,
@@ -15,6 +17,7 @@ import {
1517
resolveRuntimeConfigCacheKey,
1618
selectApplicableRuntimeConfig,
1719
setRuntimeConfigSnapshot,
20+
setRuntimeConfigAppliedHash,
1821
setRuntimeConfigSnapshotRefreshHandler,
1922
} from "./runtime-snapshot.js";
2023
import type { OpenClawConfig } from "./types.js";
@@ -97,6 +100,25 @@ describe("runtime snapshot state", () => {
97100
);
98101
});
99102

103+
it("tracks the applied source revision independently from runtime fingerprints", () => {
104+
expect(getRuntimeConfigAppliedHash()).toBeNull();
105+
106+
setRuntimeConfigAppliedHash("disk-hash-1");
107+
setRuntimeConfigSnapshot({ gateway: { port: 18789 } });
108+
expect(getRuntimeConfigAppliedHash()).toBe("disk-hash-1");
109+
110+
resetConfigRuntimeState();
111+
expect(getRuntimeConfigAppliedHash()).toBeNull();
112+
});
113+
114+
it("hashes resolved source content independently from root-file revision metadata", () => {
115+
const first = hashRuntimeConfigValue({ logging: { level: "info" } });
116+
const second = hashRuntimeConfigValue({ logging: { level: "debug" } });
117+
118+
expect(first).not.toBe(second);
119+
expect(hashRuntimeConfigValue({ logging: { level: "info" } })).toBe(first);
120+
});
121+
100122
it("selects runtime config only when input still matches the runtime source", () => {
101123
const sourceConfig: OpenClawConfig = {
102124
models: {

src/config/runtime-snapshot.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export type RuntimeConfigSnapshotMetadata = {
106106
let runtimeConfigSnapshot: OpenClawConfig | null = null;
107107
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
108108
let runtimeConfigSnapshotMetadata: RuntimeConfigSnapshotMetadata | null = null;
109+
let runtimeConfigAppliedHash: string | null = null;
109110
let runtimeConfigSnapshotRevision = 0;
110111
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
111112
type ManagedRuntimeConfigWritePreflight = (
@@ -169,6 +170,14 @@ export function setRuntimeConfigSnapshot(
169170
runtimeConfigSnapshotMetadata = createRuntimeConfigSnapshotMetadata(config, sourceConfig);
170171
}
171172

173+
export function setAppliedRuntimeConfigSnapshot(
174+
config: OpenClawConfig,
175+
sourceConfig: OpenClawConfig,
176+
): void {
177+
setRuntimeConfigSnapshot(config, sourceConfig);
178+
runtimeConfigAppliedHash = hashRuntimeConfigValue(sourceConfig);
179+
}
180+
172181
/** Publish a newer canonical source without changing the active runtime object. */
173182
export function setRuntimeConfigSourceSnapshotIfCurrent(params: {
174183
expectedRevision: number;
@@ -189,6 +198,7 @@ export function resetConfigRuntimeState(): void {
189198
runtimeConfigSnapshot = null;
190199
runtimeConfigSourceSnapshot = null;
191200
runtimeConfigSnapshotMetadata = null;
201+
runtimeConfigAppliedHash = null;
192202
runtimeConfigSnapshotRevision = 0;
193203
resetPublishedConfigRuntimeEnv();
194204
}
@@ -209,6 +219,15 @@ export function getRuntimeConfigSnapshotMetadata(): RuntimeConfigSnapshotMetadat
209219
return runtimeConfigSnapshotMetadata;
210220
}
211221

222+
/** Resolved source-config revision accepted by the active Gateway runtime. */
223+
export function getRuntimeConfigAppliedHash(): string | null {
224+
return runtimeConfigAppliedHash;
225+
}
226+
227+
export function setRuntimeConfigAppliedHash(hash: string | null): void {
228+
runtimeConfigAppliedHash = hash;
229+
}
230+
212231
export function resolveRuntimeConfigCacheKey(config: OpenClawConfig): string {
213232
const metadata = runtimeConfigSnapshotMetadata;
214233
if (metadata && config === runtimeConfigSnapshot) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function createAppliedConfigHashPublisher(options: {
2+
hasPendingRestart: () => boolean;
3+
publish: (hash: string) => void;
4+
}) {
5+
let deferredHash: string | null = null;
6+
return {
7+
hasOutstandingGatewayRestart: options.hasPendingRestart,
8+
publishAppliedConfigHash: (hash: string) => {
9+
// A hot-only edit can land behind restart debt. Keep the newest revision
10+
// private until that debt retires; a replacement Gateway sets startup truth.
11+
if (options.hasPendingRestart()) {
12+
deferredHash = hash;
13+
return;
14+
}
15+
deferredHash = null;
16+
options.publish(hash);
17+
},
18+
publishDeferredAppliedConfigHash: () => {
19+
if (deferredHash === null || options.hasPendingRestart()) {
20+
return;
21+
}
22+
const hash = deferredHash;
23+
deferredHash = null;
24+
options.publish(hash);
25+
},
26+
};
27+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import type { GatewayReloadPlan } from "./config-reload-plan.js";
3+
4+
type AppliedCallback = (
5+
plan: GatewayReloadPlan,
6+
nextConfig: OpenClawConfig,
7+
) => void | Promise<void>;
8+
9+
export function createConfigAppliedRevisionTracker(options: {
10+
onConfigApplied?: AppliedCallback;
11+
onRevisionApplied?: (hash: string) => void;
12+
}) {
13+
let pending: { plan: GatewayReloadPlan; hash: string } | null = null;
14+
const flush = async (currentConfig: OpenClawConfig) => {
15+
const owner = pending;
16+
if (!owner) {
17+
return;
18+
}
19+
await options.onConfigApplied?.(owner.plan, currentConfig);
20+
// A superseding transaction runs later; this committed owner is runtime truth meanwhile.
21+
options.onRevisionApplied?.(owner.hash);
22+
if (pending === owner) {
23+
pending = null;
24+
}
25+
};
26+
return {
27+
defer: (plan: GatewayReloadPlan, hash: string) => {
28+
pending = { plan, hash };
29+
},
30+
flush,
31+
apply: async (plan: GatewayReloadPlan, config: OpenClawConfig, hash: string) => {
32+
if (pending?.plan === plan) {
33+
await flush(config);
34+
return;
35+
}
36+
await options.onConfigApplied?.(plan, config);
37+
options.onRevisionApplied?.(hash);
38+
},
39+
};
40+
}

0 commit comments

Comments
 (0)