Skip to content

Commit eba0226

Browse files
authored
fix(desktop): keep a single update banner (#1033)
* fix(desktop): keep a single update banner * fix(desktop): restore update progress feedback * fix(desktop): show real download progress in settings page - getStatus() now returns percent from lastProgress so the settings page gets the actual download progress on mount - Settings page polls getStatus() every 1s during background downloads instead of switching to foreground mode (avoids triggering the desktop shell banner) - Fix empty update banner when experience is local-test-feed by correcting the idle-state early return logic * fix(desktop): consolidate update UI and improve diagnostics - Remove "Check for Updates…" from application menu (single entry via settings) - Add UpdateBadge to sidebar for dismissed update visibility - Reset dismissed state on update events so new updates re-surface - Add progress listener diagnostics to mac-update-driver - Improve update card layout (fit-content width, word wrap, flex-wrap) - Mock update server: support slow streaming for real ZIP files - Export getUpdateManager() from ipc module - Pin version to 0.1.10 for nightly update testing * fix(controller): deterministic openclaw config serialization - Sort skill slugs at all sources (compiler, sync service, workspace scanner) so merged skill lists are stable regardless of input order - Introduce serializeOpenClawConfig() with sorted JSON keys so semantically identical configs produce the same output string - Config writer and gateway service use deterministic serialization to avoid unnecessary OpenClaw reloads on key reordering - Add tests for deterministic skill ordering and key-order skip * fix(desktop): remove driver-level writeLog calls that lack diagnostic arg
1 parent 856fd58 commit eba0226

21 files changed

Lines changed: 522 additions & 102 deletions

apps/controller/src/lib/openclaw-config-compiler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,20 @@ function compileAgentList(
227227
installedSkillSlugs?: readonly string[],
228228
workspaceSkillsByAgent?: ReadonlyMap<string, readonly string[]>,
229229
): OpenClawConfig["agents"]["list"] {
230-
const sharedSlugs = installedSkillSlugs ?? [];
230+
const sharedSlugs = [...(installedSkillSlugs ?? [])].sort((left, right) =>
231+
left.localeCompare(right),
232+
);
231233

232234
return config.bots
233235
.filter((bot) => bot.status === "active")
234236
.sort((left, right) => left.slug.localeCompare(right.slug))
235237
.map((bot, index) => {
236-
const workspaceSlugs = workspaceSkillsByAgent?.get(bot.id) ?? [];
237-
const merged = [...new Set([...sharedSlugs, ...workspaceSlugs])];
238+
const workspaceSlugs = [
239+
...(workspaceSkillsByAgent?.get(bot.id) ?? []),
240+
].sort((left, right) => left.localeCompare(right));
241+
const merged = Array.from(
242+
new Set([...sharedSlugs, ...workspaceSlugs]),
243+
).sort((left, right) => left.localeCompare(right));
238244

239245
return {
240246
id: bot.id,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { OpenClawConfig } from "@nexu/shared";
2+
3+
function sortJsonValue(value: unknown): unknown {
4+
if (Array.isArray(value)) {
5+
return value.map((item) => sortJsonValue(item));
6+
}
7+
8+
if (value && typeof value === "object") {
9+
return Object.fromEntries(
10+
Object.entries(value)
11+
.sort(([left], [right]) => left.localeCompare(right))
12+
.map(([key, nestedValue]) => [key, sortJsonValue(nestedValue)]),
13+
);
14+
}
15+
16+
return value;
17+
}
18+
19+
export function normalizeOpenClawConfig(
20+
config: OpenClawConfig,
21+
): OpenClawConfig {
22+
return sortJsonValue(config) as OpenClawConfig;
23+
}
24+
25+
export function serializeOpenClawConfig(config: OpenClawConfig): string {
26+
return `${JSON.stringify(normalizeOpenClawConfig(config), null, 2)}\n`;
27+
}

apps/controller/src/runtime/openclaw-config-writer.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "@nexu/shared";
55
import type { ControllerEnv } from "../app/env.js";
66
import { NEXU_INTERNAL_ACCOUNT_PREFIX } from "../lib/channel-binding-compiler.js";
77
import { logger } from "../lib/logger.js";
8+
import { serializeOpenClawConfig } from "../lib/openclaw-config-serialization.js";
89

910
/**
1011
* Sync weixin account IDs from openclaw.json to the openclaw-weixin plugin's
@@ -93,17 +94,24 @@ export class OpenClawConfigWriter {
9394

9495
async write(config: OpenClawConfig): Promise<void> {
9596
await mkdir(path.dirname(this.env.openclawConfigPath), { recursive: true });
96-
const content = `${JSON.stringify(config, null, 2)}\n`;
97+
const content = serializeOpenClawConfig(config);
9798

9899
// On cold start, seed the cache from the existing file on disk so the
99100
// first write() after a process restart doesn't trigger an unnecessary
100101
// OpenClaw reload when the config hasn't actually changed.
101102
if (this.lastWrittenContent === null) {
102103
try {
103-
this.lastWrittenContent = await readFile(
104+
const existingContent = await readFile(
104105
this.env.openclawConfigPath,
105106
"utf8",
106107
);
108+
try {
109+
this.lastWrittenContent = serializeOpenClawConfig(
110+
JSON.parse(existingContent) as OpenClawConfig,
111+
);
112+
} catch {
113+
this.lastWrittenContent = existingContent;
114+
}
107115
} catch {
108116
// File doesn't exist yet — leave cache empty.
109117
}

apps/controller/src/services/openclaw-gateway-service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { createHash } from "node:crypto";
1313
import type { OpenClawConfig } from "@nexu/shared";
1414
import { logger } from "../lib/logger.js";
15+
import { serializeOpenClawConfig } from "../lib/openclaw-config-serialization.js";
1516
import type { OpenClawWsClient } from "../runtime/openclaw-ws-client.js";
1617
import type { ControllerRuntimeState } from "../runtime/state.js";
1718

@@ -634,6 +635,8 @@ export class OpenClawGatewayService {
634635
}
635636

636637
private configHash(config: OpenClawConfig): string {
637-
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
638+
return createHash("sha256")
639+
.update(serializeOpenClawConfig(config))
640+
.digest("hex");
638641
}
639642
}

apps/controller/src/services/openclaw-sync-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export class OpenClawSyncService {
145145
.getAllInstalled()
146146
.filter((r) => r.source !== "workspace")
147147
.map((r) => r.slug)
148+
.sort((left, right) => left.localeCompare(right))
148149
: undefined;
149150

150151
const workspaceMap = this.workspaceScanner

apps/controller/src/services/skillhub/workspace-skill-scanner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export class WorkspaceSkillScanner {
3434
try {
3535
return readdirSync(dir, { withFileTypes: true })
3636
.filter((entry) => existsSync(join(dir, entry.name, "SKILL.md")))
37-
.map((entry) => entry.name);
37+
.map((entry) => entry.name)
38+
.sort((left, right) => left.localeCompare(right));
3839
} catch {
3940
return [];
4041
}

apps/controller/tests/openclaw-config-compiler.test.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,17 @@ describe("compileOpenClawConfig", () => {
385385
});
386386

387387
it("does not remap openai models to OAuth providers without persisted OAuth state", () => {
388+
const baseConfig = createConfig();
389+
const baseBot = baseConfig.bots[0];
390+
const baseProvider = baseConfig.providers?.[0];
391+
if (!baseBot || !baseProvider) {
392+
throw new Error("expected base config fixtures");
393+
}
388394
const result = compileOpenClawConfig(
389395
createConfig({
390396
bots: [
391397
{
392-
...createConfig().bots[0],
398+
...baseBot,
393399
modelId: "openai/gpt-5.4",
394400
},
395401
],
@@ -403,7 +409,7 @@ describe("compileOpenClawConfig", () => {
403409
},
404410
providers: [
405411
{
406-
...createConfig().providers[0],
412+
...baseProvider,
407413
apiKey: null,
408414
models: ["gpt-5.4"],
409415
},
@@ -622,12 +628,18 @@ describe("compileOpenClawConfig", () => {
622628
});
623629

624630
it("ignores unsupported custom providers in compiled model config", () => {
631+
const baseConfig = createConfig();
632+
const baseProviders = baseConfig.providers ?? [];
633+
const baseProvider = baseProviders[0];
634+
if (!baseProvider) {
635+
throw new Error("expected base config providers");
636+
}
625637
const result = compileOpenClawConfig(
626638
createConfig({
627639
providers: [
628-
...createConfig().providers,
640+
...baseProviders,
629641
{
630-
...createConfig().providers[0],
642+
...baseProvider,
631643
id: "provider-3",
632644
providerId: "custom",
633645
displayName: "Custom",
@@ -985,6 +997,40 @@ describe("compileOpenClawConfig", () => {
985997
expect(botB?.skills).toEqual(["shared-skill"]);
986998
});
987999

1000+
it("sorts merged skills deterministically regardless of input order", () => {
1001+
const baseConfig = createConfig();
1002+
const baseBot = baseConfig.bots[0];
1003+
if (!baseBot) {
1004+
throw new Error("expected base config bot");
1005+
}
1006+
const config = createConfig({
1007+
bots: [
1008+
{
1009+
...baseBot,
1010+
id: "bot-a",
1011+
slug: "bot-a",
1012+
},
1013+
],
1014+
channels: [],
1015+
});
1016+
1017+
const compiled = compileOpenClawConfig(
1018+
config,
1019+
createEnv(),
1020+
undefined,
1021+
["zeta", "alpha", "shared-skill"],
1022+
new Map([["bot-a", ["workspace-z", "alpha", "workspace-a"]]]),
1023+
);
1024+
1025+
expect(compiled.agents.list[0]?.skills).toEqual([
1026+
"alpha",
1027+
"shared-skill",
1028+
"workspace-a",
1029+
"workspace-z",
1030+
"zeta",
1031+
]);
1032+
});
1033+
9881034
it("deduplicates when same slug in shared and workspace", () => {
9891035
const config = createConfig();
9901036
const wsMap = new Map<string, readonly string[]>([
@@ -1031,14 +1077,20 @@ describe("compileOpenClawConfig", () => {
10311077
});
10321078

10331079
it("remaps openai models to OAuth provider ids when persisted OAuth state is connected", () => {
1080+
const baseConfig = createConfig();
1081+
const baseBot = baseConfig.bots[0];
1082+
const baseProvider = baseConfig.providers?.[0];
1083+
if (!baseBot || !baseProvider) {
1084+
throw new Error("expected base config fixtures");
1085+
}
10341086
const oauthState: OAuthConnectionState = {
10351087
connectedProviderIds: ["openai"],
10361088
};
10371089
const result = compileOpenClawConfig(
10381090
createConfig({
10391091
bots: [
10401092
{
1041-
...createConfig().bots[0],
1093+
...baseBot,
10421094
modelId: "openai/gpt-5.4",
10431095
},
10441096
],
@@ -1052,7 +1104,7 @@ describe("compileOpenClawConfig", () => {
10521104
},
10531105
providers: [
10541106
{
1055-
...createConfig().providers[0],
1107+
...baseProvider,
10561108
apiKey: null,
10571109
models: ["gpt-5.4"],
10581110
},

apps/controller/tests/openclaw-config-writer.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,41 @@ describe("OpenClawConfigWriter", () => {
142142
expect(JSON.parse(written)).toEqual(configB);
143143
});
144144

145+
it("skips write when config is semantically unchanged but object keys reorder", async () => {
146+
const writer = new OpenClawConfigWriter(env);
147+
const configA = makeConfig({
148+
plugins: {
149+
entries: {
150+
zed: { enabled: true },
151+
alpha: { enabled: true },
152+
},
153+
load: { paths: [] },
154+
},
155+
});
156+
const configB = makeConfig({
157+
plugins: {
158+
load: { paths: [] },
159+
entries: {
160+
alpha: { enabled: true },
161+
zed: { enabled: true },
162+
},
163+
},
164+
});
165+
166+
await writer.write(configA);
167+
const firstStat = await stat(env.openclawConfigPath);
168+
169+
await new Promise((r) => setTimeout(r, 50));
170+
171+
await writer.write(configB);
172+
const secondStat = await stat(env.openclawConfigPath);
173+
174+
expect(secondStat.mtimeMs).toBe(firstStat.mtimeMs);
175+
expect(JSON.parse(await readFile(env.openclawConfigPath, "utf8"))).toEqual(
176+
configA,
177+
);
178+
});
179+
145180
it("cold start with no existing file writes normally", async () => {
146181
// No file exists yet — writer should write without error.
147182
const writer = new OpenClawConfigWriter(env);
@@ -152,4 +187,37 @@ describe("OpenClawConfigWriter", () => {
152187
const written = await readFile(env.openclawConfigPath, "utf8");
153188
expect(JSON.parse(written)).toEqual(config);
154189
});
190+
191+
it("new writer instance skips rewrite when existing file only differs by key order", async () => {
192+
const configA = makeConfig({
193+
plugins: {
194+
entries: {
195+
zed: { enabled: true },
196+
alpha: { enabled: true },
197+
},
198+
load: { paths: [] },
199+
},
200+
});
201+
const configB = makeConfig({
202+
plugins: {
203+
load: { paths: [] },
204+
entries: {
205+
alpha: { enabled: true },
206+
zed: { enabled: true },
207+
},
208+
},
209+
});
210+
211+
const writer1 = new OpenClawConfigWriter(env);
212+
await writer1.write(configA);
213+
const firstStat = await stat(env.openclawConfigPath);
214+
215+
await new Promise((r) => setTimeout(r, 50));
216+
217+
const writer2 = new OpenClawConfigWriter(env);
218+
await writer2.write(configB);
219+
const secondStat = await stat(env.openclawConfigPath);
220+
221+
expect(secondStat.mtimeMs).toBe(firstStat.mtimeMs);
222+
});
155223
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { OpenClawConfig } from "@nexu/shared";
2+
import { describe, expect, it } from "vitest";
3+
import { OpenClawGatewayService } from "../src/services/openclaw-gateway-service.js";
4+
5+
function makeConfig(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
6+
return {
7+
gateway: { port: 18789, mode: "local", bind: "127.0.0.1" },
8+
agents: { list: [], defaults: {} },
9+
channels: {},
10+
bindings: [],
11+
plugins: { load: { paths: [] }, entries: {} },
12+
skills: { load: { watch: true } },
13+
commands: { native: "auto" },
14+
...overrides,
15+
} as OpenClawConfig;
16+
}
17+
18+
describe("OpenClawGatewayService", () => {
19+
it("treats semantically identical configs as unchanged despite key reorder", async () => {
20+
const service = new OpenClawGatewayService(
21+
{
22+
isConnected: () => true,
23+
} as never,
24+
{} as never,
25+
);
26+
27+
const configA = makeConfig({
28+
plugins: {
29+
entries: {
30+
zed: { enabled: true },
31+
alpha: { enabled: true },
32+
},
33+
load: { paths: [] },
34+
},
35+
});
36+
const configB = makeConfig({
37+
plugins: {
38+
load: { paths: [] },
39+
entries: {
40+
alpha: { enabled: true },
41+
zed: { enabled: true },
42+
},
43+
},
44+
});
45+
46+
service.noteConfigWritten(configA);
47+
48+
await expect(service.shouldPushConfig(configB)).resolves.toBe(false);
49+
});
50+
});

0 commit comments

Comments
 (0)