Integrate connection pools with the native connector workflow

Declare pooled connectors through plugin metadata and use the existing catalog, setup header, account page, and sidebar. Select only saved connections, preserve ordered membership, and keep optional runtime and usage settings in Advanced. Route ordinary removal through pin-preserving pool archival.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 10:40:38 -05:00
1 parent 6ed022757d
commit a913ebe4f4
26 files changed
+533 -98

No files matched your search

+20 -6
View File
@@ -43,18 +43,30 @@ Native recovery retains concrete routing evidence and can finish after the
router flag or plugin is disabled or uninstalled; it revalidates underlying
account access and never makes a new allocation.
The private Cloud plugin owns round-robin and quota policy. Its configuration
page uses authenticated, company-scoped Core pool APIs. New allocations over
The private Cloud plugin owns round-robin and quota policy. Its manifest declares
`aiConnectionRouter: { name, description }` alongside `ai.connections.route`.
Core adds that connector to the regular catalog and hosts its setup and account
management using authenticated, company-scoped pool APIs. No separate plugin
page or sidebar entry is needed. New allocations over
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
Operators choose **Connectors → AI connection pool → Add connection pool**,
select existing accounts, arrange their order, and create a paused connection.
The normal account page supports rename, member changes, enabling, and removal.
Optional usage and runtime defaults live under **Advanced**. Creating another
account opens the normal catalog in a new tab so the pool draft stays intact.
The catalog marks experimental-disabled or unavailable plugins as unavailable.
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.
available when experimental routing or the plugin is disabled. The ordinary
connector removal endpoint uses the same pool archive transaction. Ordinary
connection updates cannot bypass pool config revisions.
Configuration and committed selection are recorded in activity records. Runs
record the selected member/profile and override notes in the local run log and
@@ -77,5 +89,7 @@ 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.
Core also owns **Connectors / Pool host** stories for the generic native
extension. The overview links to Cloud's **In Connectors** preview on port 6010;
its stories mount these same production routes, header, sidebar and tokens.
**Full Setup And Management** exercises the complete catalog-to-pool journey.
@@ -75,3 +75,22 @@ export interface AiConnectionRouterSelection {
runtimeConfig: Record<string, unknown>;
notes: string[];
}
/** Stable, collision-free catalog identity across plugin reinstallations. */
export function aiConnectionRouterSlug(pluginKey: string): string {
return `ai-router-${Array.from(pluginKey, character => character.charCodeAt(0).toString(16).padStart(2, "0")).join("")}`;
}
export function aiConnectionRouterAppDefinition(pluginKey: string, descriptor: { name: string; description: string }, availability = { available: true } as { available: boolean; reason?: string }): import("./types/app-definition.js").AppDefinition {
return {
schemaVersion: 1, slug: aiConnectionRouterSlug(pluginKey), ...descriptor,
categories: ["ai"], branding: { logoUrl: "/brands/connection-pool.svg" }, urlPatterns: [],
aiConnectionRouter: { pluginKey }, availability,
methods: [{ key: "pool", purpose: "ai", transport: "runtime_auth", auth: "none", ownershipModes: ["customer"], whenToUse: "Use existing AI connections", guidanceMd: "", riskTier: "S1" }],
};
}
export function aiConnectionRouterPluginKey(connection: { config?: Record<string, unknown> | null }): string | null {
const router = connection.config?.aiRouter;
return router && typeof router === "object" && "pluginKey" in router && typeof router.pluginKey === "string" ? router.pluginKey : null;
}
+2
View File
@@ -2817,3 +2817,5 @@ export * from "./browser-use.js";
export * from "./types/skill-source.js";
export * from "./validators/skill-source.js";
export * from "./github-skill-repository.js";
export { aiConnectionRouterSlug, aiConnectionRouterAppDefinition, aiConnectionRouterPluginKey } from "./ai-connection-router.js";
+1 -1
View File
@@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export type SelfServeMcpAuthMode =
| "dcr"
+2
View File
@@ -687,6 +687,8 @@ export interface PaperclipPluginManifestV1 {
minimumPaperclipVersion?: PluginMinimumHostVersion;
/** Capabilities this plugin requires from the host. Enforced at runtime. */
capabilities: PluginCapability[];
/** Opt into the native pooled-connection catalog, setup, and management UI. */
aiConnectionRouter?: { name: string; description: string };
/** Entrypoint paths relative to the package root. */
entrypoints: {
/** Path to the worker entrypoint (required). */
@@ -7,5 +7,5 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",mLine truncated
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionSchema=z.object({aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
@@ -33,6 +33,11 @@ describe("plugin capability constants", () => {
});
describe("plugin manifest validators", () => {
it("requires routing authority for native pooled connector declarations without a custom UI bundle", () => {
const manifest = { id: "example.pool", apiVersion: 1, version: "0.1.0", displayName: "Pool", description: "Pool", author: "Tests", categories: ["connector"], entrypoints: { worker: "worker.js" }, aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" } };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["ui.page.register"] }).success).toBe(false);
expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["ai.connections.route"] }).aiConnectionRouter).toEqual(manifest.aiConnectionRouter);
});
it("accepts existing-style plugins that do not request access or authorization capabilities", () => {
const parsed = pluginManifestV1Schema.parse({
id: "paperclip.compat-dashboard",
+5
View File
@@ -782,6 +782,7 @@ export const pluginManifestV1Schema = z.object({
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
).optional(),
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
aiConnectionRouter: z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(500) }).strict().optional(),
entrypoints: z.object({
worker: z.string().min(1),
ui: z.string().min(1).optional(),
@@ -835,6 +836,10 @@ export const pluginManifestV1Schema = z.object({
// declare every capability it needs up-front; silently having more features
// than capabilities would cause runtime rejections.
if (manifest.aiConnectionRouter && !manifest.capabilities.includes("ai.connections.route")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ai.connections.route is required for aiConnectionRouter", path: ["capabilities"] });
}
// tools require agent.tools.register (PLUGIN_SPEC.md §11)
if (manifest.tools && manifest.tools.length > 0) {
if (!manifest.capabilities.includes("agent.tools.register")) {
@@ -17,6 +17,7 @@ import { secretService } from "../services/secrets.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import express from "express";
import request from "supertest";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { aiConnectionRoutes } from "../routes/ai-connections.js";
import { errorHandler } from "../middleware/index.js";
import { heartbeatService, buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js";
@@ -29,7 +30,7 @@ let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(), secondAgentId = randomUUID(), pluginId = randomUUID();
const pluginKey = "fixture.ai-router";
const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], entrypoints: { worker: "worker.js" } };
const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" }, entrypoints: { worker: "worker.js" } };
let members: AiConnectionPoolMember[];
const proposal = vi.fn(async (_id: string, _method: string, request: AiConnectionRouterRequest): Promise<AiConnectionRouterResult> => {
const next = (request.memberOrder.indexOf(request.lastMemberId ?? "") + 1) % request.memberOrder.length;
@@ -63,10 +64,35 @@ afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home)
describe("durable, authorized connection routing", () => {
it("defaults off and does not expose selectable pools before opt-in", async () => {
expect((await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters).toBe(false);
expect((await service().catalog())[0]?.availability).toEqual({ available: false, reason: "Enable AI connection routers in Experimental settings." });
await expect(makePool()).rejects.toThrow("Experimental");
expect(await service().selectable(companyId, "alice")).toEqual([]);
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
});
it("registers native connectors only for declared installed routers", async () => {
const entry = (await service().catalog())[0]!;
expect(entry.name).toBe("AI connection pool");
expect(entry.aiConnectionRouter).toEqual({ pluginKey });
expect(entry.availability?.available).toBe(true);
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
try {
expect((await service().catalog())[0]?.availability?.available).toBe(false);
await db.update(plugins).set({ status: "uninstalled" }).where(eq(plugins.id, pluginId));
expect(await service().catalog()).toEqual([]);
} finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
});
it("native connector removal retains durable pins and ordinary updates cannot bypass pool revisions", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "catalog-removal");
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", userId: "alice", source: "local_implicit", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] } as typeof req.actor; next(); });
app.use("/api", toolAccessRoutes(db)); app.use(errorHandler);
expect((await request(app).patch(`/api/tool-connections/${pool.id}`).send({ name: "Bypass" })).status).toBe(400);
expect((await request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(200);
expect((await service().list(companyId)).some(row => row.id === pool.id)).toBe(false);
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
expect(await resolve(pool.id, "catalog-removal", { persisted: selected })).toEqual(selected);
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, members[0]!.binding.connectionId)))[0]?.status).toBe("active");
});
it("requires company connection management authority on pool configuration routes", async () => {
const app = express(); app.use(express.json());
let actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
+16 -2
View File
@@ -1,3 +1,5 @@
import { aiConnectionRouterPluginKey } from "@paperclipai/shared";
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db";
@@ -854,9 +856,9 @@ function connectorEnrollmentPrincipal(req: Request): string {
: "Vercel Connect setup is disabled on this Paperclip instance.",
},
},
apps: APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) =>
apps: [...APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) =>
appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles)
),
), ...await aiConnectionRouterService(db).catalog()],
});
});
@@ -2082,6 +2084,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
if (aiConnectionRouterPluginKey(existing)) throw badRequest("Update this connection pool through its pool settings.");
const connection = await svc.updateConnection(existing.id, req.body);
const lifecycleChanges = classifyConnectionUpdate(
{ enabled: existing.enabled, config: existing.config },
@@ -2126,6 +2129,17 @@ function connectorEnrollmentPrincipal(req: Request): string {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
if (aiConnectionRouterPluginKey(existing)) {
assertBoard(req);
if (!await isToolConnectionManager(req, existing.companyId)) throw forbidden("A connection manager must remove this pool");
const router = aiConnectionRouterService(db);
const pool = (await router.list(existing.companyId)).find(pool => pool.id === existing.id);
if (!pool) throw notFound("Connection pool not found");
await router.remove(existing.companyId, existing.id, pool.revision, getActorInfo(req).actorId);
res.json({ ...existing, status: "archived", enabled: false });
return;
}
const applicationBefore = await svc.getApplication(existing.applicationId);
const { connection, removal } = await svc.archiveConnection(
existing.id,
+14 -4
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
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 { aiConnectionRouterAppDefinition, aiConnectionRouterSlug, 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";
import { models as claudeModels, claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local";
import { models as grokModels, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
@@ -76,6 +76,16 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
async function enabled() {
if (!(await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Enable AI connection routers in Experimental settings", { code: "ai_connection_router_disabled" });
}
async function catalog() {
const experimental = (await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters;
const installed = await db.select().from(plugins);
return installed.filter(plugin => plugin.status !== "uninstalled" && plugin.manifestJson.aiConnectionRouter && plugin.manifestJson.capabilities.includes("ai.connections.route"))
.map(plugin => aiConnectionRouterAppDefinition(plugin.pluginKey, plugin.manifestJson.aiConnectionRouter!, {
available: experimental && plugin.status === "ready",
...(!experimental ? { reason: "Enable AI connection routers in Experimental settings." }
: plugin.status !== "ready" ? { reason: "Enable the connection pool plugin in Plugins." } : {}),
}));
}
async function list(companyId: string) {
const rows = await db.select({ pool: aiConnectionPools }).from(aiConnectionPools)
.innerJoin(toolConnections, and(eq(toolConnections.id, aiConnectionPools.id), eq(toolConnections.companyId, aiConnectionPools.companyId)))
@@ -129,10 +139,10 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
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)));
} else {
const key = `plugin:${pluginKey}:ai-router`;
await tx.insert(toolApplications).values({ companyId: input.companyId, applicationKey: key, name: `${plugin.manifestJson.displayName} connections`, type: "paperclip_plugin", pluginId: plugin.id }).onConflictDoNothing();
await tx.insert(toolApplications).values({ companyId: input.companyId, applicationKey: key, name: plugin.manifestJson.aiConnectionRouter?.name ?? plugin.manifestJson.displayName, metadata: { sourceTemplateKey: aiConnectionRouterSlug(pluginKey) }, type: "paperclip_plugin", pluginId: plugin.id }).onConflictDoNothing();
const [application] = await tx.select().from(toolApplications).where(and(eq(toolApplications.companyId, input.companyId), eq(toolApplications.applicationKey, key)));
if (!application) throw new Error("Router application could not be created");
await tx.insert(toolConnections).values({ id, companyId: input.companyId, applicationId: application.id, name: config.name, uid: id, connectionPurpose: "ai", transport: "runtime_auth", enabled: config.enabled, status: "active", healthStatus: "ok", config: { aiRouter: { pluginKey } }, createdByUserId: userId });
await tx.insert(toolConnections).values({ id, companyId: input.companyId, applicationId: application.id, name: config.name, uid: id, connectionPurpose: "ai", transport: "runtime_auth", enabled: config.enabled, status: "active", healthStatus: "ok", config: { aiRouter: { pluginKey }, sourceTemplateKey: aiConnectionRouterSlug(pluginKey) }, createdByUserId: userId });
await tx.insert(aiConnectionPools).values({ id, companyId: input.companyId, pluginKey, config });
await tx.insert(aiConnectionRouterCursors).values({ poolId: id, companyId: input.companyId });
await tx.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: id, targetType: "company", targetId: input.companyId, createdByUserId: userId });
@@ -269,5 +279,5 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
}
throw conflict("Concurrent pool selections changed the cursor; retry this task");
}
return { list, selectable, inspect, save, remove, resolve };
return { catalog, list, selectable, inspect, save, remove, resolve };
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#8b8b8b" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/></svg>

After

Width:  |  Height:  |  Size: 248 B

+9
View File
@@ -0,0 +1,9 @@
import type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionUsage } from "@paperclipai/shared";
import { api } from "./client";
export type PoolInspection = Record<string, { reason: string; checkedAt: string | null; usage?: AiConnectionUsage }>;
export const aiConnectionPoolsApi = {
list: (companyId: string) => api.get<AiConnectionPool[]>(`/companies/${companyId}/ai-connection-pools`),
save: (companyId: string, body: { pluginKey: string; id?: string; expectedRevision?: number; config: AiConnectionPoolConfig }) => api.post<AiConnectionPool>(`/companies/${companyId}/ai-connection-pools`, body),
inspect: (companyId: string, id: string) => api.get<PoolInspection>(`/companies/${companyId}/ai-connection-pools/${id}/inspection`),
remove: (companyId: string, id: string, expectedRevision: number) => api.delete(`/companies/${companyId}/ai-connection-pools/${id}`, { expectedRevision }),
};
@@ -1,6 +1,6 @@
import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import { aiConnectionRouterPluginKey, aiConnectionRouterSlug, humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
@@ -58,6 +58,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
});
const connection = connectionQuery.data;
const poolPluginKey = connection && aiConnectionRouterPluginKey(connection);
const application = props.kind === "application"
? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId)
: null;
@@ -100,11 +101,11 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.map((tab) => (
{APP_TABS.filter(tab => !poolPluginKey || tab.key === "permissions").map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={tab.label}
label={poolPluginKey ? "Settings" : tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
@@ -129,6 +130,8 @@ function galleryEntryFor(
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryDisplayEntry | null {
const router = connection && aiConnectionRouterPluginKey(connection);
if (router) return apps.find(app => app.slug === aiConnectionRouterSlug(router)) ?? null;
if (application?.applicationKey) {
const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey);
if (keyed) return keyed;
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ExternalLink, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { aiConnectionRouterAppDefinition, type AiConnectionPool, type AiConnectionPoolConfig, type AiConnectionPoolMember, type AiManagedConnectionSummary, type ToolConnection } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { aiConnectionPoolsApi, type PoolInspection } from "@/api/ai-connection-pools";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { Link, useNavigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { AppLogo } from "@/pages/apps/AppLogo";
import { AppDetailHeader } from "@/pages/apps/AppDetail";
import { StepHeader } from "@/features/connections/ConnectionSetupHeader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel";
import { AI_PROVIDERS, aiMethodLabel } from "./model";
const configOf = ({ name, enabled, members, mode, thresholdPercent }: AiConnectionPoolConfig): AiConnectionPoolConfig => ({ name, enabled, members, mode, thresholdPercent });
const emptyConfig = (): AiConnectionPoolConfig => ({ name: "AI connection pool", enabled: false, members: [], mode: "round_robin", thresholdPercent: 90 });
function memberOf(account: AiManagedConnectionSummary): AiConnectionPoolMember {
const profile: AiConnectionPoolMember["profile"] = account.provider === "openai" ? { provider: "codex", model: "gpt-5.6-sol" }
: account.provider === "anthropic" ? { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }
: account.provider === "xai" ? { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" }
: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" };
return { id: crypto.randomUUID(), binding: { mode: account.ownership === "shared" ? "shared" : "delegated", provider: account.provider, method: account.method, connectionId: account.id, grantId: account.grantId }, profile };
}
/** Native connector surface shared by all plugins implementing the pool contract. */
export function AiConnectionPoolConnector({ pluginKey, connection }: { pluginKey: string; connection?: ToolConnection }) {
const { selectedCompanyId } = useCompany();
return selectedCompanyId ? <PoolConnector key={`${selectedCompanyId}:${connection?.id ?? pluginKey}`} companyId={selectedCompanyId} pluginKey={pluginKey} connection={connection} /> : <p>Select a company to continue.</p>;
}
function PoolConnector({ companyId, pluginKey, connection }: { companyId: string; pluginKey: string; connection?: ToolConnection }) {
const navigate = useNavigate();
const client = useQueryClient();
const { pushToast } = useToast();
const { setBreadcrumbs } = useBreadcrumbs();
const accountsQuery = useQuery({ queryKey: ["pool-accounts", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId) });
const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(companyId), queryFn: () => toolsApi.listGallery(companyId) });
const [editing, setEditing] = useState<AiConnectionPool>();
const [draft, setDraft] = useState(emptyConfig);
const [step, setStep] = useState(0);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickerMembers, setPickerMembers] = useState<AiConnectionPoolMember[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [removing, setRemoving] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const [inspectionError, setInspectionError] = useState("");
const [inspection, setInspection] = useState<PoolInspection>({});
const accounts = accountsQuery.data?.connections ?? [];
const entry = galleryQuery.data?.apps.find(app => app.aiConnectionRouter?.pluginKey === pluginKey);
const unavailable = galleryQuery.isSuccess && (!entry || entry.availability?.available === false);
const canManage = Boolean(accountsQuery.data?.canManageConnections);
const disabled = busy || !canManage || unavailable;
useEffect(() => {
if (connection && !editing) {
const pool = poolsQuery.data?.find(pool => pool.id === connection.id && pool.pluginKey === pluginKey);
if (pool) { setEditing(pool); setDraft(configOf(pool)); }
}
}, [poolsQuery.data, connection, editing, pluginKey]);
useEffect(() => {
setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: connection ? draft.name : "Add a connection pool" }]);
return () => setBreadcrumbs([]);
}, [connection, draft.name, setBreadcrumbs]);
useEffect(() => {
if (!editing) return;
let alive = true;
void aiConnectionPoolsApi.inspect(companyId, editing.id).then(value => { if (alive) setInspection(value); }).catch(() => { if (alive) setInspectionError("Usage unavailable."); });
return () => { alive = false; };
}, [companyId, editing]);
async function invalidate() {
await Promise.all([
client.invalidateQueries({ queryKey: ["ai-connection-pools", companyId] }),
client.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
client.invalidateQueries({ queryKey: queryKeys.tools.applications(companyId) }),
...(connection ? [client.invalidateQueries({ queryKey: queryKeys.tools.connection(connection.id) })] : []),
]);
}
async function save(config = draft) {
setBusy(true); setError("");
try {
const saved = await aiConnectionPoolsApi.save(companyId, { pluginKey, ...(editing ? { id: editing.id, expectedRevision: editing.revision } : {}), config });
setEditing(saved); setDraft(configOf(saved)); setRenaming(false);
await invalidate();
pushToast({ title: connection ? "Connection pool saved" : "Connection pool created", tone: "success" });
if (!connection) navigate(`/apps/${saved.id}/permissions`);
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setBusy(false); }
}
async function remove() {
if (!editing) return;
setBusy(true); setError("");
try { await aiConnectionPoolsApi.remove(companyId, editing.id, editing.revision); await invalidate(); navigate("/apps"); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { setBusy(false); }
}
function move(index: number, offset: number) {
setDraft(value => { const members = [...value.members]; [members[index], members[index + offset]] = [members[index + offset]!, members[index]!]; return { ...value, members }; });
}
const loadError = accountsQuery.error ?? poolsQuery.error ?? galleryQuery.error;
if (loadError) return <div className="space-y-4"><p role="alert" className="text-sm text-destructive">{loadError.message}</p><Button variant="outline" onClick={() => { void accountsQuery.refetch(); void poolsQuery.refetch(); void galleryQuery.refetch(); }}>Try again</Button></div>;
if (accountsQuery.isPending || poolsQuery.isPending || galleryQuery.isPending || (connection && !editing && poolsQuery.data?.some(pool => pool.id === connection.id))) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading connections…</p>;
if (connection && !editing) return <p role="alert">This connection pool is no longer available.</p>;
const name = entry?.name ?? "AI connection pool";
const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "" });
const orderedMembers = <ol aria-label="Connection order" className="divide-y divide-border rounded-lg border border-border">
{draft.members.map((member, index) => {
const account = accounts.find(account => account.id === member.binding.connectionId && account.grantId === member.binding.grantId);
const provider = AI_PROVIDERS[member.binding.provider];
return <li key={member.id} className="flex items-center gap-3 px-4 py-3">
<span className="w-4 text-center text-xs text-muted-foreground">{index + 1}</span>
<AppLogo name={provider.name} logoUrl={provider.logo} size={32} />
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{account?.name ?? "Connection unavailable"}</p><p className="text-xs text-muted-foreground">{account ? aiMethodLabel(account.provider, account.method) : "Restore access to this connection."}</p>{connection && draft.mode === "usage_aware" && account && <details className="mt-2 text-xs text-muted-foreground"><summary className="cursor-pointer">Usage</summary><div className="pt-3">{inspectionError ? <p role="status">{inspectionError}</p> : <AiConnectionUsagePanel account={account} cachedOnly observation={inspection[member.id]?.usage} />}</div></details>}</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled || index === 0} aria-label={`Move ${account?.name ?? "connection"} up`} onClick={() => move(index, -1)}><ArrowUp className="size-4" /></Button>
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled || index === draft.members.length - 1} aria-label={`Move ${account?.name ?? "connection"} down`} onClick={() => move(index, 1)}><ArrowDown className="size-4" /></Button>
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled} aria-label={`Remove ${account?.name ?? "connection"}`} onClick={() => setDraft({ ...draft, members: draft.members.filter(item => item.id !== member.id) })}><X className="size-4" /></Button>
</div>
</li>;
})}
{draft.members.length === 0 && <li className="px-4 py-6 text-sm text-muted-foreground">Add a connection to get started.</li>}
</ol>;
return <div className={connection ? "space-y-6" : "mx-auto max-w-2xl"}>
{connection ? <div className="flex items-start justify-between gap-4">
<AppDetailHeader appName={draft.name} connection={connection} logoEntry={logoEntry} brandKey={logoEntry.slug} allowRemoteLogo status={editing?.enabled ? { label: "Connected", tone: "connected" } : { label: "Paused", tone: "paused" }} actionCount={null} renaming={renaming} nameDraft={nameDraft} renamePending={disabled} onNameDraftChange={setNameDraft} onRenameStart={() => { if (!disabled) { setNameDraft(draft.name); setRenaming(true); } }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={value => void save({ ...draft, name: value })} />
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon-sm" aria-label="Manage connection pool"><MoreHorizontal className="size-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem variant="destructive" disabled={busy || !canManage} onSelect={() => { setError(""); setRemoving(true); }}><Trash2 />Remove connection</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
</div> : <StepHeader title="Add a connection pool" subtitle={step === 0 ? "Choose connections you already use." : "New tasks rotate in this order."} step="connect" activeIndex={step} labels={["Connections", "Order"]} appIdentity={{ name, logoUrl: logoEntry.branding.logoUrl }} />}
{unavailable && <p role="alert" className="text-sm text-destructive">{entry?.availability?.reason ?? "Enable the connection pool plugin in Plugins."}</p>}
{!canManage && <p role="alert" className="text-sm text-muted-foreground">A connection manager can edit this pool.</p>}
{error && !removing && <p role="alert" className="text-sm text-destructive">{error}</p>}
{!connection && step === 0 ? <ConnectionPicker accounts={accounts} selected={draft.members} disabled={disabled} onChange={members => setDraft({ ...draft, members })} onRefresh={() => void accountsQuery.refetch()} /> : <div className="space-y-4">
{connection && <div className="flex items-center justify-between gap-3"><h2 className="text-sm font-semibold">Connections</h2><Button variant="outline" size="sm" disabled={disabled} onClick={() => { setPickerMembers(draft.members); setPickerOpen(true); }}><Plus className="size-4" />Add connections</Button></div>}
{orderedMembers}
<p className="text-xs text-muted-foreground">{connection ? "Order changes apply to new tasks." : "Created paused. Enable it when you’re ready."}</p>
{connection && <>
<label className="flex items-center gap-2 text-sm"><Checkbox checked={draft.enabled} disabled={disabled} onCheckedChange={checked => setDraft({ ...draft, enabled: checked === true })} />Enable this pool</label>
<details className="rounded-lg border border-border"><summary className="cursor-pointer px-4 py-3 text-sm font-medium">Advanced</summary><fieldset disabled={disabled} className="space-y-4 border-t border-border p-4">
<label className="flex items-center gap-2 text-sm"><Checkbox checked={draft.mode === "usage_aware"} onCheckedChange={checked => setDraft({ ...draft, mode: checked ? "usage_aware" : "round_robin" })} />Skip connections near their usage limit</label>
{draft.mode === "usage_aware" && <label className="flex items-center gap-3 text-sm">Skip at<Input className="w-20" aria-label="Usage threshold" type="number" min={1} max={100} step={1} value={draft.thresholdPercent} onChange={event => setDraft({ ...draft, thresholdPercent: Number(event.target.value) })} />% usage</label>}
{draft.members.map((member, index) => <div key={member.id} className="space-y-2"><p className="text-sm font-medium">{accounts.find(account => account.id === member.binding.connectionId)?.name ?? "Connection unavailable"}</p><div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1 text-xs text-muted-foreground">Model<Input aria-label={`Model for connection ${index + 1}`} value={member.profile.model} onChange={event => setDraft({ ...draft, members: draft.members.map(row => row.id === member.id ? { ...row, profile: { ...row.profile, model: event.target.value } } : row) })} /></label><label className="space-y-1 text-xs text-muted-foreground">Effort (optional)<Input aria-label={`Effort for connection ${index + 1}`} value={member.profile.effort ?? ""} onChange={event => { const { effort: _old, ...profile } = member.profile; setDraft({ ...draft, members: draft.members.map(row => row.id === member.id ? { ...row, profile: { ...profile, ...(event.target.value.trim() ? { effort: event.target.value.trim() } : {}) } } : row) }); }} /></label></div></div>)}
</fieldset></details>
</>}
</div>}
<div className="mt-6 flex items-center justify-between gap-3 border-t border-border pt-4">
<Button variant="ghost" disabled={busy} onClick={() => connection ? navigate("/apps") : step ? setStep(0) : navigate("/apps")}>{!connection && step ? "Back" : "Cancel"}</Button>
<Button disabled={disabled || draft.members.length === 0 || !Number.isInteger(draft.thresholdPercent) || draft.thresholdPercent < 1 || draft.thresholdPercent > 100} onClick={() => !connection && step === 0 ? setStep(1) : void save()}>{busy && <Loader2 className="size-4 animate-spin" />}{busy ? "Saving…" : connection ? "Save changes" : step ? "Create pool" : "Continue"}</Button>
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>Add connections</DialogTitle><DialogDescription>Choose connections you already use.</DialogDescription></DialogHeader><ConnectionPicker accounts={accounts} selected={pickerMembers} disabled={disabled} onChange={setPickerMembers} onRefresh={() => void accountsQuery.refetch()} /><div className="flex items-center justify-between gap-3"><Button variant="ghost" onClick={() => setPickerOpen(false)}>Cancel</Button><Button onClick={() => { setDraft({ ...draft, members: pickerMembers }); setPickerOpen(false); }}>Done</Button></div></DialogContent></Dialog>
<AlertDialog open={removing} onOpenChange={open => { if (!busy) setRemoving(open); }}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Remove {draft.name}?</AlertDialogTitle><AlertDialogDescription>The connections in this pool are kept.</AlertDialogDescription></AlertDialogHeader>{error && <p role="alert" className="text-sm text-destructive">{error}</p>}<AlertDialogFooter className="sm:justify-between"><AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel><AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={busy} onClick={event => { event.preventDefault(); void remove(); }}>{busy ? "Removing…" : "Remove connection"}</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog>
</div>;
}
function ConnectionPicker({ accounts, selected, disabled, onChange, onRefresh }: { accounts: AiManagedConnectionSummary[]; selected: AiConnectionPoolMember[]; disabled: boolean; onChange: (members: AiConnectionPoolMember[]) => void; onRefresh: () => void }) {
const [search, setSearch] = useState("");
const filtered = accounts.filter(account => `${account.name} ${AI_PROVIDERS[account.provider].name}`.toLowerCase().includes(search.toLowerCase()));
return <div className="space-y-4">
<div className="relative"><Search className="pointer-events-none absolute left-3 top-3 size-4 text-muted-foreground" /><Input aria-label="Search connections" placeholder="Search connections…" value={search} onChange={event => setSearch(event.target.value)} className="pl-9" /></div>
<div className="max-h-80 overflow-y-auto rounded-lg border border-border divide-y divide-border">
{filtered.map(account => { const checked = selected.some(member => member.binding.connectionId === account.id); return <label key={account.grantId} className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-accent/50"><Checkbox checked={checked} disabled={disabled || (!checked && account.status !== "connected")} onCheckedChange={value => onChange(value ? [...selected, memberOf(account)] : selected.filter(member => member.binding.connectionId !== account.id))} aria-label={account.name} /><AppLogo name={AI_PROVIDERS[account.provider].name} logoUrl={AI_PROVIDERS[account.provider].logo} size={32} /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{account.name}</span><span className="block text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)} · {account.ownership === "shared" ? "Shared" : "Personal"}</span></span>{account.status !== "connected" && <span className="text-xs text-muted-foreground">Needs attention</span>}</label>; })}
{filtered.length === 0 && <p className="px-4 py-6 text-sm text-muted-foreground">{accounts.length ? "No matching connections." : "No AI connections yet."}</p>}
</div>
<div className="flex items-center justify-between gap-3 text-sm"><Link to="/apps" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground">Connect a new account<ExternalLink className="size-3.5" /></Link><Button variant="ghost" size="sm" onClick={onRefresh}>Refresh</Button></div>
</div>;
}
@@ -97,3 +97,11 @@ it("shows explicit denial and distinguishes equal-duration provider windows", as
expect(host.querySelector('[aria-label="Plan usage · Primary · 5h: 100%"]')).not.toBeNull();
expect(host.querySelector('[aria-label="Plan usage · Secondary · 5h: 50%"]')).not.toBeNull();
});
it("shows cached pool observations without offering or making a provider call", () => {
const observation: AiConnectionUsage = { connectionId: account.id, grantId: account.grantId, provider: account.provider, method: account.method, status: "ok", checkedAt: "2026-10-02T12:00:00Z", source: "fixture", planType: null, limits: [], overage: null };
flushSync(() => root.render(<QueryClientProvider client={client}><AiConnectionUsagePanel account={account} observation={observation} cachedOnly /></QueryClientProvider>));
expect(host.textContent).toContain("Usage not reported");
expect(host.querySelector("button")).toBeNull();
expect(api.probeUsage).not.toHaveBeenCalled();
});
@@ -68,24 +68,25 @@ function usageError(usage: AiConnectionUsage) {
case "invalid_response": return "Couldn’t read usage. Try again.";
case "connection_unavailable": return "Reconnect to check usage.";
case "unsupported": return "Usage unavailable.";
default: return usage.message;
default: return usage.message ?? "Usage unavailable.";
}
}
export function AiConnectionUsagePanel({ account }: { account: AiManagedConnectionSummary }) {
export function AiConnectionUsagePanel({ account, observation, cachedOnly = false }: { account: AiManagedConnectionSummary; observation?: AiConnectionUsage; cachedOnly?: boolean }) {
const probe = useMutation({
mutationFn: () => aiConnectionsApi.probeUsage(account.companyId, account.id, account.grantId),
});
const supported = supportsAiConnectionUsage(account.provider, account.method);
const usage = probe.isSuccess ? probe.data : undefined;
const usage = cachedOnly ? observation : probe.isSuccess ? probe.data : undefined;
return (
<section aria-label="Account usage limits" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Usage</h3>
{supported && <Button variant="outline" size="sm" disabled={probe.isPending || account.status !== "connected"} onClick={() => probe.mutate()}>
{supported && !cachedOnly && <Button variant="outline" size="sm" disabled={probe.isPending || account.status !== "connected"} onClick={() => probe.mutate()}>
{probe.isPending ? "Checking…" : usage?.status === "ok" ? "Refresh" : "Check usage"}
</Button>}
</div>
{cachedOnly && !usage && <p className="text-xs text-muted-foreground">Usage not observed.</p>}
{!supported && <p className="text-xs text-muted-foreground">Unavailable for this sign-in method.</p>}
{probe.error && <p role="alert" className="text-sm text-destructive">{probe.error.message}</p>}
{usage && usage.status !== "ok" && <p role={usage.status === "unsupported" ? "status" : "alert"} className="text-sm text-muted-foreground">{usageError(usage)}</p>}
@@ -1,3 +1,5 @@
import { StepHeader } from "./ConnectionSetupHeader";
export { StepHeader } from "./ConnectionSetupHeader";
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
@@ -2559,76 +2561,6 @@ function StandardConnectionSetupFlow({
);
}
export function StepHeader({
title,
headingRef,
subtitle,
step,
activeIndex,
labels,
appIdentity,
unverifiedHost,
onCancel,
}: {
title?: string;
headingRef?: Ref<HTMLHeadingElement>;
subtitle: string;
step: Step;
activeIndex: number;
labels: string[];
appIdentity?: { name: string; logoUrl: string | null; darkLogoUrl?: string | null };
/**
* Host of an unknown remote MCP server. Present for the whole generic flow so
* the operator can see whose server they are configuring at every step, not
* just on the screen where they pasted the address.
*/
unverifiedHost?: string | null;
onCancel?: () => void;
}) {
return (
<div className="mb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{appIdentity ? (
<AppLogo name={appIdentity.name} logoUrl={appIdentity.logoUrl} darkLogoUrl={appIdentity.darkLogoUrl} size={44} />
) : null}
<div>
<h1 ref={headingRef} tabIndex={headingRef ? -1 : undefined} className="text-2xl font-bold tracking-tight outline-none">
{title ?? (appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server")}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
</div>
</div>
{onCancel && <Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>}
</div>
{step !== "gallery" && labels.length > 1 && (
// A landmark with stable hooks, so the step model can be read without
// guessing at Tailwind classes. The dots are decoration — the label
// line below already says the same thing, so announcing both would
// read every step name twice.
<nav className="mt-4" aria-label="Setup progress" data-testid="wizard-stepper">
<ol className="flex gap-2" aria-hidden="true">
{labels.map((label, i) => (
<li
key={label}
data-testid="wizard-step-dot"
data-step-active={i === activeIndex ? "true" : undefined}
className={cn("h-1 w-20 rounded-full", i <= activeIndex ? "bg-foreground" : "bg-border")}
/>
))}
</ol>
<div className="mt-2 text-xs text-muted-foreground" data-testid="wizard-step-labels">
{labels.join(" · ")}
</div>
</nav>
)}
</div>
);
}
export function OAuthConnectStateScreen({
entry,
identity,
@@ -0,0 +1,76 @@
import type { Ref } from "react";
import { AppLogo } from "@/pages/apps/AppLogo";
import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function StepHeader({
title,
headingRef,
subtitle,
step,
activeIndex,
labels,
appIdentity,
unverifiedHost,
onCancel,
}: {
title?: string;
headingRef?: Ref<HTMLHeadingElement>;
subtitle: string;
step: string;
activeIndex: number;
labels: string[];
appIdentity?: { name: string; logoUrl: string | null; darkLogoUrl?: string | null };
/**
* Host of an unknown remote MCP server. Present for the whole generic flow so
* the operator can see whose server they are configuring at every step, not
* just on the screen where they pasted the address.
*/
unverifiedHost?: string | null;
onCancel?: () => void;
}) {
return (
<div className="mb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{appIdentity ? (
<AppLogo name={appIdentity.name} logoUrl={appIdentity.logoUrl} darkLogoUrl={appIdentity.darkLogoUrl} size={44} />
) : null}
<div>
<h1 ref={headingRef} tabIndex={headingRef ? -1 : undefined} className="text-2xl font-bold tracking-tight outline-none">
{title ?? (appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server")}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
</div>
</div>
{onCancel && <Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>}
</div>
{step !== "gallery" && labels.length > 1 && (
// A landmark with stable hooks, so the step model can be read without
// guessing at Tailwind classes. The dots are decoration — the label
// line below already says the same thing, so announcing both would
// read every step name twice.
<nav className="mt-4" aria-label="Setup progress" data-testid="wizard-stepper">
<ol className="flex gap-2" aria-hidden="true">
{labels.map((label, i) => (
<li
key={label}
data-testid="wizard-step-dot"
data-step-active={i === activeIndex ? "true" : undefined}
className={cn("h-1 w-20 rounded-full", i <= activeIndex ? "bg-foreground" : "bg-border")}
/>
))}
</ol>
<div className="mt-2 text-xs text-muted-foreground" data-testid="wizard-step-labels">
{labels.join(" · ")}
</div>
</nav>
)}
</div>
);
}
+13 -2
View File
@@ -1,3 +1,5 @@
import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector";
import { aiConnectionRouterPluginKey } from "@paperclipai/shared";
import { BrowserUseSettingsPanel } from "./app-detail/BrowserUseSettingsPanel";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement";
@@ -69,7 +71,16 @@ import {
export { connectionAddress, connectionTransportLabel };
export function AppDetail({ renderActions, onReconnect }: {
export function AppDetail(props: { renderActions?: (connection: ToolConnection) => ReactNode; onReconnect?: (connection: ToolConnection) => void } = {}) {
const { connectionId = "" } = useParams<{ connectionId: string }>();
const connection = useQuery({ queryKey: queryKeys.tools.connection(connectionId), queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId });
if (connection.isPending) return <p role="status">Loading connection…</p>;
if (connection.error) return <p role="alert">{connection.error.message}</p>;
const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data);
return pluginKey ? <AiConnectionPoolConnector pluginKey={pluginKey} connection={connection.data} /> : <StandardAppDetail {...props} />;
}
function StandardAppDetail({ renderActions, onReconnect }: {
renderActions?: (connection: ToolConnection) => ReactNode;
onReconnect?: (connection: ToolConnection) => void;
} = {}) {
@@ -709,7 +720,7 @@ export function AppDetail({ renderActions, onReconnect }: {
);
}
function AppDetailHeader({
export function AppDetailHeader({
appName,
connection,
logoEntry,
+11
View File
@@ -1,3 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { toolsApi } from "@/api/tools";
import { queryKeys } from "@/lib/queryKeys";
import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
@@ -16,11 +20,18 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
const [searchParams] = useSearchParams();
const { appKey } = useParams<{ appKey?: string }>();
const source = searchParams.get("source") ?? appKey ?? searchParams.get("appKey");
const gallery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId && !!source?.startsWith("ai-router-") });
const router = gallery.data?.apps.find(app => app.slug === source)?.aiConnectionRouter;
const returningToSkills = source === "github" && selectedCompanyId && skillSourceReturnPath(selectedCompanyId);
function returnToSkills() {
const path = selectedCompanyId && consumeSkillSourceReturn(selectedCompanyId);
if (path) navigate(path);
}
if (source?.startsWith("ai-router-")) {
if (gallery.isPending) return <p role="status">Loading connector…</p>;
if (!router) return <p role="alert">{gallery.error?.message ?? "This connection pool plugin is unavailable. Enable it in Plugins."}</p>;
return <AiConnectionPoolConnector pluginKey={router.pluginKey} />;
}
return <ConnectionSetupFlow byoOnly={byoOnly} credentialSource={credentialSource} host="page"
onComplete={returningToSkills ? returnToSkills : undefined}
onCancel={returningToSkills ? returnToSkills : undefined} />;
+8 -3
View File
@@ -1,4 +1,5 @@
import {
aiConnectionRouterPluginKey,
connectionSetupVerbForApp,
isRetiredComposioConnection,
RETIRED_COMPOSIO_MESSAGE,
@@ -114,6 +115,7 @@ type ConnectionRemovalTarget = {
accountName: string;
providerName: string;
remainingConnectionCount: number;
pool?: boolean;
} & ({ kind: "chat"; provider: ChatProvider } | { kind?: undefined });
// Temporary, page-only hold until Google OAuth verification is approved.
@@ -155,6 +157,7 @@ function chatConnectHref(
function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
const slug = appDefinitionSlug(entry);
if (entry.aiConnectionRouter) return appSourceConnectHref(slug);
const definition = getAppStoreDefinition(slug);
return appSupportsToolCatalogSetup(definition)
? appSourceConnectHref(slug)
@@ -230,6 +233,7 @@ function connectorAction(
href: string | null;
title?: string;
} {
if (row.entry?.aiConnectionRouter) return { label: "Add connection pool", href: row.entry.availability?.available === false ? null : connectHrefFor(row.entry), title: row.entry.availability?.reason };
const applicationId = row.applications[0]?.id ?? null;
const chatHref = (row.slug === "agentmail" || chatConnectorsEnabled)
? chatConnectHref(
@@ -291,7 +295,7 @@ function accountActionHref(
* surface. Connected providers sort first and expand in place to show every
* account; unconnected providers retain the same catalog setup flows.
*/
export function Browse({ renderAccountDetails = (connection) => connection.connectionPurpose === "ai" ? <ManagedAiConnectionRow connection={connection} /> : null }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode } = {}) {
export function Browse({ renderAccountDetails = (connection) => connection.connectionPurpose === "ai" && !aiConnectionRouterPluginKey(connection) ? <ManagedAiConnectionRow connection={connection} /> : null }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode } = {}) {
const navigate = useNavigate();
const preselectedChatAgentId =
typeof window === "undefined"
@@ -368,7 +372,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
pushToast({
title: "Connection removed",
body:
target.kind === "chat"
target.pool ? "The connections in the pool are kept." : target.kind === "chat"
? `${target.providerName} is disconnected. Existing Paperclip tasks remain available.`
: target.remainingConnectionCount > 0
? `${target.providerName} still has ${target.remainingConnectionCount} active ${target.remainingConnectionCount === 1 ? "connection" : "connections"} available to agents.`
@@ -750,7 +754,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
Remove {connectionToRemove?.accountName ?? "this"} connection?
</AlertDialogTitle>
<AlertDialogDescription>
{connectionToRemove?.kind === "chat"
{connectionToRemove?.pool ? "The connections in this pool are kept." : connectionToRemove?.kind === "chat"
? `This connection will stop receiving new work from ${connectionToRemove.providerName}. Existing Paperclip tasks and conversation history remain available. This does not delete the app, bot, or account in ${connectionToRemove.providerName}.`
: connectionToRemove &&
connectionToRemove.remainingConnectionCount > 0
@@ -866,6 +870,7 @@ export function ConnectorCard({
);
onRequestRemove({
id: connection.id,
pool: Boolean(aiConnectionRouterPluginKey(connection)),
accountName,
providerName: row.name,
remainingConnectionCount: row.connections.filter(
@@ -1,3 +1,4 @@
import { aiConnectionRouterSlug, aiConnectionRouterPluginKey } from "@paperclipai/shared";
import type { AppDefinition, ToolApplication, ToolConnection } from "@paperclipai/shared";
export type AppGalleryDisplayEntry = AppDefinition & {
@@ -34,6 +35,8 @@ export function appApplicationSourceSlug(application: ToolApplication | null | u
if (typeof source === "string" && source.trim()) return source.trim();
const key = application.applicationKey?.trim();
if (!key) return null;
const router = key.match(/^plugin:(.+):ai-router$/);
if (router) return aiConnectionRouterSlug(router[1]);
const galleryPrefix = "app-gallery:";
if (key.startsWith(galleryPrefix)) {
const slug = key.slice(galleryPrefix.length).split(":")[0] || null;
@@ -47,6 +50,8 @@ export function appApplicationSourceSlug(application: ToolApplication | null | u
export function appConnectionSourceSlug(connection: ToolConnection | null | undefined): string | null {
if (!connection) return null;
const router = aiConnectionRouterPluginKey(connection);
if (router) return aiConnectionRouterSlug(router);
const source = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey;
return typeof source === "string" && source.trim() ? source.trim() : null;
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Route, Routes, useNavigate, useParams } from "@/lib/router";
import { aiConnectionRouterAppDefinition, aiConnectionRouterSlug, getAppStoreDefinition, type AiConnectionList, type AiConnectionPool, type ToolApplication, type ToolConnection } from "@paperclipai/shared";
import { Browse } from "@/pages/apps/Browse";
import { AppsConnect } from "@/pages/apps/AppsConnect";
import { AppDetail } from "@/pages/apps/AppDetail";
import { AppDetailSidebar } from "@/components/AppConnectionSidebar.production";
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
export type PoolConnectorScenario = "catalog" | "setup" | "manage" | "empty" | "unavailable" | "permission" | "conflict" | "usage" | "exhausted" | "unknown" | "revoked";
export interface PoolConnectorStoryProps { pluginKey?: string; descriptor?: { name: string; description: string }; scenario?: PoolConnectorScenario }
const companyId = "company-storybook";
const poolId = "10000000-0000-4000-8000-000000000001";
const date = new Date("2026-09-10T12:00:00Z");
export const poolStoryAccounts: AiConnectionList["connections"] = [
{ id: "30000000-0000-4000-8000-000000000001", grantId: "40000000-0000-4000-8000-000000000001", companyId, provider: "openai", method: "subscription", name: "My ChatGPT account", ownership: "personal", isDefault: true, status: "connected" },
{ id: "30000000-0000-4000-8000-000000000002", grantId: "40000000-0000-4000-8000-000000000002", companyId, provider: "anthropic", method: "subscription", name: "My Claude account", ownership: "personal", isDefault: true, status: "connected" },
{ id: "30000000-0000-4000-8000-000000000003", grantId: "40000000-0000-4000-8000-000000000003", companyId, provider: "openrouter", method: "api_key", name: "Team OpenRouter", ownership: "shared", isDefault: true, status: "connected" },
];
/** Production Connectors routes with fixture data only. Used by host and plugin stories. */
export function ConnectionPoolConnectorHost({ pluginKey = "example.pool-router", descriptor = { name: "AI connection pool", description: "Rotate tasks between your AI connections." }, scenario = "catalog" }: PoolConnectorStoryProps) {
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false }, mutations: { retry: false } } }));
const [ready, setReady] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const previous = window.fetch;
const catalog = [aiConnectionRouterAppDefinition(pluginKey, descriptor, scenario === "unavailable" ? { available: false, reason: "Enable AI connection routers in Experimental settings." } : { available: true }), ...["openai", "anthropic", "openrouter", "github"].map(slug => getAppStoreDefinition(slug)!)];
let pools: AiConnectionPool[] = ["setup", "empty", "catalog", "unavailable"].includes(scenario) ? [] : [{ id: poolId, companyId, pluginKey, name: "Research accounts", enabled: false, mode: ["usage", "exhausted", "unknown"].includes(scenario) ? "usage_aware" : "round_robin", thresholdPercent: 90, revision: 1, members: poolStoryAccounts.slice(0, 2).map((account, index) => ({ id: `20000000-0000-4000-8000-00000000000${index + 1}`, binding: { mode: "delegated", provider: account.provider, method: account.method, connectionId: account.id, grantId: account.grantId }, profile: index === 0 ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } })) }];
const accounts = scenario === "empty" ? [] : scenario === "revoked" ? poolStoryAccounts.slice(1) : poolStoryAccounts;
const applications = (): ToolApplication[] => catalog.map(app => ({ id: `app-${app.slug}`, companyId, applicationKey: app.aiConnectionRouter ? `plugin:${pluginKey}:ai-router` : `app-gallery:${app.slug}`, name: app.name, description: app.description, type: app.aiConnectionRouter ? "paperclip_plugin" : "mcp_http", status: "active", pluginId: app.aiConnectionRouter ? "plugin-storybook" : null, ownerAgentId: null, ownerUserId: "dotta", metadata: { sourceTemplateKey: app.slug }, archivedAt: null, createdAt: date, updatedAt: date }));
const connections = (): ToolConnection[] => [...accounts.map(account => ({ id: account.id, name: account.name, enabled: true, slug: account.provider, config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method }, aiIsolatedSubscription: true } })), ...pools.map(pool => ({ id: pool.id, name: pool.name, enabled: pool.enabled, slug: aiConnectionRouterSlug(pluginKey), config: { aiRouter: { pluginKey }, sourceTemplateKey: aiConnectionRouterSlug(pluginKey) } }))].map(row => ({ id: row.id, companyId, applicationId: `app-${row.slug}`, name: row.name, uid: row.id, connectionKind: "managed", ownership: "customer", connectionPurpose: "ai", transport: "runtime_auth", authKind: "none", credentialSource: "paperclip_vault", credentialPolicy: "per_user", status: "active", enabled: row.enabled, transportConfig: {}, config: row.config, credentialSecretRefs: [], healthStatus: "ok", healthCheckedAt: date, healthMessage: null, lastError: null, createdByAgentId: null, createdByUserId: "dotta", createdAt: date, updatedAt: date }));
window.fetch = async (input, init) => {
const request = input instanceof Request ? input : null;
const path = new URL(request?.url ?? String(input), window.location.origin).pathname;
const method = init?.method ?? request?.method ?? "GET";
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
const reply = (value: unknown) => Response.json(value);
if (path === `/api/companies/${companyId}/chat-endpoints`) return reply([]);
if (path === `/api/companies/${companyId}/user-directory`) return reply({ users: [{ principalId: "dotta", status: "active", user: { name: "Dotta", email: "dotta@example.test" } }] });
if (path === `/api/companies/${companyId}/tools/apps/attention`) return reply({ apps: [] });
if (path === `/api/companies/${companyId}/tools/gallery`) return reply({ apps: catalog, capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true } });
if (path === `/api/companies/${companyId}/tools/applications`) return reply({ applications: applications() });
if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: connections() });
if (path === `/api/companies/${companyId}/ai-connections`) return reply({ currentUserId: "dotta", canManageConnections: scenario !== "permission", connections: accounts });
if (path === `/api/companies/${companyId}/ai-connection-pools`) {
if (method === "GET") return reply(pools);
if (scenario === "conflict") return Response.json({ error: "Pool changed; reload before saving" }, { status: 409 });
const previousPool = pools.find(pool => pool.id === body.id);
if (body.id && (!previousPool || previousPool.revision !== body.expectedRevision)) return Response.json({ error: "Pool changed; reload before saving" }, { status: 409 });
const saved = { ...body.config, id: body.id ?? poolId, companyId, pluginKey, revision: (previousPool?.revision ?? 0) + 1 };
pools = [...pools.filter(pool => pool.id !== saved.id), saved]; return reply(saved);
}
if (path.endsWith("/inspection")) return reply(Object.fromEntries((pools[0]?.members ?? []).map((member, index) => {
const percent = scenario === "exhausted" ? 100 : index === 0 ? 94 : 42;
const observed = ["usage", "exhausted", "unknown"].includes(scenario);
const checkedAt = new Date(Date.now() - 15000).toISOString();
return [member.id, { reason: "Eligible if agent access permits", checkedAt: observed ? checkedAt : null,
...(observed ? { usage: { connectionId: member.binding.connectionId, grantId: member.binding.grantId, provider: member.binding.provider, method: member.binding.method, status: scenario === "unknown" ? "unsupported" : "ok", checkedAt, source: "fixture", planType: "subscription", overage: null, limits: scenario === "unknown" ? [] : [{ id: "subscription", label: "Subscription", scope: null, usedPercent: percent, remainingPercent: 100 - percent, windowDurationSeconds: 18000, resetsAt: new Date(Date.now() + 180000).toISOString(), used: null, limit: null, remaining: null, unit: null, limitReached: percent >= 100, allowed: percent < 100 }] } } : {}) }];
})));
if (method === "DELETE" && path.includes("/ai-connection-pools/")) { pools = pools.filter(pool => !path.endsWith(pool.id)); return reply({ ok: true }); }
const connection = path.match(/^\/api\/tool-connections\/([^/]+)$/);
if (connection) { const row = connections().find(row => row.id === connection[1]); if (method === "DELETE") pools = pools.filter(pool => pool.id !== connection[1]); return row ? reply(row) : Response.json({ error: "Connection not found" }, { status: 404 }); }
// Fixtures never forward a mutation to a live instance.
if (path.startsWith("/api/") && method !== "GET") return Response.json({ error: "This preview does not run live operations." }, { status: 400 });
return previous(input, init);
};
navigate(["setup", "empty"].includes(scenario) ? `/PAP/apps/connect?source=${aiConnectionRouterSlug(pluginKey)}` : ["catalog", "unavailable"].includes(scenario) ? "/PAP/apps" : `/PAP/apps/${poolId}/permissions`, { replace: true });
setReady(true);
return () => { window.fetch = previous; client.clear(); };
}, []);
if (!ready) return <p>Loading connectors…</p>;
return <QueryClientProvider client={client}><Routes>
<Route path="/:companyPrefix/apps" element={<div className="mx-auto max-w-5xl p-6"><BreadcrumbBar /><div className="pt-6"><Browse renderAccountDetails={() => null} /></div></div>} />
<Route path="/:companyPrefix/apps/connect" element={<div className="mx-auto max-w-5xl p-6"><BreadcrumbBar /><div className="pt-6"><AppsConnect /></div></div>} />
<Route path="/:companyPrefix/apps/:connectionId/:tab" element={<PoolDetailRoute />} />
</Routes></QueryClientProvider>;
}
function PoolDetailRoute() {
const { connectionId = "" } = useParams<{ connectionId: string }>();
return <div className="flex min-h-screen"><div className="hidden w-56 shrink-0 md:block"><AppDetailSidebar kind="connection" connectionId={connectionId} /></div><main className="min-w-0 flex-1 p-6"><BreadcrumbBar /><div className="mx-auto max-w-3xl pt-6"><AppDetail /></div></main></div>;
}
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ConnectionPoolConnectorHost } from "../prototypes/ConnectionPoolConnectorHost";
const meta = { title: "Connectors/Pool host", component: ConnectionPoolConnectorHost, parameters: { layout: "fullscreen" } } satisfies Meta<typeof ConnectionPoolConnectorHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Catalog: Story = {};
export const Setup: Story = { args: { scenario: "setup" } };
export const Manage: Story = { args: { scenario: "manage" } };
export const CreateFromConnectors: Story = { play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Add connection pool AI connection pool" }));
await userEvent.click(await c.findByRole("checkbox", { name: "My ChatGPT account" }));
await userEvent.click(c.getByRole("checkbox", { name: "My Claude account" }));
await userEvent.click(c.getByRole("button", { name: "Continue" }));
await userEvent.click(c.getByRole("button", { name: "Move My Claude account up" }));
await expect(within(c.getByRole("list", { name: "Connection order" })).getAllByRole("listitem")[0]).toHaveTextContent("My Claude account");
await userEvent.click(c.getByRole("button", { name: "Create pool" }));
await expect(await c.findByRole("checkbox", { name: "Enable this pool" })).not.toBeChecked();
await expect(c.getByRole("heading", { level: 1, name: "AI connection pool" })).toBeVisible();
} };
@@ -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">
{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>}
{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--in-connectors">Connection pool connector 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"}