From 6338e35dc1aff03702625807f98d750b8d2c79be Mon Sep 17 00:00:00 2001
From: Dotta
Date: Mon, 5 Oct 2026 09:07:41 -0500
Subject: [PATCH] Add pool deletion and simplify adoption UI
Co-Authored-By: Paperclip
---
doc/connections/AI-CONNECTION-ROUTERS.md | 9 +++++
.../__tests__/ai-connection-router.test.ts | 35 +++++++++++++++++++
server/src/routes/ai-connections.ts | 8 +++++
server/src/services/ai-connection-router.ts | 35 +++++++++++++++----
.../ai-connections/AiConnectionField.tsx | 18 +++++-----
.../stories/connection-pools.stories.tsx | 4 +--
6 files changed, 92 insertions(+), 17 deletions(-)
diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md
index 078bd6f7329..282b66f5dc5 100644
--- a/doc/connections/AI-CONNECTION-ROUTERS.md
+++ b/doc/connections/AI-CONNECTION-ROUTERS.md
@@ -49,6 +49,13 @@ the chosen threshold wait; known exhaustion defers pinned turns. The deferred
run schedules `ai_connection_pool_wait` without spending the failure retry
budget, and the UI labels that retry as **Pool exhausted**.
+Operators create, list, edit and delete pools in that plugin page. Deletion uses
+`DELETE /api/companies/:companyId/ai-connection-pools/:poolId` with the current
+`expectedRevision`. Core disables and archives the virtual connection, retaining
+its task pins, cursor and run records. It rejects stale edits and new allocations;
+already admitted runs keep their concrete recovery evidence. Cleanup remains
+available when experimental routing or the plugin is disabled.
+
Configuration and committed selection are recorded in activity records. Runs
record the selected member/profile and override notes in the local run log and
recovery context. These records stay in the instance database.
@@ -70,3 +77,5 @@ to build the preview.
Pool configuration stories belong to the private Cloud plugin's own Storybook
in `extensions/plugin-connection-pool/storybook/`. Both previews use fictional
accounts; they do not call live providers or mutate a Paperclip instance.
+The Core overview links to Cloud's pool list preview on port 6010; from there,
+use **New pool** or select an existing pool to edit, inspect or delete it.
diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts
index 7466eb98062..02650b98293 100644
--- a/server/src/__tests__/ai-connection-router.test.ts
+++ b/server/src/__tests__/ai-connection-router.test.ts
@@ -74,12 +74,47 @@ describe("durable, authorized connection routing", () => {
const pool = await makePool(); const url = `/api/companies/${companyId}/ai-connection-pools`;
expect((await request(app).get(url)).status).toBe(200);
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connection-pools`)).status).toBe(403);
+ expect((await request(app).delete(`/api/companies/${otherCompanyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
actor = { ...actor, memberships: [{ companyId, status: "active", membershipRole: "viewer" }] };
expect((await request(app).get(url)).status).toBe(403);
expect((await request(app).post(url).send({ pluginKey, config: pool })).status).toBe(403);
expect((await request(app).get(`${url}/${pool.id}/inspection`)).status).toBe(403);
+ expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
actor = { type: "agent", agentId, companyId, source: "agent_jwt" } as unknown as typeof actor;
expect((await request(app).get(url)).status).toBe(403);
+ expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
+ actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
+ expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409);
+ expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200);
+ expect((await request(app).get(url)).body.some((entry: { id: string }) => entry.id === pool.id)).toBe(false);
+ });
+ it("deletes pools without erasing pins, cursors, or admitted recovery evidence", async () => {
+ const pool = await makePool(); const selected = await resolve(pool.id, "retained");
+ await expect(service().remove(otherCompanyId, pool.id, pool.revision, "alice")).rejects.toThrow("not found");
+ await expect(service().remove(companyId, pool.id, pool.revision + 1, "alice")).rejects.toThrow("reload before deleting");
+ await service().remove(companyId, pool.id, pool.revision, "alice");
+ expect((await service().list(companyId)).some(entry => entry.id === pool.id)).toBe(false);
+ expect((await service().selectable(companyId, "alice")).some(entry => entry.id === pool.id)).toBe(false);
+ expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
+ const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
+ await expect(resolve(pool.id, "new")).rejects.toThrow("choose another AI connection");
+ expect(await resolve(pool.id, "retained", { persisted: selected })).toEqual(selected);
+ const { name, enabled, mode, thresholdPercent, members: savedMembers } = pool;
+ await expect(service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision + 1, config: { name, enabled, mode, thresholdPercent, members: savedMembers } }, "alice")).rejects.toThrow("not found");
+ });
+ it("does not commit a selection when its pool is deleted during a proposal", async () => {
+ const pool = await makePool();
+ proposal.mockImplementationOnce(async () => { await service().remove(companyId, pool.id, pool.revision, "alice"); return { kind: "selected", memberId: members[0]!.id }; });
+ await expect(resolve(pool.id, "deleted-during-selection")).rejects.toThrow("choose another AI connection");
+ expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
+ const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(0);
+ });
+ it("allows cleanup while experimental routing and its plugin are disabled", async () => {
+ const pool = await makePool();
+ await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false });
+ await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
+ try { await expect(service().remove(companyId, pool.id, pool.revision, "alice")).resolves.toEqual({ ok: true }); }
+ finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
});
it("rotates new tasks across qualified backends and retains pins on turns, resets and service restart", async () => {
const pool = await makePool();
diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts
index 1a6a3725f51..adcb505e5a1 100644
--- a/server/src/routes/ai-connections.ts
+++ b/server/src/routes/ai-connections.ts
@@ -197,6 +197,14 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ const companyId = req.params.companyId as string;
+ assertBoard(req); assertCompanyAccess(req, companyId);
+ if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
+ const poolId = z.string().uuid().parse(req.params.poolId);
+ const { expectedRevision } = z.object({ expectedRevision: z.number().int().positive() }).strict().parse(req.body);
+ res.json(await pools.remove(companyId, poolId, expectedRevision, getActorInfo(req).actorId));
+ });
function assertLocalOperator(req: Request) {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId as string);
diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts
index 67708d42e76..3db4a692cb9 100644
--- a/server/src/services/ai-connection-router.ts
+++ b/server/src/services/ai-connection-router.ts
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, ne, sql } from "drizzle-orm";
import { aiConnectionPools, aiConnectionRouterCursors, aiConnectionTaskPins, companySecrets, connectionGrants, instanceSettings, plugins, toolApplications, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db";
import { aiConnectionPoolConfigSchema, isAiConnectionCompatible, type AiConnectionPool, type AiConnectionPoolMember, type AiConnectionRouterRequest, type AiConnectionRouterSelection, type AiConnectionPoolSaveInput, type AiConnectionUsage } from "@paperclipai/shared";
import { isCodexLocalKnownModel, codexLocalReasoningEffortsForModel } from "@paperclipai/adapter-codex-local";
@@ -77,8 +77,10 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
if (!(await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Enable AI connection routers in Experimental settings", { code: "ai_connection_router_disabled" });
}
async function list(companyId: string) {
- const rows = await db.select().from(aiConnectionPools).where(eq(aiConnectionPools.companyId, companyId));
- return rows.map((row): AiConnectionPool => ({ ...row.config, id: row.id, companyId, pluginKey: row.pluginKey, revision: row.revision }));
+ const rows = await db.select({ pool: aiConnectionPools }).from(aiConnectionPools)
+ .innerJoin(toolConnections, and(eq(toolConnections.id, aiConnectionPools.id), eq(toolConnections.companyId, aiConnectionPools.companyId)))
+ .where(and(eq(aiConnectionPools.companyId, companyId), ne(toolConnections.status, "archived")));
+ return rows.map(({ pool: row }): AiConnectionPool => ({ ...row.config, id: row.id, companyId, pluginKey: row.pluginKey, revision: row.revision }));
}
async function selectable(companyId: string, userId: string) {
if (!(await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) return [];
@@ -120,6 +122,8 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
if (input.id) {
const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, id), eq(aiConnectionPools.companyId, input.companyId), eq(aiConnectionPools.pluginKey, pluginKey))).for("update");
if (!row) throw notFound("Connection pool not found");
+ const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, id), eq(toolConnections.companyId, input.companyId)));
+ if (!connection || connection.status === "archived") throw notFound("Connection pool not found");
if (input.expectedRevision !== row.revision) throw conflict("Pool changed; reload before saving");
await tx.update(aiConnectionPools).set({ config, revision: row.revision + 1, updatedAt: new Date() }).where(eq(aiConnectionPools.id, id));
await tx.update(toolConnections).set({ name: config.name, enabled: config.enabled, updatedAt: new Date() }).where(and(eq(toolConnections.id, id), eq(toolConnections.companyId, input.companyId)));
@@ -137,6 +141,21 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
});
return (await list(input.companyId)).find((p) => p.id === id)!;
}
+ async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string) {
+ // Archive the virtual connection; cascading deletion would erase task pins.
+ // Cleanup remains available when the experimental flag or plugin is off.
+ await db.transaction(async tx => {
+ const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, poolId), eq(aiConnectionPools.companyId, companyId))).for("update");
+ if (!row) throw notFound("Connection pool not found");
+ const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, poolId), eq(toolConnections.companyId, companyId)));
+ if (!connection || connection.status === "archived") throw notFound("Connection pool not found");
+ if (row.revision !== expectedRevision) throw conflict("Pool changed; reload before deleting");
+ await tx.update(aiConnectionPools).set({ config: { ...row.config, enabled: false }, revision: row.revision + 1, updatedAt: new Date() }).where(eq(aiConnectionPools.id, poolId));
+ await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(and(eq(toolConnections.id, poolId), eq(toolConnections.companyId, companyId)));
+ await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } });
+ });
+ return { ok: true };
+ }
async function usageCacheKey(member: AiConnectionPoolMember, companyId: string) {
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.id, member.binding.grantId)));
if (!grant) return null;
@@ -188,8 +207,12 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
}
await enabled();
for (let attempt = 0; attempt < 20; attempt++) {
- const [row] = await db.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, input.poolId), eq(aiConnectionPools.companyId, input.companyId)));
- if (!row || !row.config.enabled) throw unprocessable("Enable the selected connection pool");
+ const [record] = await db.select({ pool: aiConnectionPools, connectionStatus: toolConnections.status }).from(aiConnectionPools)
+ .innerJoin(toolConnections, and(eq(toolConnections.id, aiConnectionPools.id), eq(toolConnections.companyId, aiConnectionPools.companyId)))
+ .where(and(eq(aiConnectionPools.id, input.poolId), eq(aiConnectionPools.companyId, input.companyId)));
+ if (!record || record.connectionStatus === "archived") throw unprocessable("This connection pool is unavailable; choose another AI connection");
+ const row = record.pool;
+ if (!row.config.enabled) throw unprocessable("Enable the selected connection pool");
const plugin = await owner(row.pluginKey);
if (!workerManager?.isRunning(plugin.id)) throw unprocessable("The connection router worker is unavailable; retry when it is ready");
const [pin] = await db.select().from(aiConnectionTaskPins).where(pinWhere);
@@ -246,5 +269,5 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
}
throw conflict("Concurrent pool selections changed the cursor; retry this task");
}
- return { list, selectable, inspect, save, resolve };
+ return { list, selectable, inspect, save, remove, resolve };
}
diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx
index 9754e2b5578..726cfa24f85 100644
--- a/ui/src/components/ai-connections/AiConnectionField.tsx
+++ b/ui/src/components/ai-connections/AiConnectionField.tsx
@@ -132,7 +132,7 @@ export function AiConnectionField({
{compatiblePools.map(pool => )}
{value?.mode === "router" && !compatiblePools.some(pool => pool.id === value.connectionId) && }
- {value?.mode === "router" && New tasks rotate. Existing tasks keep their account and harness. Reset any incompatible existing session before adoption.}
+ {value?.mode === "router" && New tasks rotate. Existing tasks keep their account.}
}
{value?.mode !== "router" &&
- Adopt Connections for {agentName}
+ {pendingAdoption?.mode === "router" ? `Use ${accounts.data?.pools?.find(pool => pool.id === pendingAdoption.connectionId)?.name ?? "this pool"}?` : `Adopt Connections for ${agentName}`}
- Saving validates access to the selected connection. Existing sessions keep their account or require an explicit reset before adoption.
+ {pendingAdoption?.mode === "router" ? "Reset existing sessions that use an account outside this pool." : "Saving validates access to the selected connection. Existing sessions keep their account or require an explicit reset before adoption."}
-
+ {pendingAdoption?.mode !== "router" &&
{pendingAdoption?.mode === "responsible_user"
? `Responsible user’s default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider)?.name ?? "Not connected"}. Other users use their own default.`
- : pendingAdoption?.mode === "router" ? `Task-pinned connection pool. Existing sessions must use a pool member or be reset.` : accounts.data?.connections.find(
+ : accounts.data?.connections.find(
(account) => account.id === pendingAdoption?.connectionId,
)?.name}
-
-
+
}
+ {pendingAdoption?.mode !== "router" &&
After adoption, missing credentials block execution. Previous
authentication will not be used as a fallback.
-