From 3730c6a604552b9b65b6f31dc502c5a98b31799a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 5 Jun 2026 12:31:54 +0200 Subject: [PATCH 1/2] Fix integration scope save latency --- src/main/broker.test.ts | 8 ++++++++ src/main/broker.ts | 16 ++++++++++++--- src/main/integrations.ts | 43 ++++++++++++++++++++++------------------ 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 3194ad00..ba2559de 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -579,6 +579,14 @@ describe('BrokerManager local + cloud coexistence', () => { await manager.shutdown() }) + it('treats project-scoped refreshEventStream as a no-op before a broker session starts', async () => { + const manager = new BrokerManager() + + await expect(manager.refreshEventStream(PROJECT_ID, 'startup-refresh')).resolves.toBeUndefined() + + await manager.shutdown() + }) + it('does not let a global event-stream refresh overwrite session windows', async () => { const manager = new BrokerManager() const firstWindow = createMockWindow() diff --git a/src/main/broker.ts b/src/main/broker.ts index 8709a712..726f8cba 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1723,9 +1723,19 @@ export class BrokerManager { async refreshEventStream(projectId?: string, reason = 'manual', win?: BrowserWindow): Promise { const normalizedProjectId = projectId?.trim() - const sessions = normalizedProjectId - ? await this.getOrAwaitSessionsForProject(normalizedProjectId) - : Array.from(this.sessions.values()) + let sessions: BrokerSession[] + if (normalizedProjectId) { + try { + sessions = await this.getOrAwaitSessionsForProject(normalizedProjectId) + } catch (err) { + if (err instanceof Error && err.message === 'Relay workspace not started — select the project first') { + return + } + throw err + } + } else { + sessions = Array.from(this.sessions.values()) + } const rebindWindow = normalizedProjectId ? win : undefined for (const session of sessions) { diff --git a/src/main/integrations.ts b/src/main/integrations.ts index eef375a5..c0b316e2 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -747,21 +747,21 @@ export class IntegrationsManager { const normalizedProvider = toRelayfileProvider(provider) const normalizedResource = resource.trim().toLowerCase() if (!normalizedResource) throw new Error('Integration option resource is required') + const workspaceId = await getAccountWorkspaceId(accountWorkspaceReadyRetryOptions()) - const payload = await this.withWorkspaceHandle(async (handle) => { - try { - return await handle.requestJson({ - operation: 'listIntegrationOptions', - method: 'GET', - path: `api/v1/workspaces/${handle.workspaceId}/integrations/${encodeURIComponent(normalizedProvider)}/options/${encodeURIComponent(normalizedResource)}` - }) as unknown - } catch (error) { - if (normalizedProvider === 'slack' && normalizedResource === 'channels') { - return this.listLegacySlackChannelOptions(handle, normalizedProvider, error) - } + let payload: unknown + try { + payload = await this.fetchJson( + 'GET', + `api/v1/workspaces/${workspaceId}/integrations/${encodeURIComponent(normalizedProvider)}/options/${encodeURIComponent(normalizedResource)}` + ) + } catch (error) { + if (normalizedProvider === 'slack' && normalizedResource === 'channels') { + payload = await this.listLegacySlackChannelOptions(workspaceId, normalizedProvider, error) + } else { throw error } - }) + } const rawOptions = isRecord(payload) && Array.isArray(payload.options) ? payload.options : [] return rawOptions @@ -776,7 +776,7 @@ export class IntegrationsManager { } private async listLegacySlackChannelOptions( - handle: WorkspaceHandle, + workspaceId: string, provider: string, originalError: unknown ): Promise<{ options: IntegrationOption[] }> { @@ -786,11 +786,10 @@ export class IntegrationsManager { try { do { const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '' - const payload = await handle.requestJson({ - operation: 'listSlackAvailableChannels', - method: 'GET', - path: `api/v1/workspaces/${handle.workspaceId}/integrations/${encodeURIComponent(provider)}/channels/available${query}` - }) as SlackChannelOptionResponse + const payload = await this.fetchJson( + 'GET', + `api/v1/workspaces/${workspaceId}/integrations/${encodeURIComponent(provider)}/channels/available${query}` + ) const channels = Array.isArray(payload.channels) ? payload.channels : [] for (const channel of channels) { if (!isRecord(channel)) continue @@ -971,8 +970,8 @@ export class IntegrationsManager { } await this.persistIntegration(projectId, integration) - await this.syncAgentState(projectId, integration.notifyAgent) this.emit({ type: 'integration-added', projectId, integration }) + this.syncAgentStateEventually(projectId, integration.notifyAgent) return integration } @@ -1500,6 +1499,12 @@ export class IntegrationsManager { return integration?.visibleInProject !== false } + private syncAgentStateEventually(projectId: string, notifyAgent: boolean): void { + void this.syncAgentState(projectId, notifyAgent).catch((error) => { + console.warn('[integrations] Failed to sync integration state:', toErrorMessage(error)) + }) + } + private async syncAgentState(projectId: string, notifyAgent: boolean): Promise { let integrations = this.visibleIntegrationsForProject(projectId) await this.syncLocalMounts() From ce79a7b8cc52f9f60ea307e85bd1c9ed8617a5df Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Fri, 5 Jun 2026 10:58:14 +0000 Subject: [PATCH 2/2] chore: apply pr-reviewer fixes for #90 --- src/main/integrations.test.ts | 282 ++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/main/integrations.test.ts diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts new file mode 100644 index 00000000..ca6b50cf --- /dev/null +++ b/src/main/integrations.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type FetchCall = { + url: string + init?: RequestInit +} + +const mock = vi.hoisted(() => { + const fetchCalls: FetchCall[] = [] + const initialIntegration = () => ({ + id: 'slack-integration-1', + name: 'Slack', + type: 'slack', + provider: 'slack', + integrationId: 'slack-integration-1', + scope: {}, + mountPaths: ['/slack/channels'], + connectedAt: '2026-06-05T00:00:00.000Z', + notifyAgent: true, + subscribeAgent: false, + downloadHistoricalData: false, + visibleInProject: true + }) + const store = { + projects: [ + { + id: 'project-1', + name: 'Project 1', + rootPath: '/tmp/project-1', + roots: [], + channels: ['general'], + channelPeople: {}, + integrations: [initialIntegration()] + } + ], + activeProjectId: 'project-1' + } + let mountReconcilePromise: Promise = Promise.resolve() + + const jsonResponse = (payload: unknown, status = 200, statusText = 'OK') => ({ + ok: status >= 200 && status < 300, + status, + statusText, + json: async () => payload + }) + + const fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const normalizedUrl = String(url) + fetchCalls.push({ url: normalizedUrl, init }) + const parsed = new URL(normalizedUrl) + + if (parsed.pathname === '/api/v1/integrations/catalog') { + return jsonResponse({ + providers: [ + { + id: 'slack', + displayName: 'Slack', + version: '1', + backends: ['nango'], + defaultMountPaths: ['/slack/channels'] + }, + { + id: 'google-mail', + displayName: 'Gmail', + version: '1', + backends: ['nango'], + defaultMountPaths: ['/google-mail/messages'] + } + ] + }) + } + + if (parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations/google-mail/options/channels') { + return jsonResponse({ + options: [ + { value: 'inbox', label: 'Inbox', hint: 'mail' }, + { value: 'sent' } + ] + }) + } + + if (parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations/slack/options/channels') { + return jsonResponse({ error: 'not found' }, 404, 'Not Found') + } + + if ( + parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations/slack/channels/available' && + parsed.searchParams.get('cursor') === 'cursor-2' + ) { + return jsonResponse({ + channels: [{ id: 'C456', name: 'private-team', is_private: true }] + }) + } + + if (parsed.pathname === '/api/v1/workspaces/account-workspace-id/integrations/slack/channels/available') { + return jsonResponse({ + channels: [{ id: 'C123', name: 'general' }], + nextCursor: 'cursor-2' + }) + } + + throw new Error(`unexpected fetch: ${normalizedUrl}`) + }) + + return { + fetchCalls, + store, + fetch, + browserWindow: { + getAllWindows: vi.fn(() => []) + }, + resolveCloudAuth: vi.fn(async () => ({ + apiUrl: 'https://cloud.example', + accessToken: 'account-token' + })), + getAccountWorkspaceId: vi.fn(async () => 'account-workspace-id'), + accountWorkspaceReadyRetryOptions: vi.fn(() => ({ retryAttempts: 1, retryDelayMs: 0 })), + loadStore: vi.fn(() => store), + saveStore: vi.fn(() => undefined), + integrationMountManager: { + ensureMounted: vi.fn(() => mountReconcilePromise), + currentWorkspaceId: vi.fn(() => null), + localPathsFor: vi.fn(() => []), + stop: vi.fn(async () => undefined) + }, + integrationEventBridge: { + reconcile: vi.fn(async () => undefined), + closeAll: vi.fn(async () => undefined) + }, + cloudAgentManager: { + updateMountPaths: vi.fn(async () => undefined) + }, + brokerManager: { + listAgents: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined) + }, + ensureProjectIntegrationsLink: vi.fn(async () => undefined), + removeProjectIntegrationsLink: vi.fn(async () => undefined), + resetStore() { + store.projects[0].integrations = [initialIntegration()] + }, + setMountReconcilePromise(value: Promise) { + mountReconcilePromise = value + } + } +}) + +vi.mock('electron', () => ({ + BrowserWindow: mock.browserWindow, + shell: { + openExternal: vi.fn(async () => undefined) + } +})) + +vi.mock('./auth', () => ({ + resolveCloudAuth: mock.resolveCloudAuth, + getAccountWorkspaceId: mock.getAccountWorkspaceId, + accountWorkspaceReadyRetryOptions: mock.accountWorkspaceReadyRetryOptions, + getApiUrl: vi.fn(() => 'https://cloud.example') +})) + +vi.mock('./store', () => ({ + loadStore: mock.loadStore, + saveStore: mock.saveStore +})) + +vi.mock('./integration-mounts', () => ({ + integrationMountManager: mock.integrationMountManager, + integrationMountRootForWorkspace: vi.fn((workspaceId: string) => `/tmp/relayfile/${workspaceId}`) +})) + +vi.mock('./integration-event-bridge', () => ({ + integrationEventBridge: mock.integrationEventBridge, + integrationSubscriptionSummaries: vi.fn(() => []) +})) + +vi.mock('./cloud-agent', () => ({ + cloudAgentManager: mock.cloudAgentManager +})) + +vi.mock('./broker', () => ({ + brokerManager: mock.brokerManager +})) + +vi.mock('./integration-symlinks', () => ({ + PROJECT_INTEGRATIONS_LINK_NAME: '.integrations', + ensureProjectIntegrationsLink: mock.ensureProjectIntegrationsLink, + removeProjectIntegrationsLink: mock.removeProjectIntegrationsLink +})) + +vi.mock('./relay-workspace', () => ({ + getRelayWorkspaceManager: vi.fn(() => ({ + withHandle: vi.fn(), + getWorkspaceHandle: vi.fn() + })) +})) + +import { IntegrationsManager } from './integrations' + +describe('IntegrationsManager', () => { + beforeEach(() => { + mock.fetchCalls.splice(0) + mock.fetch.mockClear() + mock.browserWindow.getAllWindows.mockClear() + mock.resolveCloudAuth.mockClear() + mock.getAccountWorkspaceId.mockClear() + mock.accountWorkspaceReadyRetryOptions.mockClear() + mock.loadStore.mockClear() + mock.saveStore.mockClear() + mock.integrationMountManager.ensureMounted.mockClear() + mock.integrationMountManager.currentWorkspaceId.mockClear() + mock.integrationMountManager.localPathsFor.mockClear() + mock.integrationMountManager.stop.mockClear() + mock.integrationEventBridge.reconcile.mockClear() + mock.cloudAgentManager.updateMountPaths.mockClear() + mock.brokerManager.listAgents.mockClear() + mock.brokerManager.sendMessage.mockClear() + mock.ensureProjectIntegrationsLink.mockClear() + mock.removeProjectIntegrationsLink.mockClear() + mock.resetStore() + mock.setMountReconcilePromise(Promise.resolve()) + vi.stubGlobal('fetch', mock.fetch) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('lists integration options through the account workspace API', async () => { + const manager = new IntegrationsManager() + + await expect(manager.listOptions('project-1', 'gmail', 'channels')).resolves.toEqual([ + { value: 'inbox', label: 'Inbox', hint: 'mail' }, + { value: 'sent', label: 'sent' } + ]) + + const optionsCall = mock.fetchCalls.find((call) => + call.url.endsWith('/api/v1/workspaces/account-workspace-id/integrations/google-mail/options/channels') + ) + expect(optionsCall?.init?.headers).toMatchObject({ + Authorization: 'Bearer account-token' + }) + }) + + it('falls back to legacy Slack channel options through the account workspace API', async () => { + const manager = new IntegrationsManager() + + await expect(manager.listOptions('project-1', 'slack', 'channels')).resolves.toEqual([ + { value: 'C123', label: '#general' }, + { value: 'C456', label: '#private-team', hint: 'private' } + ]) + + expect(mock.fetchCalls.map((call) => new URL(call.url).pathname + new URL(call.url).search)).toContain( + '/api/v1/workspaces/account-workspace-id/integrations/slack/channels/available?cursor=cursor-2' + ) + }) + + it('does not block updateScope on integration state sync', async () => { + let finishMountReconcile!: () => void + mock.setMountReconcilePromise(new Promise((resolve) => { + finishMountReconcile = resolve + })) + const manager = new IntegrationsManager() + + const updated = await manager.updateScope( + 'project-1', + 'slack-integration-1', + { channels: ['C123'] }, + ['/slack/channels/C123'] + ) + + expect(updated).toMatchObject({ + integrationId: 'slack-integration-1', + scope: { channels: ['C123'] }, + mountPaths: ['/slack/channels/C123'] + }) + expect(mock.integrationMountManager.ensureMounted).toHaveBeenCalled() + + finishMountReconcile() + }) +})