diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index 8c8320cba7..1364f32bce 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -126,6 +126,9 @@ export class CloudService extends EventEmitter implements Di cloudSettingsService.on("settings-updated", this.settingsListener) await cloudSettingsService.initialize() + // Set up bridge listener for settings updates from other instances + cloudSettingsService.setupBridgeListener() + this._settingsService = cloudSettingsService } diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts index 4117dbb0bd..392dcbd7de 100644 --- a/packages/cloud/src/CloudSettingsService.ts +++ b/packages/cloud/src/CloudSettingsService.ts @@ -21,6 +21,7 @@ import { import { getRooCodeApiUrl } from "./config.js" import { RefreshTimer } from "./RefreshTimer.js" +import { BridgeOrchestrator } from "./bridge/index.js" const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings" const USER_SETTINGS_CACHE_KEY = "user-settings" @@ -104,6 +105,37 @@ export class CloudSettingsService extends EventEmitter im } } + /** + * Set up listener for settings update events from other instances + * This will be called by CloudService after initialization + */ + public setupBridgeListener(): void { + // This method will be called to set up listening for remote settings updates + // The actual implementation will be handled through the BridgeOrchestrator + // which will call handleRemoteSettingsUpdate when events are received + } + + /** + * Handle remote settings update events from other instances + * This method should be called by the BridgeOrchestrator when a settings update event is received + */ + public handleRemoteSettingsUpdate(versions: { organization: number; user: number }): void { + // Check if we should refetch settings based on version numbers + if (this.shouldRefetchSettings(versions)) { + this.log("[cloud-settings] Received settings update event with newer versions, refetching...") + this.fetchSettings().catch((error) => { + this.log("[cloud-settings] Error refetching settings after update event:", error) + }) + } + } + + private shouldRefetchSettings(eventVersions: { organization: number; user: number }): boolean { + const currentOrgVersion = this.settings?.version ?? -1 + const currentUserVersion = this.userSettings?.version ?? -1 + + return eventVersions.organization > currentOrgVersion || eventVersions.user > currentUserVersion + } + private async fetchSettings(): Promise { const token = this.authService.getSessionToken() @@ -257,6 +289,9 @@ export class CloudSettingsService extends EventEmitter im this.userSettings = result.data await this.cacheSettings() this.emit("settings-updated", {} as Record) + + // Broadcast settings update event to other instances + this.broadcastSettingsUpdate() } return true @@ -266,6 +301,24 @@ export class CloudSettingsService extends EventEmitter im } } + private broadcastSettingsUpdate(): void { + // Broadcast settings update event to other instances + const bridge = BridgeOrchestrator.getInstance() + if (!bridge) { + return + } + + const versions = { + organization: this.settings?.version ?? 0, + user: this.userSettings?.version ?? 0, + } + + // Use the public method to publish settings update + bridge.publishSettingsUpdate(versions).catch((error) => { + this.log("[cloud-settings] Error broadcasting settings update event:", error) + }) + } + private async removeSettings(): Promise { this.settings = undefined this.userSettings = undefined diff --git a/packages/cloud/src/__tests__/CloudSettingsService.broadcast.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.broadcast.test.ts new file mode 100644 index 0000000000..28bad92b46 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudSettingsService.broadcast.test.ts @@ -0,0 +1,314 @@ +import type { ExtensionContext } from "vscode" +import type { OrganizationSettings, AuthService } from "@roo-code/types" + +import { CloudSettingsService } from "../CloudSettingsService.js" +import { BridgeOrchestrator } from "../bridge/index.js" + +vi.mock("../bridge/index.js", () => ({ + BridgeOrchestrator: { + getInstance: vi.fn(), + }, +})) + +vi.mock("../config", () => ({ + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +global.fetch = vi.fn() + +describe("CloudSettingsService - Settings Broadcast", () => { + let mockContext: ExtensionContext + let mockAuthService: { + getState: ReturnType + getSessionToken: ReturnType + hasActiveSession: ReturnType + on: ReturnType + } + let cloudSettingsService: CloudSettingsService + let mockLog: ReturnType + let mockBridgeInstance: { + publishSettingsUpdate: ReturnType + } + + const mockSettings: OrganizationSettings = { + version: 1, + defaultSettings: {}, + allowList: { + allowAll: true, + providers: {}, + }, + } + + const mockUserSettings = { + features: {}, + settings: { extensionBridgeEnabled: true }, + version: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as ExtensionContext + + mockAuthService = { + getState: vi.fn().mockReturnValue("active-session"), + getSessionToken: vi.fn().mockReturnValue("valid-token"), + hasActiveSession: vi.fn().mockReturnValue(true), + on: vi.fn(), + } + + mockLog = vi.fn() + + mockBridgeInstance = { + publishSettingsUpdate: vi.fn().mockResolvedValue(undefined), + } + + vi.mocked(BridgeOrchestrator.getInstance).mockReturnValue( + mockBridgeInstance as unknown as ReturnType, + ) + + cloudSettingsService = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService, mockLog) + }) + + afterEach(() => { + cloudSettingsService.dispose() + }) + + describe("broadcastSettingsUpdate", () => { + it("should broadcast settings update when user settings are updated", async () => { + // Initialize with existing settings + mockContext.globalState.get = vi.fn((key: string) => { + if (key === "organization-settings") return mockSettings + if (key === "user-settings") return mockUserSettings + return undefined + }) + + await cloudSettingsService.initialize() + + // Mock successful update response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + ...mockUserSettings, + version: 2, + }), + } as unknown as Response) + + // Update user settings + await cloudSettingsService.updateUserSettings({ taskSyncEnabled: true }) + + // Verify broadcast was called with correct versions + expect(mockBridgeInstance.publishSettingsUpdate).toHaveBeenCalledWith({ + organization: 1, + user: 2, + }) + }) + + it("should not broadcast if BridgeOrchestrator is not available", async () => { + vi.mocked(BridgeOrchestrator.getInstance).mockReturnValue(null) + + await cloudSettingsService.initialize() + + // Mock successful update response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + ...mockUserSettings, + version: 2, + }), + } as unknown as Response) + + // Update user settings + await cloudSettingsService.updateUserSettings({ taskSyncEnabled: true }) + + // Verify broadcast was not called + expect(mockBridgeInstance.publishSettingsUpdate).not.toHaveBeenCalled() + }) + + it("should handle broadcast errors gracefully", async () => { + mockBridgeInstance.publishSettingsUpdate.mockRejectedValue(new Error("Broadcast failed")) + + await cloudSettingsService.initialize() + + // Mock successful update response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + ...mockUserSettings, + version: 2, + }), + } as unknown as Response) + + // Update user settings + await cloudSettingsService.updateUserSettings({ taskSyncEnabled: true }) + + // Verify error was logged + expect(mockLog).toHaveBeenCalledWith( + "[cloud-settings] Error broadcasting settings update event:", + expect.any(Error), + ) + }) + }) + + describe("handleRemoteSettingsUpdate", () => { + it("should refetch settings when remote version is newer", async () => { + // Initialize with existing settings + mockContext.globalState.get = vi.fn((key: string) => { + if (key === "organization-settings") return mockSettings + if (key === "user-settings") return mockUserSettings + return undefined + }) + + await cloudSettingsService.initialize() + + // Mock fetch for refetch + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + organization: { ...mockSettings, version: 2 }, + user: { ...mockUserSettings, version: 2 }, + }), + } as unknown as Response) + + // Simulate remote settings update with newer versions + cloudSettingsService.handleRemoteSettingsUpdate({ + organization: 2, + user: 2, + }) + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify fetch was called + expect(fetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension-settings", { + headers: { + Authorization: "Bearer valid-token", + }, + }) + }) + + it("should not refetch settings when remote version is same or older", async () => { + // Initialize with existing settings + mockContext.globalState.get = vi.fn((key: string) => { + if (key === "organization-settings") return mockSettings + if (key === "user-settings") return mockUserSettings + return undefined + }) + + await cloudSettingsService.initialize() + + vi.mocked(fetch).mockClear() + + // Simulate remote settings update with same versions + cloudSettingsService.handleRemoteSettingsUpdate({ + organization: 1, + user: 1, + }) + + // Wait for any async operations + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify fetch was not called + expect(fetch).not.toHaveBeenCalled() + }) + + it("should refetch when only organization version is newer", async () => { + // Initialize with existing settings + mockContext.globalState.get = vi.fn((key: string) => { + if (key === "organization-settings") return mockSettings + if (key === "user-settings") return mockUserSettings + return undefined + }) + + await cloudSettingsService.initialize() + + // Mock fetch for refetch + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + organization: { ...mockSettings, version: 2 }, + user: mockUserSettings, + }), + } as unknown as Response) + + // Simulate remote settings update with newer org version only + cloudSettingsService.handleRemoteSettingsUpdate({ + organization: 2, + user: 1, + }) + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify fetch was called + expect(fetch).toHaveBeenCalled() + }) + + it("should refetch when only user version is newer", async () => { + // Initialize with existing settings + mockContext.globalState.get = vi.fn((key: string) => { + if (key === "organization-settings") return mockSettings + if (key === "user-settings") return mockUserSettings + return undefined + }) + + await cloudSettingsService.initialize() + + // Mock fetch for refetch + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + organization: mockSettings, + user: { ...mockUserSettings, version: 2 }, + }), + } as unknown as Response) + + // Simulate remote settings update with newer user version only + cloudSettingsService.handleRemoteSettingsUpdate({ + organization: 1, + user: 2, + }) + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify fetch was called + expect(fetch).toHaveBeenCalled() + }) + + it("should handle refetch errors gracefully", async () => { + await cloudSettingsService.initialize() + + // Mock fetch to fail + vi.mocked(fetch).mockRejectedValue(new Error("Network error")) + + // Simulate remote settings update + cloudSettingsService.handleRemoteSettingsUpdate({ + organization: 2, + user: 2, + }) + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify error was logged (the actual error is logged by fetchSettings) + expect(mockLog).toHaveBeenCalledWith( + "[cloud-settings] Error fetching extension settings:", + expect.any(Error), + ) + }) + }) + + describe("setupBridgeListener", () => { + it("should be callable without errors", () => { + // This method is a placeholder for now + // The actual listener setup happens in BridgeOrchestrator + expect(() => cloudSettingsService.setupBridgeListener()).not.toThrow() + }) + }) +}) diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts index 15b5c65eb2..50c09d2d4d 100644 --- a/packages/cloud/src/bridge/BridgeOrchestrator.ts +++ b/packages/cloud/src/bridge/BridgeOrchestrator.ts @@ -6,12 +6,14 @@ import { type TaskLike, type CloudUserInfo, type ExtensionBridgeCommand, + type ExtensionBridgeEvent, type TaskBridgeCommand, type StaticAppProperties, type GitProperties, ConnectionState, ExtensionSocketEvents, TaskSocketEvents, + ExtensionBridgeEventName, } from "@roo-code/types" import { SocketTransport } from "./SocketTransport.js" @@ -215,6 +217,14 @@ export class BridgeOrchestrator { this.extensionChannel?.handleCommand(message) }) + // Listen for settings update events from other instances + socket.on(ExtensionSocketEvents.RELAYED_EVENT, (event: ExtensionBridgeEvent) => { + if (event.type === ExtensionBridgeEventName.SettingsUpdated) { + console.log(`[BridgeOrchestrator] on(${ExtensionSocketEvents.RELAYED_EVENT}) -> SettingsUpdated`) + this.handleSettingsUpdateEvent(event) + } + }) + socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { console.log( `[BridgeOrchestrator] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, @@ -331,4 +341,63 @@ export class BridgeOrchestrator { // so we need to set up listeners again. this.setupSocketListeners() } + + /** + * Publish a settings update event to be broadcast to other instances + */ + public async publishSettingsUpdate(versions: { organization: number; user: number }): Promise { + if (this.extensionChannel) { + await this.extensionChannel.publishSettingsUpdate(versions) + } + } + + private handleSettingsUpdateEvent(event: ExtensionBridgeEvent): void { + // Extract versions from the event + if (event.type !== ExtensionBridgeEventName.SettingsUpdated) { + return + } + + type SettingsUpdateEvent = ExtensionBridgeEvent & { + versions: { organization: number; user: number } + } + + const versions = (event as SettingsUpdateEvent).versions + if (!versions) { + return + } + + // Notify CloudSettingsService about the update + // We use dynamic import to avoid circular dependency + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { CloudService } = require("../CloudService.js") as { + CloudService: { + hasInstance(): boolean + instance: { + settingsService: { + handleRemoteSettingsUpdate?: (versions: { organization: number; user: number }) => void + } | null + } + } + } + + if (CloudService.hasInstance()) { + const cloudService = CloudService.instance + const settingsService = cloudService.settingsService + if (settingsService && "handleRemoteSettingsUpdate" in settingsService) { + const handleRemoteSettingsUpdate = settingsService.handleRemoteSettingsUpdate as (versions: { + organization: number + user: number + }) => void + handleRemoteSettingsUpdate(versions) + } + } + } catch (error) { + console.error( + `[BridgeOrchestrator] Error notifying CloudSettingsService of settings update: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } } diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 72f62ffd92..dcdda80d51 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -205,6 +205,18 @@ export class ExtensionChannel extends BaseChannel< }) } + /** + * Publish a settings update event to be broadcast to other instances + */ + public async publishSettingsUpdate(versions: { organization: number; user: number }): Promise { + await this.publish(ExtensionSocketEvents.EVENT, { + type: ExtensionBridgeEventName.SettingsUpdated, + instance: await this.updateInstance(), + versions, + timestamp: Date.now(), + }) + } + private cleanupListeners(): void { this.eventListeners.forEach((listener, eventName) => { // Cast is safe because we only store valid event names from eventMapping. diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index a566e4ec6a..2257508d3f 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -422,6 +422,7 @@ export enum ExtensionBridgeEventName { InstanceRegistered = "instance_registered", InstanceUnregistered = "instance_unregistered", HeartbeatUpdated = "heartbeat_updated", + SettingsUpdated = "settings_updated", } export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ @@ -532,6 +533,15 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), + z.object({ + type: z.literal(ExtensionBridgeEventName.SettingsUpdated), + instance: extensionInstanceSchema, + versions: z.object({ + organization: z.number(), + user: z.number(), + }), + timestamp: z.number(), + }), ]) export type ExtensionBridgeEvent = z.infer