mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Add pool deletion and simplify adoption UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
74ed4efead
commit
6338e35dc1
6 files changed
+92
-17
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" } };
|
||||
|
||||
Reference in new issue
Block a user