diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index e4784b5e..7361bfe3 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -342,6 +342,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("composio_connect_failed", error instanceof Error ? error.message : String(error)); } }); + ipcMain.handle(IPC_CHANNELS.disconnectComposioToolkit, async (_event, input) => { + try { + await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug); + notifyDataChanged(); + return ok(snapshot.load()); + } catch (error) { + return fail("composio_disconnect_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => { try { await composioConnections.refreshAccounts(input.orgId); diff --git a/src/main/services/composio/composio-client.ts b/src/main/services/composio/composio-client.ts index 85270005..40d28f53 100644 --- a/src/main/services/composio/composio-client.ts +++ b/src/main/services/composio/composio-client.ts @@ -93,19 +93,34 @@ export class ComposioClient { } async listConnectedAccounts(userId: string): Promise { - const params = new URLSearchParams({ user_ids: userId, limit: "50" }); - const response = await this.request("GET", `${BASE_URL}/connected_accounts?${params.toString()}`); - if (!Array.isArray(response.items)) return []; - return (response.items as Array>).map((item) => { - const toolkit = (item.toolkit ?? {}) as Record; - const authConfig = (item.auth_config ?? {}) as Record; - return { - id: String(item.id ?? ""), - toolkitSlug: String(toolkit.slug ?? "").toLowerCase(), - status: String(item.status ?? ""), - authConfigId: String(authConfig.id ?? ""), - createdAt: String(item.created_at ?? ""), - }; + const accounts: ComposioConnectedAccount[] = []; + let cursor: string | null = null; + do { + const params = new URLSearchParams({ user_ids: userId, limit: "100" }); + if (cursor) params.set("cursor", cursor); + const response = await this.request("GET", `${BASE_URL}/connected_accounts?${params.toString()}`); + const items = Array.isArray(response.items) ? response.items as Array> : []; + for (const item of items) { + const toolkit = (item.toolkit ?? {}) as Record; + const authConfig = (item.auth_config ?? {}) as Record; + accounts.push({ + id: String(item.id ?? ""), + toolkitSlug: String(toolkit.slug ?? "").toLowerCase(), + status: String(item.status ?? ""), + authConfigId: String(authConfig.id ?? ""), + createdAt: String(item.created_at ?? ""), + }); + } + cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null; + if (items.length === 0) break; + } while (cursor); + return accounts; + } + + /** Idempotent: an account already gone in Composio (404) counts as deleted. */ + async deleteConnectedAccount(accountId: string): Promise { + await this.request("DELETE", `${BASE_URL}/connected_accounts/${encodeURIComponent(accountId)}`, undefined, { + tolerateNotFound: true, }); } @@ -128,7 +143,12 @@ export class ComposioClient { }; } - private async request(method: string, url: string, body?: Record): Promise> { + private async request( + method: string, + url: string, + body?: Record, + options?: { tolerateNotFound?: boolean }, + ): Promise> { return new Promise((resolve, reject) => { const request = net.request({ method, url }); request.setHeader("x-api-key", this.apiKey); @@ -139,6 +159,10 @@ export class ComposioClient { request.on("response", (response) => { response.on("data", (chunk) => { responseData += chunk.toString(); }); response.on("end", () => { + if (response.statusCode === 404 && options?.tolerateNotFound) { + resolve({}); + return; + } if (response.statusCode && response.statusCode >= 400) { reject(new Error(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`)); return; diff --git a/src/main/services/composio/composio-connection-service.ts b/src/main/services/composio/composio-connection-service.ts index 5b533997..fc129d08 100644 --- a/src/main/services/composio/composio-connection-service.ts +++ b/src/main/services/composio/composio-connection-service.ts @@ -17,8 +17,14 @@ let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null * accounts against them. One connected account per toolkit per organization, * mirrored into connector_accounts so agent runs and the Connectors page * read local state instead of calling out on every frame. + * + * Mutations and refreshes are serialized per organization: a refresh whose + * server read predates a delete or connect would otherwise land afterwards + * and resurrect (or sweep) rows from its stale snapshot. */ export class ComposioConnectionService { + private static queues = new Map>(); + constructor(private readonly db: DatabaseSync) {} listCatalog(): Promise { @@ -29,14 +35,32 @@ export class ComposioConnectionService { * Starts (or restarts) the company's connection for a toolkit: initiates a * connected account against the toolkit's configured auth config via the * link flow and records the pending account locally as INITIALIZING — the - * poll that follows adopts Composio's real status. The returned redirect - * URL completes authorization (or API-key entry) in the browser. + * poll that follows adopts Composio's real status. Any previous account + * for the toolkit is deleted first so reconnecting replaces instead of + * accumulating accounts in Composio. The returned redirect URL completes + * authorization (or API-key entry) in the browser. */ - async connectToolkit(orgId: string, toolkitSlug: string): Promise { + connectToolkit(orgId: string, toolkitSlug: string): Promise { + return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug)); + } + + /** See class docs: linearizes connect/refresh/disconnect for one org. */ + private serialize(orgId: string, operation: () => Promise): Promise { + const tail = ComposioConnectionService.queues.get(orgId) ?? Promise.resolve(); + const next = tail.then(operation, operation); + ComposioConnectionService.queues.set(orgId, next.then(() => undefined, () => undefined)); + return next; + } + + private async connectToolkitNow(orgId: string, toolkitSlug: string): Promise { const slug = normalizeSlug(toolkitSlug); const client = this.requireClient(); const entry = (await this.catalog(client)).find((item) => item.slug === slug); if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`); + const previous = this.db.prepare( + "select id from connector_accounts where org_id = ? and toolkit_slug = ?", + ).get(orgId, slug) as { id: string } | undefined; + if (previous) await client.deleteConnectedAccount(previous.id); const account = await client.createConnectedAccount({ authConfigId: entry.authConfigId, userId: getComposioUserId(this.db, orgId), @@ -56,11 +80,21 @@ export class ComposioConnectionService { return { accountId: account.id, redirectUrl: account.redirectUrl }; } - /** Pulls account statuses from Composio and updates the local mirror. */ - async refreshAccounts(orgId: string): Promise { + /** + * Pulls account statuses from Composio and updates the local mirror, in + * both directions: statuses adopt Composio's truth, and local rows whose + * account no longer exists in Composio are removed along with their + * grants — deleting an account in the dashboard cleans up here too. + */ + refreshAccounts(orgId: string): Promise { + return this.serialize(orgId, () => this.refreshAccountsNow(orgId)); + } + + private async refreshAccountsNow(orgId: string): Promise { const client = this.requireClient(); const accounts = await client.listConnectedAccounts(getComposioUserId(this.db, orgId)); const now = new Date().toISOString(); + const liveIds = new Set(accounts.map((account) => account.id)); for (const account of accounts) { if (!account.toolkitSlug) continue; this.db.prepare(` @@ -82,6 +116,36 @@ export class ComposioConnectionService { now, ); } + const localRows = this.db.prepare("select id, toolkit_slug from connector_accounts where org_id = ?") + .all(orgId) as Array<{ id: string; toolkit_slug: string }>; + for (const row of localRows) { + if (liveIds.has(row.id)) continue; + this.removeLocalAccount(orgId, row.toolkit_slug); + } + } + + /** + * Deletes the company's connection for a toolkit: removes the connected + * account in Composio (already-gone counts as deleted), then the local + * mirror row and every agent grant that pointed at it. + */ + disconnectToolkit(orgId: string, toolkitSlug: string): Promise { + return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug)); + } + + private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise { + const slug = normalizeSlug(toolkitSlug); + const row = this.db.prepare( + "select id from connector_accounts where org_id = ? and toolkit_slug = ?", + ).get(orgId, slug) as { id: string } | undefined; + if (!row) return; + await this.requireClient().deleteConnectedAccount(row.id); + this.removeLocalAccount(orgId, slug); + } + + private removeLocalAccount(orgId: string, slug: string): void { + this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ?").run(orgId, slug); + this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ?").run(orgId, slug); } private requireClient(): ComposioClient { diff --git a/src/preload/index.ts b/src/preload/index.ts index a4ea247d..7726f1df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -84,6 +84,7 @@ const api: DesktopApi = { setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input), listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog), connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), + disconnectComposioToolkit: (input) => invoke(IPC_CHANNELS.disconnectComposioToolkit, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index c91142b1..001580d8 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -13,7 +13,7 @@ import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; */ export function ConnectorsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); - const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions); + const { catalog, busySlug, busyAction, connectError, connect, disconnect, retry } = useConnectorCatalog(orgId, actions); const accounts = useMemo( () => snapshot.connectorAccounts.filter((account) => account.orgId === orgId), @@ -58,7 +58,16 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { )} - + {catalog.phase === "error" && (
diff --git a/src/renderer/src/pages/connectors/ConnectedList.tsx b/src/renderer/src/pages/connectors/ConnectedList.tsx index a48e51c7..cf400b7b 100644 --- a/src/renderer/src/pages/connectors/ConnectedList.tsx +++ b/src/renderer/src/pages/connectors/ConnectedList.tsx @@ -1,4 +1,4 @@ -import { Pill } from "../../shared/ui"; +import { Button, IconButton, Pill } from "../../shared/ui"; import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain"; import { ToolkitTile } from "./ToolkitCatalog"; @@ -15,18 +15,27 @@ const STATUS_TONE: Record = { /** * The company's connected toolkits, each with the roster of agents the CEO * has granted access to. Grants are the CEO's call — made with its - * grant_connector tool — so the roster here is read-only. + * grant_connector tool — so the roster here is read-only. Reconnect restarts + * the link flow for the toolkit; delete removes the connection entirely. */ export function ConnectedList({ accounts, grants, agents, logoBySlug, + busySlug, + busyAction, + onReconnect, + onDelete, }: { accounts: ConnectorAccountRecord[]; grants: ConnectorGrantRecord[]; agents: AgentRecord[]; logoBySlug: Map; + busySlug: string | null; + busyAction: "connect" | "disconnect" | null; + onReconnect: (toolkitSlug: string) => void; + onDelete: (toolkitSlug: string) => void; }) { if (accounts.length === 0) return null; const agentNames = new Map(agents.map((agent) => [agent.id, agent.name])); @@ -39,6 +48,7 @@ export function ConnectedList({ .filter((grant) => grant.toolkitSlug === account.toolkitSlug) .map((grant) => agentNames.get(grant.agentId)) .filter((name): name is string => Boolean(name)); + const busy = busySlug !== null; return (
@@ -50,6 +60,17 @@ export function ConnectedList({ /> {account.toolkitName} {account.status.charAt(0) + account.status.slice(1).toLowerCase()} + + + onDelete(account.toolkitSlug)} + /> +
{holders.length > 0 && (
diff --git a/src/renderer/src/pages/connectors/ToolkitCatalog.tsx b/src/renderer/src/pages/connectors/ToolkitCatalog.tsx index 5354a5aa..e913d154 100644 --- a/src/renderer/src/pages/connectors/ToolkitCatalog.tsx +++ b/src/renderer/src/pages/connectors/ToolkitCatalog.tsx @@ -53,7 +53,7 @@ export function ToolkitCatalog({ entries: ConnectorCatalogEntry[]; connectedSlugs: Set; busySlug: string | null; - onConnect: (entry: ConnectorCatalogEntry) => void; + onConnect: (toolkitSlug: string) => void; }) { const [query, setQuery] = useState(""); const filtered = useMemo(() => { @@ -100,7 +100,7 @@ export function ToolkitCatalog({ {connected ? ( Connected ) : ( - )} diff --git a/src/renderer/src/pages/connectors/useConnectorCatalog.ts b/src/renderer/src/pages/connectors/useConnectorCatalog.ts index 63c70169..827543db 100644 --- a/src/renderer/src/pages/connectors/useConnectorCatalog.ts +++ b/src/renderer/src/pages/connectors/useConnectorCatalog.ts @@ -20,12 +20,14 @@ const CONNECT_POLL_ATTEMPTS = 15; export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { catalog: CatalogState; busySlug: string | null; + busyAction: "connect" | "disconnect" | null; connectError: string | null; - connect: (entry: ConnectorCatalogEntry) => void; + connect: (toolkitSlug: string) => void; + disconnect: (toolkitSlug: string) => void; retry: () => void; } { const [catalogSlot, setCatalogSlot] = useState(null); - const [busySlug, setBusySlug] = useState(null); + const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | null>(null); const [connectError, setConnectError] = useState(null); const pollTimer = useRef | null>(null); const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" }; @@ -89,24 +91,40 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { poll(0); }, [refreshAccounts, orgId]); - const connect = useCallback((entry: ConnectorCatalogEntry) => { - setBusySlug(entry.slug); + const connect = useCallback((toolkitSlug: string) => { + setBusy({ slug: toolkitSlug, action: "connect" }); setConnectError(null); void window.agentCompany - .connectComposioToolkit({ orgId, toolkitSlug: entry.slug }) + .connectComposioToolkit({ orgId, toolkitSlug }) .then(async (result) => { if (!result.ok) { setConnectError(result.error.message); return; } - pollUntilActive(entry.slug); + pollUntilActive(toolkitSlug); if (result.data.redirectUrl) { const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl); if (!opened.ok) setConnectError(opened.error.message); } }) - .finally(() => setBusySlug(null)); + .finally(() => setBusy(null)); }, [orgId, pollUntilActive]); - return { catalog, busySlug, connectError, connect, retry: load }; + const disconnect = useCallback((toolkitSlug: string) => { + setBusy({ slug: toolkitSlug, action: "disconnect" }); + setConnectError(null); + void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug })) + .catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error))) + .finally(() => setBusy(null)); + }, [actions, orgId]); + + return { + catalog, + busySlug: busy?.slug ?? null, + busyAction: busy?.action ?? null, + connectError, + connect, + disconnect, + retry: load, + }; } diff --git a/src/shared/commands/composio.ts b/src/shared/commands/composio.ts index ea668edc..f70e07b0 100644 --- a/src/shared/commands/composio.ts +++ b/src/shared/commands/composio.ts @@ -16,6 +16,11 @@ export interface ConnectComposioToolkitResult { redirectUrl: string | null; } +export interface DisconnectComposioToolkitInput { + orgId: string; + toolkitSlug: string; +} + export interface RefreshComposioAccountsInput { orgId: string; } diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 72bca067..7917829d 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -66,6 +66,7 @@ import type { ConnectComposioToolkitInput, ConnectComposioToolkitResult, ConnectorCatalogEntry, + DisconnectComposioToolkitInput, RefreshComposioAccountsInput, SearchInput, SendMessageInput, @@ -178,6 +179,7 @@ export interface DesktopApi { setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise>; listConnectorCatalog(): Promise>; connectComposioToolkit(input: ConnectComposioToolkitInput): Promise>; + disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise>; refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index b5aae24c..c12c067e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -76,6 +76,7 @@ export const IPC_CHANNELS = { deleteSprint: "agentcompany:delete-sprint", listConnectorCatalog: "agentcompany:list-connector-catalog", connectComposioToolkit: "agentcompany:connect-composio-toolkit", + disconnectComposioToolkit: "agentcompany:disconnect-composio-toolkit", refreshComposioAccounts: "agentcompany:refresh-composio-accounts", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event",