Add pool deletion and simplify adoption UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 09:07:41 -05:00
1 parent 74ed4efead
commit 6338e35dc1
6 files changed
+92 -17

No files matched your search

+9
View File
@@ -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.
@@ -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();
+8
View File
@@ -197,6 +197,14 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
const body = z.object({ pluginKey: z.string().min(1).max(160), id: z.string().uuid().optional(), expectedRevision: z.number().int().positive().optional(), config: aiConnectionPoolConfigSchema }).strict().parse(req.body);
res.json(await pools.save(body.pluginKey, { ...body, companyId }, getActorInfo(req).actorId));
});
router.delete("/companies/:companyId/ai-connection-pools/:poolId", async (req, res) => {
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);
+29 -6
View File
@@ -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 };
}
@@ -132,7 +132,7 @@ export function AiConnectionField({
{compatiblePools.map(pool => <option key={pool.id} value={pool.id}>{pool.name} · Experimental pool</option>)}
{value?.mode === "router" && !compatiblePools.some(pool => pool.id === value.connectionId) && <option value={value.connectionId}>Pool unavailable — enable routing and the pool</option>}
</select>
{value?.mode === "router" && <span className="text-muted-foreground">New tasks rotate. Existing tasks keep their account and harness. Reset any incompatible existing session before adoption.</span>}
{value?.mode === "router" && <span className="text-muted-foreground">New tasks rotate. Existing tasks keep their account.</span>}
</label>}
{value?.mode !== "router" && <AiConnectionPicker
requirement={{ companyId, provider }}
@@ -159,22 +159,22 @@ export function AiConnectionField({
>
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onCloseAutoFocus={restoreFocus}>
<DialogHeader>
<DialogTitle>Adopt Connections for {agentName}</DialogTitle>
<DialogTitle>{pendingAdoption?.mode === "router" ? `Use ${accounts.data?.pools?.find(pool => pool.id === pendingAdoption.connectionId)?.name ?? "this pool"}?` : `Adopt Connections for ${agentName}`}</DialogTitle>
<DialogDescription>
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."}
</DialogDescription>
</DialogHeader>
<p className="text-sm">
{pendingAdoption?.mode !== "router" && <p className="text-sm">
{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}
</p>
<p className="text-xs text-muted-foreground">
</p>}
{pendingAdoption?.mode !== "router" && <p className="text-xs text-muted-foreground">
After adoption, missing credentials block execution. Previous
authentication will not be used as a fallback.
</p>
</p>}
<DialogFooter>
<Button
variant="ghost"
@@ -188,7 +188,7 @@ export function AiConnectionField({
setPendingAdoption(undefined);
}}
>
Use this binding when saved
{pendingAdoption?.mode === "router" ? "Use pool" : "Use this binding when saved"}
</Button>
</DialogFooter>
</DialogContent>
@@ -67,7 +67,7 @@ function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) {
const [enabled, setEnabled] = useState(scenario === "flag-on");
const overview = scenario === "overview";
return <QueryClientProvider client={client}><main className="mx-auto max-w-3xl space-y-6 p-6">
<header className="space-y-2"><h1 className="text-xl font-semibold">Experimental connection pools</h1><p className="text-sm text-muted-foreground">Core integration · production components with fixture accounts. Configure the pool itself in Cloud’s plugin Storybook.</p></header>
{scenario !== "adoption" && <header className="space-y-2"><h1 className="text-xl font-semibold">Experimental connection pools</h1><a className="text-sm text-primary underline" href="http://127.0.0.1:6010/?path=/story/plugins-connection-pools--pool-list">Create and manage pools in Cloud Storybook</a></header>}
{(overview || scenario.startsWith("flag")) && <AiConnectionRoutersSetting checked={enabled} onCheckedChange={setEnabled} managed={scenario === "flag-managed" ? { managed: true, managedBy: "paperclip-cloud" } : undefined} />}
{(overview || ["selector", "legacy", "adoption", "unavailable", "read-only"].includes(scenario)) && <section className="space-y-2"><h2 className="text-sm font-semibold">Agent connection</h2><AiConnectionField companyId={companyId} agentId={agentId} agentName={agent.name}
adapterType={scenario === "legacy" ? "claude_local" : "codex_local"} routerAdapterType={scenario === "legacy" ? "claude_local" : "paperclip_runner"}
@@ -83,7 +83,7 @@ type Story = StoryObj<typeof meta>;
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" } };