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. -

+

}
diff --git a/ui/storybook/stories/connection-pools.stories.tsx b/ui/storybook/stories/connection-pools.stories.tsx index 30f4621434e..a83192fb197 100644 --- a/ui/storybook/stories/connection-pools.stories.tsx +++ b/ui/storybook/stories/connection-pools.stories.tsx @@ -67,7 +67,7 @@ function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) { const [enabled, setEnabled] = useState(scenario === "flag-on"); const overview = scenario === "overview"; return
-

Experimental connection pools

Core integration · production components with fixture accounts. Configure the pool itself in Cloud’s plugin Storybook.

+ {scenario !== "adoption" &&

Experimental connection pools

Create and manage pools in Cloud Storybook
} {(overview || scenario.startsWith("flag")) && } {(overview || ["selector", "legacy", "adoption", "unavailable", "read-only"].includes(scenario)) &&

Agent connection

; export const AllCoreSurfaces: Story = { args: { scenario: "overview" } }; export const ConnectionSelector: Story = { args: { scenario: "selector" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), ""); await expect(c.getByText("Responsible user’s connection", { exact: false })).toBeVisible(); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(c.getByText(/New tasks rotate/)).toBeVisible(); } }; export const CompatibleLegacyHarness: Story = { args: { scenario: "legacy" } }; -export const ExistingSessionAdoption: Story = { args: { scenario: "adoption" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click(c.getByRole("button", { name: "Choose a managed connection" })); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(await within(canvasElement.ownerDocument.body).findByRole("dialog")).toHaveTextContent("Task-pinned connection pool"); } }; +export const ExistingSessionAdoption: Story = { args: { scenario: "adoption" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click(c.getByRole("button", { name: "Choose a managed connection" })); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(await within(canvasElement.ownerDocument.body).findByRole("dialog", { name: "Use Research accounts?" })).toHaveTextContent("Reset existing sessions that use an account outside this pool."); } }; export const PoolUnavailable: Story = { args: { scenario: "unavailable" } }; export const ReadOnlySelection: Story = { args: { scenario: "read-only" } }; export const ComposerModelsFromBothProviders: Story = { args: { scenario: "models" } };