Connected connectors can be deleted and reconnected; mirror sweeps stale rows and ops serialize per org

This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 12:09:31 -04:00
1 parent eb16baff50
commit 38bd3588e7
11 files changed
+187 -33

No files matched your search

+9
View File
@@ -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);
+38 -14
View File
@@ -93,19 +93,34 @@ export class ComposioClient {
}
async listConnectedAccounts(userId: string): Promise<ComposioConnectedAccount[]> {
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<Record<string, unknown>>).map((item) => {
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
const authConfig = (item.auth_config ?? {}) as Record<string, unknown>;
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<Record<string, unknown>> : [];
for (const item of items) {
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
const authConfig = (item.auth_config ?? {}) as Record<string, unknown>;
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<void> {
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<string, unknown>): Promise<Record<string, unknown>> {
private async request(
method: string,
url: string,
body?: Record<string, unknown>,
options?: { tolerateNotFound?: boolean },
): Promise<Record<string, unknown>> {
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;
@@ -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<string, Promise<unknown>>();
constructor(private readonly db: DatabaseSync) {}
listCatalog(): Promise<ConnectorCatalogEntry[]> {
@@ -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<ConnectComposioToolkitResult> {
connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug));
}
/** See class docs: linearizes connect/refresh/disconnect for one org. */
private serialize<T>(orgId: string, operation: () => Promise<T>): Promise<T> {
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<ConnectComposioToolkitResult> {
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<void> {
/**
* 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<void> {
return this.serialize(orgId, () => this.refreshAccountsNow(orgId));
}
private async refreshAccountsNow(orgId: string): Promise<void> {
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<void> {
return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug));
}
private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise<void> {
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 {
+1
View File
@@ -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) => {
+11 -2
View File
@@ -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) {
</div>
)}
<ConnectedList accounts={accounts} grants={grants} agents={agents} logoBySlug={logoBySlug} />
<ConnectedList
accounts={accounts}
grants={grants}
agents={agents}
logoBySlug={logoBySlug}
busySlug={busySlug}
busyAction={busyAction}
onReconnect={connect}
onDelete={disconnect}
/>
{catalog.phase === "error" && (
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
@@ -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<string, "green" | "amber" | "red" | undefined> = {
/**
* 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<string, string | null>;
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 (
<div key={account.id} style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
<div className="row" style={{ gap: 10 }}>
@@ -50,6 +60,17 @@ export function ConnectedList({
/>
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
<Button size="small" disabled={busy} onClick={() => onReconnect(account.toolkitSlug)}>
{busySlug === account.toolkitSlug && busyAction === "connect" ? "Opening…" : "Reconnect"}
</Button>
<IconButton
icon="x"
label={`Delete ${account.toolkitName} connection`}
disabled={busy}
onClick={() => onDelete(account.toolkitSlug)}
/>
</span>
</div>
{holders.length > 0 && (
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
@@ -53,7 +53,7 @@ export function ToolkitCatalog({
entries: ConnectorCatalogEntry[];
connectedSlugs: Set<string>;
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 ? (
<Pill tone="green" dot>Connected</Pill>
) : (
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry)}>
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry.slug)}>
{busySlug === entry.slug ? "Opening…" : "Connect"}
</Button>
)}
@@ -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<CatalogSlot>(null);
const [busySlug, setBusySlug] = useState<string | null>(null);
const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const pollTimer = useRef<ReturnType<typeof setTimeout> | 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,
};
}
+5
View File
@@ -16,6 +16,11 @@ export interface ConnectComposioToolkitResult {
redirectUrl: string | null;
}
export interface DisconnectComposioToolkitInput {
orgId: string;
toolkitSlug: string;
}
export interface RefreshComposioAccountsInput {
orgId: string;
}
+2
View File
@@ -66,6 +66,7 @@ import type {
ConnectComposioToolkitInput,
ConnectComposioToolkitResult,
ConnectorCatalogEntry,
DisconnectComposioToolkitInput,
RefreshComposioAccountsInput,
SearchInput,
SendMessageInput,
@@ -178,6 +179,7 @@ export interface DesktopApi {
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
listConnectorCatalog(): Promise<DesktopResult<ConnectorCatalogEntry[]>>;
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise<DesktopResult<AppSnapshot>>;
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
+1
View File
@@ -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",