Connected connectors can be deleted and reconnected; mirror sweeps stale rows and ops serialize per org
This commit is contained in:
1 parent
eb16baff50
commit
38bd3588e7
11 files changed
+187
-33
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export interface ConnectComposioToolkitResult {
|
||||
redirectUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DisconnectComposioToolkitInput {
|
||||
orgId: string;
|
||||
toolkitSlug: string;
|
||||
}
|
||||
|
||||
export interface RefreshComposioAccountsInput {
|
||||
orgId: string;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user