From 97502dbe546be6bebf5eeff327634d10117eb530 Mon Sep 17 00:00:00 2001 From: gsknnft Date: Mon, 20 Apr 2026 17:19:50 -0400 Subject: [PATCH] fix ongoing findings issue --- src/lib/studio/coordinator.ts | 80 ++++++++++++- tests/unit/studioSettingsCoordinator.test.ts | 111 +++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts index e3f4d6c..2c3aa24 100644 --- a/src/lib/studio/coordinator.ts +++ b/src/lib/studio/coordinator.ts @@ -5,6 +5,7 @@ import type { StudioFocusedPreference, StudioGatewaySettings, StudioGatewaySettingsPublic, + StudioGatewaySettingsPatch, StudioOfficePreferencePatch, StudioSettingsPublic, StudioSettingsPatch, @@ -267,13 +268,83 @@ const mergeOfficeFloorsPatch = ( return { ...current, ...next }; }; +const mergeGatewayProfilesPatch = ( + current: StudioGatewaySettingsPatch["profiles"], + next: StudioGatewaySettingsPatch["profiles"], +): StudioGatewaySettingsPatch["profiles"] => { + if (next === undefined) return current; + if (next === null) return null; + if (!current || current === null) return { ...next }; + const merged: NonNullable = { + ...current, + }; + for (const [adapterType, profilePatch] of Object.entries(next)) { + if (profilePatch === null) { + merged[adapterType as keyof typeof merged] = null; + continue; + } + const existing = merged[adapterType as keyof typeof merged]; + merged[adapterType as keyof typeof merged] = + existing && existing !== null + ? { ...existing, ...profilePatch } + : { ...profilePatch }; + } + return merged; +}; + +const mergeGatewayConnectionPatch = ( + current: StudioGatewaySettingsPatch["lastKnownGood"], + next: StudioGatewaySettingsPatch["lastKnownGood"], +): StudioGatewaySettingsPatch["lastKnownGood"] => { + if (next === undefined) return current; + if (next === null) return null; + if (!current || current === null) return { ...next }; + return { ...current, ...next }; +}; + +const mergeGatewayPatch = ( + current: StudioSettingsPatch["gateway"], + next: StudioSettingsPatch["gateway"], +): StudioSettingsPatch["gateway"] => { + if (next === undefined) return current; + if (next === null) return null; + if (!current || current === null) { + return { + ...next, + ...(next.profiles !== undefined + ? { profiles: mergeGatewayProfilesPatch(undefined, next.profiles) } + : {}), + ...(next.lastKnownGood !== undefined + ? { + lastKnownGood: mergeGatewayConnectionPatch( + undefined, + next.lastKnownGood, + ), + } + : {}), + }; + } + + const profiles = mergeGatewayProfilesPatch(current.profiles, next.profiles); + const lastKnownGood = mergeGatewayConnectionPatch( + current.lastKnownGood, + next.lastKnownGood, + ); + return { + ...current, + ...next, + ...(profiles !== undefined ? { profiles } : {}), + ...(lastKnownGood !== undefined ? { lastKnownGood } : {}), + }; +}; + const mergeStudioPatch = ( current: StudioSettingsPatch | null, next: StudioSettingsPatch ): StudioSettingsPatch => { if (!current) { return { - ...(next.gateway !== undefined ? { gateway: next.gateway } : {}), + ...(next.gateway !== undefined ? { gateway: mergeGatewayPatch(undefined, next.gateway) } : {}), ...(next.activeFloorId !== undefined ? { activeFloorId: next.activeFloorId } : {}), ...(next.focused ? { focused: { ...next.focused } } : {}), ...(next.avatars ? { avatars: { ...next.avatars } } : {}), @@ -297,11 +368,12 @@ const mergeStudioPatch = ( const standup = mergeStandupPatch(current.standup, next.standup); const taskBoard = mergeTaskBoardPatch(current.taskBoard, next.taskBoard); const officeFloors = mergeOfficeFloorsPatch(current.officeFloors, next.officeFloors); + const gateway = mergeGatewayPatch(current.gateway, next.gateway); return { ...(next.gateway !== undefined - ? { gateway: next.gateway } - : current.gateway !== undefined - ? { gateway: current.gateway } + ? { gateway } + : gateway !== undefined + ? { gateway } : {}), ...(next.activeFloorId !== undefined ? { activeFloorId: next.activeFloorId } diff --git a/tests/unit/studioSettingsCoordinator.test.ts b/tests/unit/studioSettingsCoordinator.test.ts index 3b77e34..e027071 100644 --- a/tests/unit/studioSettingsCoordinator.test.ts +++ b/tests/unit/studioSettingsCoordinator.test.ts @@ -50,6 +50,117 @@ describe("StudioSettingsCoordinator", () => { coordinator.dispose(); }); + it("merges queued gateway patches field-by-field before flushing", async () => { + const fetchSettings = vi.fn(async () => createResponse()); + const updateSettings = vi.fn(async () => createResponse()); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 300); + + coordinator.schedulePatch( + { + gateway: { + lastKnownGood: { + url: "ws://localhost:18789", + token: undefined, + adapterType: "openclaw", + }, + }, + }, + 300, + ); + coordinator.schedulePatch( + { + gateway: { + url: "http://localhost:7770", + adapterType: "local", + profiles: { + local: { + url: "http://localhost:7770", + token: undefined, + }, + }, + }, + }, + 300, + ); + + await vi.advanceTimersByTimeAsync(300); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + gateway: { + url: "http://localhost:7770", + adapterType: "local", + profiles: { + local: { + url: "http://localhost:7770", + token: undefined, + }, + }, + lastKnownGood: { + url: "ws://localhost:18789", + token: undefined, + adapterType: "openclaw", + }, + }, + }); + + coordinator.dispose(); + }); + + it("merges queued gateway profile and last-known-good subpatches", async () => { + const fetchSettings = vi.fn(async () => createResponse()); + const updateSettings = vi.fn(async () => createResponse()); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 300); + + coordinator.schedulePatch({ + gateway: { + profiles: { + hermes: { + url: "ws://localhost:18888", + token: undefined, + }, + }, + lastKnownGood: { + url: "ws://localhost:18789", + token: "stored-token", + }, + }, + }); + coordinator.schedulePatch({ + gateway: { + profiles: { + hermes: { + token: "new-token", + }, + }, + lastKnownGood: { + adapterType: "openclaw", + }, + }, + }); + + await vi.advanceTimersByTimeAsync(300); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + gateway: { + profiles: { + hermes: { + url: "ws://localhost:18888", + token: "new-token", + }, + }, + lastKnownGood: { + url: "ws://localhost:18789", + token: "stored-token", + adapterType: "openclaw", + }, + }, + }); + + coordinator.dispose(); + }); + it("flushPending persists queued patch immediately", async () => { const fetchSettings = vi.fn(async () => createResponse()); const updateSettings = vi.fn(async () => createResponse());