mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(connections): add experimental task-pinned AI routing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
806af230b6
commit
c2efe3e92a
51 files changed
+1763
-60
No files matched your search
@@ -1893,3 +1893,11 @@ unavailable. Preserve current ownership and newer-work fences. See
|
||||
endpoints delegate to sources while retaining response shapes.
|
||||
- GitHub.com, manual refresh only. No upstream editing, polling, webhook sync, commits,
|
||||
or pull-request creation in this milestone.
|
||||
|
||||
### Experimental AI connection routing
|
||||
|
||||
Opt-in plugin routers may represent a pool as an AI runtime binding. Core keeps
|
||||
company and credential authorization, atomically records task/agent affinity and
|
||||
a pool cursor, and persists concrete native recovery evidence. The full contract
|
||||
is in [AI-CONNECTION-ROUTERS.md](connections/AI-CONNECTION-ROUTERS.md). Disabled
|
||||
routing cannot allocate new tasks; already admitted native runs remain recoverable.
|
||||
@@ -686,3 +686,10 @@ requirements, and advisory warnings for missing or external references. New
|
||||
upstream skills require reviewed selection; removed or deselected skills remain
|
||||
installed. Editing starts with an independent copy. Write-back and PR publication
|
||||
are a later milestone; exact path and commit provenance provide their base.
|
||||
|
||||
### Experimental connection routing
|
||||
|
||||
A virtual AI connection can rotate new task/agent allocations through an
|
||||
authorized pool while preserving session affinity. Admission, credentials and
|
||||
durable recovery remain host responsibilities; policy can be supplied by an
|
||||
opt-in plugin. See [the experimental contract](connections/AI-CONNECTION-ROUTERS.md).
|
||||
@@ -0,0 +1,47 @@
|
||||
# Experimental AI connection routers
|
||||
|
||||
AI routers are virtual, company-scoped connections owned by a capability-gated
|
||||
plugin (`ai.connections.route`). The instance flag `enableAiConnectionRouters`
|
||||
defaults to false. Pools also default to disabled. The host must implement this
|
||||
contract; the plugin's minimum version alone does not establish compatibility.
|
||||
|
||||
The host authorizes members using the ordinary company, user, sharing, install,
|
||||
health and harness checks. It sends authorized metadata and sanitized usage
|
||||
observations to `onRouteAiConnection`. The plugin proposes an opaque member ID;
|
||||
it cannot receive credentials or expand authorization. Pure round robin does
|
||||
not probe usage. Usage-aware selection has a shared 15-second probe budget,
|
||||
60-second freshness cache and ordinary grant/secret freshness invalidation.
|
||||
|
||||
One cursor spans all agents in a pool. An allocation transaction locks the
|
||||
cursor, checks config and cursor revisions, rechecks authorization, and writes
|
||||
the task pin and cursor advance together. Pins use company, pool, agent and the
|
||||
existing task key (including `__heartbeat__`). Wakes without a task key retain
|
||||
the original run ID as an affinity key in retry context. Conflicts retry at most
|
||||
20 times; contention beyond that returns an actionable conflict.
|
||||
|
||||
Pins snapshot the concrete binding and member profile. Removing or editing a
|
||||
member affects future allocations. Composer changes can change a supported
|
||||
model or effort, with a note on fallback, but never the account or harness.
|
||||
Session reset and compaction retain the pin. Revocation requires operator repair.
|
||||
A pre-existing managed session can adopt its saved account when it is an
|
||||
eligible member; otherwise the operator must explicitly reset the session.
|
||||
|
||||
Credential `ai_session_epoch` changes on reconnect or manual rotation. Only
|
||||
verified runtime refresh write-back preserves it. Session fingerprints use the
|
||||
epoch while authentication failure attribution still uses the token generation.
|
||||
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 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**.
|
||||
|
||||
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.
|
||||
|
||||
Tests: `cd server && pnpm exec vitest run src/__tests__/ai-connection-router.test.ts`
|
||||
plus the existing AI connection, retry accounting, run-dispatch and UI suites.
|
||||
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE IF NOT EXISTS "ai_connection_pools" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"plugin_key" text NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ai_connection_pools_company_id_uq" UNIQUE("company_id","id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "ai_connection_router_cursors" (
|
||||
"pool_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"last_member_id" text,
|
||||
"version" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "ai_connection_task_pins" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"pool_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"task_key" text NOT NULL,
|
||||
"selection" jsonb NOT NULL,
|
||||
"member" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "ai_session_epoch" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_pools" ADD CONSTRAINT "ai_connection_pools_id_tool_connections_id_fk" FOREIGN KEY ("id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_pools" ADD CONSTRAINT "ai_connection_pools_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_pools" ADD CONSTRAINT "ai_connection_pools_company_connection_fk" FOREIGN KEY ("company_id","id") REFERENCES "public"."tool_connections"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_router_cursors" ADD CONSTRAINT "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk" FOREIGN KEY ("pool_id") REFERENCES "public"."ai_connection_pools"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_router_cursors" ADD CONSTRAINT "ai_connection_router_cursors_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_router_cursors" ADD CONSTRAINT "ai_connection_router_cursors_company_pool_fk" FOREIGN KEY ("company_id","pool_id") REFERENCES "public"."ai_connection_pools"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_task_pins" ADD CONSTRAINT "ai_connection_task_pins_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_task_pins" ADD CONSTRAINT "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk" FOREIGN KEY ("pool_id") REFERENCES "public"."ai_connection_pools"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_task_pins" ADD CONSTRAINT "ai_connection_task_pins_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_task_pins" ADD CONSTRAINT "ai_connection_task_pins_company_pool_fk" FOREIGN KEY ("company_id","pool_id") REFERENCES "public"."ai_connection_pools"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai_connection_task_pins" ADD CONSTRAINT "ai_connection_task_pins_company_agent_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_connection_task_pins_affinity_uq" ON "ai_connection_task_pins" USING btree ("company_id","pool_id","agent_id","task_key");
|
||||
+760
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "26315a87-4675-4dea-9634-efe5e878cc7d",
|
||||
"prevId": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19",
|
||||
"id": "18dbd936-096c-4e1d-ab23-10e223ba3a42",
|
||||
"prevId": "66a0b3c9-91bb-46b8-a868-a6facedbc14b",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -2903,6 +2903,362 @@
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ai_connection_pools": {
|
||||
"name": "ai_connection_pools",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"plugin_key": {
|
||||
"name": "plugin_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ai_connection_pools_id_tool_connections_id_fk": {
|
||||
"name": "ai_connection_pools_id_tool_connections_id_fk",
|
||||
"tableFrom": "ai_connection_pools",
|
||||
"tableTo": "tool_connections",
|
||||
"columnsFrom": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_pools_company_id_companies_id_fk": {
|
||||
"name": "ai_connection_pools_company_id_companies_id_fk",
|
||||
"tableFrom": "ai_connection_pools",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_pools_company_connection_fk": {
|
||||
"name": "ai_connection_pools_company_connection_fk",
|
||||
"tableFrom": "ai_connection_pools",
|
||||
"tableTo": "tool_connections",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ai_connection_pools_company_id_uq": {
|
||||
"name": "ai_connection_pools_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ai_connection_router_cursors": {
|
||||
"name": "ai_connection_router_cursors",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"pool_id": {
|
||||
"name": "pool_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_member_id": {
|
||||
"name": "last_member_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk": {
|
||||
"name": "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk",
|
||||
"tableFrom": "ai_connection_router_cursors",
|
||||
"tableTo": "ai_connection_pools",
|
||||
"columnsFrom": [
|
||||
"pool_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_router_cursors_company_id_companies_id_fk": {
|
||||
"name": "ai_connection_router_cursors_company_id_companies_id_fk",
|
||||
"tableFrom": "ai_connection_router_cursors",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_router_cursors_company_pool_fk": {
|
||||
"name": "ai_connection_router_cursors_company_pool_fk",
|
||||
"tableFrom": "ai_connection_router_cursors",
|
||||
"tableTo": "ai_connection_pools",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"pool_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ai_connection_task_pins": {
|
||||
"name": "ai_connection_task_pins",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pool_id": {
|
||||
"name": "pool_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"task_key": {
|
||||
"name": "task_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"selection": {
|
||||
"name": "selection",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"member": {
|
||||
"name": "member",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ai_connection_task_pins_affinity_uq": {
|
||||
"name": "ai_connection_task_pins_affinity_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "pool_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "agent_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "task_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ai_connection_task_pins_company_id_companies_id_fk": {
|
||||
"name": "ai_connection_task_pins_company_id_companies_id_fk",
|
||||
"tableFrom": "ai_connection_task_pins",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_task_pins_pool_id_ai_connection_pools_id_fk": {
|
||||
"name": "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk",
|
||||
"tableFrom": "ai_connection_task_pins",
|
||||
"tableTo": "ai_connection_pools",
|
||||
"columnsFrom": [
|
||||
"pool_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_task_pins_agent_id_agents_id_fk": {
|
||||
"name": "ai_connection_task_pins_agent_id_agents_id_fk",
|
||||
"tableFrom": "ai_connection_task_pins",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_task_pins_company_pool_fk": {
|
||||
"name": "ai_connection_task_pins_company_pool_fk",
|
||||
"tableFrom": "ai_connection_task_pins",
|
||||
"tableTo": "ai_connection_pools",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"pool_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ai_connection_task_pins_company_agent_fk": {
|
||||
"name": "ai_connection_task_pins_company_agent_fk",
|
||||
"tableFrom": "ai_connection_task_pins",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ai_provider_defaults": {
|
||||
"name": "ai_provider_defaults",
|
||||
"schema": "",
|
||||
@@ -12347,6 +12703,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_session_epoch": {
|
||||
"name": "ai_session_epoch",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"latest_version": {
|
||||
"name": "latest_version",
|
||||
"type": "integer",
|
||||
@@ -12737,6 +13100,373 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_source_entries": {
|
||||
"name": "company_skill_source_entries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_id": {
|
||||
"name": "source_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"inspection": {
|
||||
"name": "inspection",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"skill_id": {
|
||||
"name": "skill_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"selection": {
|
||||
"name": "selection",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'new'"
|
||||
},
|
||||
"present": {
|
||||
"name": "present",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skill_source_entries_source_path_idx": {
|
||||
"name": "company_skill_source_entries_source_path_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "source_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"company_skill_source_entries_company_skill_idx": {
|
||||
"name": "company_skill_source_entries_company_skill_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "skill_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skill_source_entries_company_id_companies_id_fk": {
|
||||
"name": "company_skill_source_entries_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skill_source_entries",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"company_skill_source_entries_source_id_company_skill_sources_id_fk": {
|
||||
"name": "company_skill_source_entries_source_id_company_skill_sources_id_fk",
|
||||
"tableFrom": "company_skill_source_entries",
|
||||
"tableTo": "company_skill_sources",
|
||||
"columnsFrom": [
|
||||
"source_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"company_skill_source_entries_skill_id_company_skills_id_fk": {
|
||||
"name": "company_skill_source_entries_skill_id_company_skills_id_fk",
|
||||
"tableFrom": "company_skill_source_entries",
|
||||
"tableTo": "company_skills",
|
||||
"columnsFrom": [
|
||||
"skill_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_sources": {
|
||||
"name": "company_skill_sources",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repository_url": {
|
||||
"name": "repository_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"full_name": {
|
||||
"name": "full_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tracking_ref": {
|
||||
"name": "tracking_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"connection_id": {
|
||||
"name": "connection_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"excluded_folders": {
|
||||
"name": "excluded_folders",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"last_attempt_at": {
|
||||
"name": "last_attempt_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_success_at": {
|
||||
"name": "last_success_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_scan_commit": {
|
||||
"name": "last_scan_commit",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lease_token": {
|
||||
"name": "lease_token",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lease_expires_at": {
|
||||
"name": "lease_expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skill_sources_repository_ref_idx": {
|
||||
"name": "company_skill_sources_repository_ref_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "repository_url",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "tracking_ref",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"company_skill_sources_identity_ref_idx": {
|
||||
"name": "company_skill_sources_identity_ref_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "repository_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "tracking_ref",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skill_sources_company_id_companies_id_fk": {
|
||||
"name": "company_skill_sources_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skill_sources",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"company_skill_sources_connection_id_tool_connections_id_fk": {
|
||||
"name": "company_skill_sources_connection_id_tool_connections_id_fk",
|
||||
"tableFrom": "company_skill_sources",
|
||||
"tableTo": "tool_connections",
|
||||
"columnsFrom": [
|
||||
"connection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_comments": {
|
||||
"name": "company_skill_comments",
|
||||
"schema": "",
|
||||
@@ -31098,6 +31828,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_needs_generation": {
|
||||
"name": "title_needs_generation",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
@@ -45835,6 +46572,27 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tool_mcp_gateway_tokens_expiry_idx": {
|
||||
"name": "tool_mcp_gateway_tokens_expiry_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tool_mcp_gateway_tokens_gateway_idx": {
|
||||
"name": "tool_mcp_gateway_tokens_gateway_idx",
|
||||
"columns": [
|
||||
+7
@@ -2052,6 +2052,13 @@
|
||||
"when": 1790891286527,
|
||||
"tag": "0294_chilly_marvel_apes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 295,
|
||||
"version": "7",
|
||||
"when": 1790967927385,
|
||||
"tag": "0295_cuddly_dazzler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { foreignKey, integer, jsonb, pgTable, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import type { AiConnectionPoolConfig, AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { toolConnections } from "./tool_access.js";
|
||||
|
||||
export const aiConnectionPools = pgTable("ai_connection_pools", {
|
||||
id: uuid("id").primaryKey().references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
config: jsonb("config").$type<AiConnectionPoolConfig>().notNull(),
|
||||
revision: integer("revision").notNull().default(1),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
unique("ai_connection_pools_company_id_uq").on(table.companyId, table.id),
|
||||
foreignKey({ columns: [table.companyId, table.id], foreignColumns: [toolConnections.companyId, toolConnections.id], name: "ai_connection_pools_company_connection_fk" }).onDelete("cascade"),
|
||||
]);
|
||||
export const aiConnectionRouterCursors = pgTable("ai_connection_router_cursors", {
|
||||
poolId: uuid("pool_id").primaryKey().references(() => aiConnectionPools.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
lastMemberId: text("last_member_id"),
|
||||
version: integer("version").notNull().default(0),
|
||||
}, (table) => [foreignKey({ columns: [table.companyId, table.poolId], foreignColumns: [aiConnectionPools.companyId, aiConnectionPools.id], name: "ai_connection_router_cursors_company_pool_fk" }).onDelete("cascade")]);
|
||||
export const aiConnectionTaskPins = pgTable("ai_connection_task_pins", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
poolId: uuid("pool_id").notNull().references(() => aiConnectionPools.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
taskKey: text("task_key").notNull(),
|
||||
selection: jsonb("selection").$type<AiConnectionRouterSelection>().notNull(),
|
||||
member: jsonb("member").$type<AiConnectionPoolConfig["members"][number]>().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
uniqueIndex("ai_connection_task_pins_affinity_uq").on(table.companyId, table.poolId, table.agentId, table.taskKey),
|
||||
foreignKey({ columns: [table.companyId, table.poolId], foreignColumns: [aiConnectionPools.companyId, aiConnectionPools.id], name: "ai_connection_task_pins_company_pool_fk" }).onDelete("cascade"),
|
||||
foreignKey({ columns: [table.companyId, table.agentId], foreignColumns: [agents.companyId, agents.id], name: "ai_connection_task_pins_company_agent_fk" }).onDelete("cascade"),
|
||||
]);
|
||||
@@ -21,6 +21,7 @@ export const companySecrets = pgTable(
|
||||
externalRef: text("external_ref"),
|
||||
providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }),
|
||||
providerMetadata: jsonb("provider_metadata").$type<Record<string, unknown>>(),
|
||||
aiSessionEpoch: integer("ai_session_epoch").notNull().default(0),
|
||||
latestVersion: integer("latest_version").notNull().default(1),
|
||||
description: text("description"),
|
||||
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
|
||||
|
||||
@@ -218,3 +218,5 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows
|
||||
|
||||
|
||||
export * from "./company_skill_sources.js";
|
||||
|
||||
export * from "./ai_connection_routing.js";
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
|
||||
/**
|
||||
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
|
||||
*
|
||||
@@ -345,6 +346,9 @@ export interface PluginDefinition {
|
||||
*
|
||||
* Requires `external.objects.read`.
|
||||
*/
|
||||
/** Propose a member from host-authorized candidates. Requires ai.connections.route. */
|
||||
onRouteAiConnection?(params: AiConnectionRouterRequest): Promise<AiConnectionRouterResult>;
|
||||
|
||||
onResolveExternalObject?(
|
||||
params: ResolveExternalObjectParams,
|
||||
): Promise<PluginExternalObjectResolveResult>;
|
||||
|
||||
@@ -451,3 +451,5 @@ export {
|
||||
|
||||
export { PluginEnvironmentCreationCleanupError, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "./environment-creation-cleanup.js";
|
||||
export type { PluginEnvironmentCreationCleanup } from "./environment-creation-cleanup.js";
|
||||
|
||||
export type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
|
||||
/**
|
||||
* JSON-RPC 2.0 message types and protocol helpers for the host ↔ worker IPC
|
||||
* channel.
|
||||
@@ -1350,6 +1351,7 @@ export interface HostToWorkerMethods {
|
||||
params: DetectExternalObjectsParams,
|
||||
result: DetectExternalObjectsResult,
|
||||
];
|
||||
routeAiConnection: [params: AiConnectionRouterRequest, result: AiConnectionRouterResult];
|
||||
resolveExternalObject: [
|
||||
params: ResolveExternalObjectParams,
|
||||
result: PluginExternalObjectResolveResult,
|
||||
@@ -1478,6 +1480,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
||||
"performAction",
|
||||
"executeTool",
|
||||
"detectExternalObjects",
|
||||
"routeAiConnection",
|
||||
"resolveExternalObject",
|
||||
"refreshExternalObjects",
|
||||
"environmentValidateConfig",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
|
||||
import { environmentCreationCleanupErrorData } from "./environment-creation-cleanup.js";
|
||||
/**
|
||||
* Worker-side RPC host — runs inside the child process spawned by the host.
|
||||
@@ -1620,6 +1621,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
return handleExecuteTool(params as ExecuteToolParams);
|
||||
case "detectExternalObjects":
|
||||
return handleDetectExternalObjects(params as DetectExternalObjectsParams);
|
||||
case "routeAiConnection":
|
||||
if (!plugin.definition.onRouteAiConnection) throw methodNotImplemented("routeAiConnection");
|
||||
return plugin.definition.onRouteAiConnection(params as AiConnectionRouterRequest);
|
||||
case "resolveExternalObject":
|
||||
return handleResolveExternalObject(params as ResolveExternalObjectParams);
|
||||
case "refreshExternalObjects":
|
||||
@@ -1736,6 +1740,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
||||
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
|
||||
if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects");
|
||||
if (plugin.definition.onRouteAiConnection) supportedMethods.push("routeAiConnection");
|
||||
if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject");
|
||||
if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects");
|
||||
if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig");
|
||||
|
||||
@@ -1115,3 +1115,25 @@ describe("worker duplex channel dispatch", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("AI connection router RPC", () => {
|
||||
it("advertises and calls only an implemented routing hook", async () => {
|
||||
const input = new PassThrough(), output = new PassThrough();
|
||||
const reader = createInterface({ input: output });
|
||||
const request = { companyId: "company", taskKey: "task", candidates: [], memberOrder: [] };
|
||||
let received: unknown;
|
||||
const plugin = definePlugin({ async setup() {}, onRouteAiConnection(params) { received = params; return { kind: "selected", memberId: "authorized-member" }; } });
|
||||
const worker = startWorkerRpcHost({ plugin, stdin: input, stdout: output });
|
||||
let sequence = 0;
|
||||
const pending = new Map<string, (value: unknown) => void>();
|
||||
reader.on("line", line => { const response = parseMessage(line); if (isJsonRpcResponse(response)) { pending.get(String(response.id))?.(response); pending.delete(String(response.id)); } });
|
||||
const call = (method: string, params: unknown) => new Promise<JsonRpcResponse>(resolve => { const id = String(++sequence); pending.set(id, value => resolve(value as JsonRpcResponse)); input.write(serializeMessage(createRequest(method, params, id))); });
|
||||
try {
|
||||
const initialized = await call("initialize", { manifest: { id: "fixture.router", apiVersion: 1, version: "1.0.0", displayName: "Router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], entrypoints: {} }, config: {}, databaseNamespace: null });
|
||||
expect((initialized as { result: { supportedMethods: string[] } }).result.supportedMethods).toContain("routeAiConnection");
|
||||
expect((await call("routeAiConnection", request) as { result: unknown }).result).toEqual({ kind: "selected", memberId: "authorized-member" });
|
||||
expect(received).toEqual(request);
|
||||
} finally { worker.stop(); reader.close(); input.destroy(); output.destroy(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
import { aiConnectionBindingSchema, type AiConnectionBinding } from "./ai-connections.js";
|
||||
import type { AiConnectionUsage } from "./ai-connection-usage.js";
|
||||
|
||||
export const aiConnectionRouterBindingSchema = z.object({
|
||||
mode: z.literal("router"),
|
||||
connectionId: z.string().uuid(),
|
||||
}).strict();
|
||||
export const aiRuntimeConnectionBindingSchema = z.union([aiConnectionBindingSchema, aiConnectionRouterBindingSchema]);
|
||||
export type AiRuntimeConnectionBinding = z.infer<typeof aiRuntimeConnectionBindingSchema>;
|
||||
|
||||
export const aiConnectionPoolMemberSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
binding: aiConnectionBindingSchema.refine((v) => v.mode !== "responsible_user", "Select an explicit account grant"),
|
||||
profile: z.object({
|
||||
provider: z.enum(["codex", "acpx", "opencode"]),
|
||||
acpxAgent: z.enum(["claude", "grok"]).optional(),
|
||||
model: z.string().trim().min(1).max(256),
|
||||
effort: z.string().trim().min(1).max(32).optional(),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
export const aiConnectionPoolConfigSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
enabled: z.boolean().default(false),
|
||||
mode: z.enum(["round_robin", "usage_aware"]).default("round_robin"),
|
||||
thresholdPercent: z.number().int().min(1).max(100).default(90),
|
||||
members: z.array(aiConnectionPoolMemberSchema).min(1).max(100),
|
||||
}).strict().superRefine((v, ctx) => {
|
||||
const ids = new Set<string>();
|
||||
const grants = new Set<string>();
|
||||
const connections = new Set<string>();
|
||||
for (const [index, member] of v.members.entries()) {
|
||||
const grant = `${member.binding.connectionId}:${member.binding.grantId}`;
|
||||
if (ids.has(member.id) || grants.has(grant) || connections.has(member.binding.connectionId)) ctx.addIssue({ code: "custom", path: ["members", index], message: "Duplicate pool member" });
|
||||
ids.add(member.id); grants.add(grant); connections.add(member.binding.connectionId);
|
||||
if (member.profile.provider === "acpx" && !member.profile.acpxAgent) ctx.addIssue({ code: "custom", path: ["members", index, "profile"], message: "Choose an ACP harness" });
|
||||
}
|
||||
});
|
||||
export type AiConnectionPoolMember = z.infer<typeof aiConnectionPoolMemberSchema>;
|
||||
export type AiConnectionPoolConfig = z.infer<typeof aiConnectionPoolConfigSchema>;
|
||||
export interface AiConnectionPool extends AiConnectionPoolConfig {
|
||||
id: string;
|
||||
companyId: string;
|
||||
pluginKey: string;
|
||||
revision: number;
|
||||
}
|
||||
/** Host-authorized metadata only. No credentials or raw provider responses. */
|
||||
export interface AiConnectionRouterRequest {
|
||||
companyId: string;
|
||||
pool: AiConnectionPool;
|
||||
agentId: string;
|
||||
taskKey: string;
|
||||
lastMemberId: string | null;
|
||||
cursorVersion: number;
|
||||
/** Opaque IDs preserve order without exposing unauthorized account metadata. */
|
||||
memberOrder: string[];
|
||||
pinnedMemberId?: string;
|
||||
candidates: Array<{ member: AiConnectionPoolMember; runtimeConfig: Record<string, unknown>; notes: string[]; usage?: AiConnectionUsage }>;
|
||||
now: string;
|
||||
}
|
||||
export type AiConnectionRouterResult =
|
||||
| { kind: "selected"; memberId: string; skipped?: Record<string, string> }
|
||||
| { kind: "exhausted"; retryAt: string; skipped: Record<string, string> }
|
||||
| { kind: "unavailable"; skipped: Record<string, string> };
|
||||
export interface AiConnectionPoolSaveInput {
|
||||
companyId: string;
|
||||
id?: string;
|
||||
expectedRevision?: number;
|
||||
config: AiConnectionPoolConfig;
|
||||
}
|
||||
export interface AiConnectionRouterSelection {
|
||||
poolId: string;
|
||||
memberId: string;
|
||||
binding: AiConnectionBinding;
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
notes: string[];
|
||||
}
|
||||
@@ -176,6 +176,7 @@ export interface AiManagedConnectionSummary {
|
||||
}
|
||||
export interface AiConnectionList {
|
||||
currentUserId: string;
|
||||
pools?: import("./ai-connection-router.js").AiConnectionPool[];
|
||||
canManageConnections: boolean;
|
||||
connections: AiManagedConnectionSummary[];
|
||||
}
|
||||
|
||||
@@ -1320,6 +1320,7 @@ export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number];
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
export const PLUGIN_CAPABILITIES = [
|
||||
"ai.connections.route",
|
||||
// Data Read
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
|
||||
@@ -50,6 +50,11 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableAiConnectionRouters: {
|
||||
title: "AI connection routers",
|
||||
description: "Allow experimental plugin connections to choose task-pinned accounts and harnesses.",
|
||||
tier: "managed", cloudDefault: false, selfHostedDefault: false,
|
||||
},
|
||||
enableNativeRunner: {
|
||||
title: "Paperclip Runner",
|
||||
description:
|
||||
|
||||
@@ -2785,6 +2785,7 @@ export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from
|
||||
|
||||
export * from "./agent-appearance.js";
|
||||
export * from "./ai-connections.js";
|
||||
export * from "./ai-connection-router.js";
|
||||
export * from "./ai-connection-usage.js";
|
||||
export * from "./types/email.js";
|
||||
export * from "./validators/email.js";
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface InstanceExperimentalSettings {
|
||||
* Existing native runs ignore later flag changes so they remain recoverable.
|
||||
*/
|
||||
enableNativeRunner: boolean;
|
||||
enableAiConnectionRouters: boolean;
|
||||
/**
|
||||
* Hide the local environment and run all agents in the platform-managed
|
||||
* sandbox environment. Run selection refuses local while this is on.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { agentAppearanceSchema } from "../agent-appearance.js";
|
||||
import { aiConnectionBindingSchema } from "../ai-connections.js";
|
||||
import { aiRuntimeConnectionBindingSchema as aiConnectionBindingSchema } from "../ai-connection-router.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AGENT_ICON_NAMES,
|
||||
|
||||
@@ -41,6 +41,7 @@ export const patchInstanceGeneralSettingsSchema = z
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableNativeRunner: z.boolean().default(true),
|
||||
enableAiConnectionRouters: z.boolean().default(false),
|
||||
enableManagedSandboxOnly: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableIsolatedWorkspacesByDefault: z.boolean().default(false),
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { createDb, companies, agents, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
||||
import type { AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { aiConnectionRouterService, AiConnectionPoolExhausted, poolMemberRuntimeConfig, applyAiConnectionRouterTaskSettings } from "../services/ai-connection-router.js";
|
||||
import { projectPaperclipRunnerTaskConfig, resolvePaperclipRunnerNativeProviderInput } from "../services/native-runtime/provider-profile.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { aiConnectionRoutes } from "../routes/ai-connections.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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" } };
|
||||
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;
|
||||
const order = request.pinnedMemberId ? [request.pinnedMemberId] : [...request.memberOrder.slice(next), ...request.memberOrder.slice(0, next)];
|
||||
const selected = order.find(id => request.candidates.some(candidate => candidate.member.id === id));
|
||||
return selected ? { kind: "selected", memberId: selected } : { kind: "unavailable", skipped: {} };
|
||||
});
|
||||
const worker = { isRunning: () => true, call: proposal } as unknown as PluginWorkerManager;
|
||||
const service = () => aiConnectionRouterService(db, worker);
|
||||
const resolve = (poolId: string, taskKey: string, extra = {}) => service().resolve({ companyId, poolId, agentId, userId: "alice", adapterType: "paperclip_runner", taskKey, ...extra });
|
||||
const makePool = async (config = {}) => service().save(pluginKey, { companyId, config: { name: randomUUID(), enabled: true, mode: "round_robin", thresholdPercent: 90, members, ...config } }, "alice");
|
||||
beforeAll(async () => {
|
||||
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-router-tests-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "router-fixture");
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-router-db-"); db = createDb(database.connectionString);
|
||||
await db.insert(companies).values([{ id: companyId, name: "Router fixture", issuePrefix: "RTF" }, { id: otherCompanyId, name: "Other", issuePrefix: "RTO" }]);
|
||||
await db.insert(agents).values([agentId, secondAgentId].map(id => ({ id, companyId, name: "Router agent", adapterType: "paperclip_runner" })));
|
||||
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: pluginKey, version: "0.1.0", manifestJson: manifest, status: "ready" });
|
||||
const accounts = aiConnectionService(db);
|
||||
const codex = await accounts.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Codex", apiKey: "fixture", agentIds: [], allAgents: true }, "fixture-codex");
|
||||
const claude = await accounts.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "personal", name: "Claude", loginSessionId: "fixture", agentIds: [], allAgents: true }, "fixture-claude");
|
||||
members = [
|
||||
{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", ...codex }, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } },
|
||||
{ id: randomUUID(), binding: { mode: "delegated", provider: "anthropic", method: "subscription", ...claude }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } },
|
||||
];
|
||||
}, 90000);
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
|
||||
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);
|
||||
await expect(makePool()).rejects.toThrow("Experimental");
|
||||
expect(await service().selectable(companyId, "alice")).toEqual([]);
|
||||
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
|
||||
});
|
||||
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" }] };
|
||||
app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler);
|
||||
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);
|
||||
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);
|
||||
actor = { type: "agent", agentId, companyId, source: "agent_jwt" } as unknown as typeof actor;
|
||||
expect((await request(app).get(url)).status).toBe(403);
|
||||
});
|
||||
it("rotates new tasks across qualified backends and retains pins on turns, resets and service restart", async () => {
|
||||
const pool = await makePool();
|
||||
expect((await resolve(pool.id, "one")).memberId).toBe(members[0]!.id);
|
||||
expect((await resolve(pool.id, "two")).memberId).toBe(members[1]!.id);
|
||||
expect((await resolve(pool.id, "one", { overrides: { model: "claude-sonnet-5", modelReasoningEffort: "high" } })).memberId).toBe(members[0]!.id);
|
||||
expect((await aiConnectionRouterService(db, worker).resolve({ companyId, poolId: pool.id, agentId, userId: "alice", adapterType: "paperclip_runner", taskKey: "one" })).memberId).toBe(members[0]!.id);
|
||||
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(2);
|
||||
});
|
||||
it("commits one allocation for duplicate starts and serializes concurrent tasks", async () => {
|
||||
const pool = await makePool();
|
||||
const same = await Promise.all(Array.from({ length: 8 }, () => resolve(pool.id, "duplicate")));
|
||||
expect(new Set(same.map(s => s.memberId)).size).toBe(1);
|
||||
const many = await Promise.all(Array.from({ length: 8 }, (_, n) => resolve(pool.id, `concurrent-${n}`)));
|
||||
expect(many.filter(s => s.memberId === members[0]!.id)).toHaveLength(4);
|
||||
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id)); expect(pins).toHaveLength(9);
|
||||
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(9);
|
||||
});
|
||||
it("rolls back both pin and cursor on a failure during commit", async () => {
|
||||
const pool = await makePool();
|
||||
await db.execute(`CREATE FUNCTION fixture_router_crash() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture commit crash'; END $$`);
|
||||
await db.execute(`CREATE TRIGGER fixture_router_crash BEFORE UPDATE ON ai_connection_router_cursors FOR EACH ROW EXECUTE FUNCTION fixture_router_crash()`);
|
||||
try { await expect(resolve(pool.id, "crash")).rejects.toThrow(); } finally { await db.execute(`DROP TRIGGER fixture_router_crash ON ai_connection_router_cursors`); await db.execute(`DROP FUNCTION fixture_router_crash()`); }
|
||||
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
|
||||
const selected = await resolve(pool.id, "crash"); expect(selected.memberId).toBe(members[0]!.id);
|
||||
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
|
||||
});
|
||||
it("rechecks config revisions after a proposal and preserves removed member snapshots", async () => {
|
||||
const pool = await makePool(); const old = await resolve(pool.id, "existing");
|
||||
const saved = await service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: { name: "Edited", enabled: true, mode: "round_robin", thresholdPercent: 90, members: [members[1]!] } }, "alice");
|
||||
await expect(service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: saved }, "alice")).rejects.toThrow();
|
||||
expect((await resolve(pool.id, "existing")).binding).toEqual(old.binding);
|
||||
expect((await resolve(pool.id, "future")).memberId).toBe(members[1]!.id);
|
||||
});
|
||||
it("keeps account and harness pinned through independent composer fallback", async () => {
|
||||
const pool = await makePool();
|
||||
const first = await resolve(pool.id, "composer");
|
||||
const changed = await resolve(pool.id, "composer", { overrides: { model: "claude-sonnet-5", modelReasoningEffort: "high" } });
|
||||
expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1);
|
||||
const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" });
|
||||
expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2);
|
||||
const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed);
|
||||
const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) });
|
||||
expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" });
|
||||
expect(overrides).toMatchObject({ cwd: "/fixture" }); expect(overrides).not.toHaveProperty("effort");
|
||||
});
|
||||
it("retries a changed config proposal without advancing the abandoned selection", async () => {
|
||||
const pool = await makePool();
|
||||
proposal.mockImplementationOnce(async () => {
|
||||
await service().save(pluginKey, { companyId, id: pool.id, expectedRevision: pool.revision, config: { name: pool.name, enabled: true, mode: pool.mode, thresholdPercent: pool.thresholdPercent, members: [members[1]!] } }, "alice");
|
||||
return { kind: "selected", memberId: members[0]!.id };
|
||||
});
|
||||
expect((await resolve(pool.id, "revision-conflict")).memberId).toBe(members[1]!.id);
|
||||
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1);
|
||||
});
|
||||
it("fails closed when the experimental flag changes during a proposal", async () => {
|
||||
const pool = await makePool();
|
||||
proposal.mockImplementationOnce(async () => { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false }); return { kind: "selected", memberId: members[0]!.id }; });
|
||||
try { await expect(resolve(pool.id, "disabled-in-flight")).rejects.toThrow("disabled during allocation"); expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0); }
|
||||
finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); }
|
||||
});
|
||||
it("uses only compatible legacy members and makes reassignment an independent pin", async () => {
|
||||
const pool = await makePool();
|
||||
expect((await resolve(pool.id, "legacy", { adapterType: "claude_local" })).memberId).toBe(members[1]!.id);
|
||||
expect((await resolve(pool.id, "legacy", { agentId: secondAgentId, adapterType: "codex_local" })).memberId).toBe(members[0]!.id);
|
||||
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id)); expect(pins).toHaveLength(2);
|
||||
});
|
||||
it("adopts a valid existing account and requires explicit reset for an unrelated session", async () => {
|
||||
const pool = await makePool();
|
||||
expect((await resolve(pool.id, "adopt", { requireExisting: true, existingGrantId: members[1]!.binding.grantId })).memberId).toBe(members[1]!.id);
|
||||
await expect(resolve(pool.id, "foreign-session", { requireExisting: true, existingGrantId: randomUUID() })).rejects.toThrow("Reset");
|
||||
expect((await resolve(pool.id, "foreign-session")).memberId).toBe(members[0]!.id);
|
||||
});
|
||||
it("rejects cross-company access and unauthorized plugin proposals", async () => {
|
||||
const pool = await makePool();
|
||||
await expect(resolve(pool.id, "foreign", { companyId: otherCompanyId })).rejects.toThrow();
|
||||
proposal.mockResolvedValueOnce({ kind: "selected", memberId: randomUUID() });
|
||||
await expect(resolve(pool.id, "bad-proposal")).rejects.toThrow("unauthorized");
|
||||
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(0);
|
||||
});
|
||||
it("does not grant members to another responsible user or move revoked pins", async () => {
|
||||
const pool = await makePool(); const selected = await resolve(pool.id, "revoke");
|
||||
await expect(resolve(pool.id, "revoke", { userId: "bob" })).rejects.toThrow();
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.binding.mode === "responsible_user" ? "" : selected.binding.grantId));
|
||||
try { await expect(resolve(pool.id, "revoke")).rejects.toThrow(); expect((await resolve(pool.id, "new")).memberId).toBe(members[1]!.id); }
|
||||
finally { await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.id, members[0]!.binding.grantId)); }
|
||||
});
|
||||
it("never probes pure rotation, caches authorized observations, and invalidates on credential change", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 12 } })));
|
||||
try {
|
||||
const pure = await makePool(); await resolve(pure.id, "pure"); expect(spy).not.toHaveBeenCalled();
|
||||
const aware = await makePool({ mode: "usage_aware", members: [members[1]!] }); await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(1); await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect((await service().inspect(companyId, aware.id, "alice"))[members[1]!.id]?.checkedAt).not.toBeNull();
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId)); const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
||||
await secretService(db).rotate(ref.secretId, { value: "fixture-refreshed", preserveAiSessionEpoch: true }, { userId: "alice" });
|
||||
expect((await service().inspect(companyId, aware.id, "alice"))[members[1]!.id]?.checkedAt).toBeNull();
|
||||
await resolve(aware.id, "aware"); expect(spy).toHaveBeenCalledTimes(2);
|
||||
proposal.mockResolvedValueOnce({ kind: "exhausted", retryAt: new Date(Date.now() + 60_000).toISOString(), skipped: {} });
|
||||
await expect(resolve(aware.id, "aware")).rejects.toBeInstanceOf(AiConnectionPoolExhausted);
|
||||
const pins = await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, aware.id)); expect(pins).toHaveLength(1);
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
it("expires a usage observation at a reported reset", async () => {
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId));
|
||||
const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
||||
await secretService(db).rotate(ref.secretId, { value: "fixture-reset-cache", preserveAiSessionEpoch: true }, { userId: "alice" });
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 12, resets_at: new Date(Date.now() + 200).toISOString() } })));
|
||||
try {
|
||||
const pool = await makePool({ mode: "usage_aware", members: [members[1]!] }); await resolve(pool.id, "reset-cache"); expect(spy).toHaveBeenCalledTimes(1);
|
||||
await new Promise(resolve => setTimeout(resolve, 250)); await resolve(pool.id, "reset-cache"); expect(spy).toHaveBeenCalledTimes(2);
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
it("treats unfinished probes as unknown within the shared selection budget", async () => {
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[1]!.binding.grantId));
|
||||
const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
||||
await secretService(db).rotate(ref.secretId, { value: "fixture-slow-probe", preserveAiSessionEpoch: true }, { userId: "alice" });
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise<Response>(() => {}));
|
||||
try { const pool = await makePool({ mode: "usage_aware", members: [members[1]!] }); const start = Date.now(); expect((await resolve(pool.id, "slow-probe")).memberId).toBe(members[1]!.id); expect(Date.now() - start).toBeLessThan(20_000); expect(proposal.mock.calls.at(-1)?.[2].candidates[0]?.usage).toBeUndefined(); }
|
||||
finally { spy.mockRestore(); }
|
||||
}, 25_000);
|
||||
it("rejects disabled routing but recovers server-owned native evidence after disable/uninstall", async () => {
|
||||
const pool = await makePool(); const selected = await resolve(pool.id, "recovery");
|
||||
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: false });
|
||||
try { await expect(resolve(pool.id, "recovery")).rejects.toThrow("Experimental"); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); }
|
||||
finally { await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); }
|
||||
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
|
||||
try { await expect(resolve(pool.id, "new")).rejects.toThrow("plugin"); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); await db.delete(toolConnections).where(eq(toolConnections.id, pool.id)); expect(await resolve(pool.id, "recovery", { persisted: selected })).toEqual(selected); }
|
||||
finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); }
|
||||
});
|
||||
it("keeps session identity and fingerprints stable on authenticated refresh, but changes them on manual replacement", async () => {
|
||||
const input = { companyId, agentId, responsibleUserId: "alice", adapterType: "paperclip_runner", binding: members[0]!.binding, config: { provider: "codex", model: "gpt-5.6-sol" } };
|
||||
const first = await prepareManagedAiRuntime(db, input);
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, members[0]!.binding.grantId)); const ref = grant!.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
||||
try {
|
||||
await secretService(db).rotate(ref.secretId, { value: "fixture-new-token", preserveAiSessionEpoch: true }, { userId: "alice" });
|
||||
const refreshed = await prepareManagedAiRuntime(db, input);
|
||||
try { expect(refreshed.identity).not.toBe(first.identity); expect(refreshed.sessionIdentity).toBe(first.sessionIdentity); expect(managedAiSessionFingerprintConfig(refreshed.config, refreshed.home)).toEqual(managedAiSessionFingerprintConfig(first.config, first.home)); } finally { await refreshed.cleanup(); }
|
||||
await secretService(db).rotate(ref.secretId, { value: "fixture-replacement" }, { userId: "alice" });
|
||||
const replaced = await prepareManagedAiRuntime(db, input); try { expect(replaced.sessionIdentity).not.toBe(first.sessionIdentity); } finally { await replaced.cleanup(); }
|
||||
const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, ref.secretId))); expect(secret!.aiSessionEpoch).toBeGreaterThan(0);
|
||||
} finally { await first.cleanup(); }
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ describe("instance settings service", () => {
|
||||
expect(normalizeExperimentalSettings({
|
||||
enableEnvironments: true,
|
||||
enableNativeRunner: false,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
@@ -35,6 +36,7 @@ describe("instance settings service", () => {
|
||||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
enableNativeRunner: false,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
|
||||
+1
-1
@@ -897,7 +897,7 @@ export async function createApp(
|
||||
lifecycleManager: lifecycle,
|
||||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
hostVersion: opts.hostVersion ?? serverVersion,
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
function classifyRetryReasonKind(retryReason: string | null): RetryReasonKind {
|
||||
if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON) return "max_turn_continuation";
|
||||
if (retryReason === ISSUE_DISPOSITION_REPAIR_RETRY_REASON) return "disposition_repair";
|
||||
if (retryReason === "ai_connection_busy") return "ai_connection_wait";
|
||||
if ((retryReason === "ai_connection_busy" || retryReason === "ai_connection_pool_wait")) return "ai_connection_wait";
|
||||
if (retryReason === "native_safe_replacement") return "native_safe_replacement";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -91,3 +91,8 @@ describe("wake context", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves non-assignee pool wait authority", () => {
|
||||
expect(isNonAssigneeWorkspaceBusyRetry("ai_connection_pool_wait", { aiConnectionBusyDeferredWhileAssignee: false })).toBe(true);
|
||||
expect(isNonAssigneeWorkspaceBusyRetry("ai_connection_pool_wait", { aiConnectionBusyDeferredWhileAssignee: true })).toBe(false);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
|
||||
export const MAX_TURN_CONTINUATION_RETRY_REASON = "max_turns_continuation";
|
||||
export const WORKSPACE_BUSY_RETRY_REASON = "workspace_busy";
|
||||
export const AI_CONNECTION_POOL_WAIT_RETRY_REASON = "ai_connection_pool_wait";
|
||||
export const AI_CONNECTION_BUSY_RETRY_REASON = "ai_connection_busy";
|
||||
export const INTERACTION_CONTINUATION_INFRA_RETRY_REASON = "interaction_continuation_infra_retry";
|
||||
export const INTERACTION_CONTINUATION_INFRA_WAKE_REASON = "interaction_continuation_infra_retry";
|
||||
@@ -41,7 +42,7 @@ export function isNonAssigneeWorkspaceBusyRetry(
|
||||
return (
|
||||
(retryReason === WORKSPACE_BUSY_RETRY_REASON &&
|
||||
contextSnapshot.workspaceBusyDeferredWhileAssignee === false) ||
|
||||
(retryReason === AI_CONNECTION_BUSY_RETRY_REASON &&
|
||||
((retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) &&
|
||||
contextSnapshot.aiConnectionBusyDeferredWhileAssignee === false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
WORKSPACE_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_POOL_WAIT_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
||||
WAKE_COMMENT_IDS_KEY,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { completeConnectionIntentSchema } from "@paperclipai/shared";
|
||||
import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js";
|
||||
@@ -5,7 +6,7 @@ import { pipeline } from "node:stream/promises";
|
||||
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { listOpenRouterModels } from "../services/openrouter-models.js";
|
||||
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { toolConnections } from "@paperclipai/db";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js";
|
||||
@@ -3241,6 +3242,21 @@ export function agentRoutes(
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
const poolId = asNonEmptyString(req.query.poolId);
|
||||
if (poolId) {
|
||||
const pool = (await aiConnectionRouterService(db).selectable(companyId, getActorInfo(req).actorId)).find(pool => pool.id === poolId);
|
||||
if (!pool) throw unprocessable("Enable an accessible AI connection pool");
|
||||
const catalog = new Map<string, { id: string; label: string }>();
|
||||
for (const member of pool.members) {
|
||||
const memberAdapter = member.binding.provider === "openai" ? "codex_local" : member.binding.provider === "anthropic" ? "claude_local" : member.binding.provider === "xai" ? "grok_local" : "opencode_local";
|
||||
const models = [{ id: member.profile.model, label: member.profile.model }, ...await listAdapterModels(memberAdapter)];
|
||||
for (const model of models) {
|
||||
try { poolMemberRuntimeConfig({ ...member, profile: { ...member.profile, model: model.id } }, type); catalog.set(model.id, model); } catch { /* Only qualified profiles belong in the pool catalog. */ }
|
||||
}
|
||||
}
|
||||
res.json([...catalog.values()]);
|
||||
return;
|
||||
}
|
||||
const refresh = typeof req.query.refresh === "string"
|
||||
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
|
||||
: false;
|
||||
@@ -3379,7 +3395,16 @@ export function agentRoutes(
|
||||
return result;
|
||||
}
|
||||
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
if (binding.mode === "router") {
|
||||
|
||||
const settings = await import("../services/instance-settings.js");
|
||||
if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Enable AI connection routers in Experimental settings");
|
||||
const pool = (await aiConnectionRouterService(db).selectable(companyId, responsibleUserForAiRequest(req) ?? "")).find((p) => p.id === binding.connectionId && p.enabled);
|
||||
if (!pool) throw unprocessable("Enable the selected connection pool");
|
||||
if (!pool.members.some((m) => { try { poolMemberRuntimeConfig(m, adapterType); return true; } catch { return false; } })) throw unprocessable("This pool has no compatible harness");
|
||||
return pool.id;
|
||||
}
|
||||
const userId = responsibleUserForAiRequest(req);
|
||||
const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
|
||||
const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, model: config.model, runnerProvider: config.provider, acpxAgent: config.acpxAgent, binding, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: test }).catch((error: unknown) => {
|
||||
@@ -3422,7 +3447,8 @@ export function agentRoutes(
|
||||
|
||||
const adapter = requireServerAdapter(type);
|
||||
|
||||
const aiBinding = req.body.aiConnection ? aiConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
|
||||
const requestedBinding = req.body.aiConnection ? aiRuntimeConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
|
||||
const aiBinding = requestedBinding?.mode === "router" ? undefined : requestedBinding;
|
||||
if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials");
|
||||
const inputAdapterConfig = aiBinding ? { ...req.body.adapterConfig, env: stripAiAuthBindings(req.body.adapterConfig?.env) } : (req.body?.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const savedAgentId = typeof req.body.agentId === "string" ? req.body.agentId : null;
|
||||
@@ -3434,6 +3460,12 @@ export function agentRoutes(
|
||||
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
await assertCanUpdateAgent(req, savedAgent);
|
||||
}
|
||||
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
|
||||
if (poolBinding?.mode === "router") {
|
||||
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false);
|
||||
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
|
||||
return;
|
||||
}
|
||||
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
|
||||
companyId,
|
||||
// Omission tests the saved selection. Explicit null tests a prospective
|
||||
@@ -4632,7 +4664,7 @@ export function agentRoutes(
|
||||
|
||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||
const status = requiresApproval ? "pending_approval" : "idle";
|
||||
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
|
||||
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
|
||||
const managedHireConnectionId = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
companyId,
|
||||
@@ -4881,7 +4913,7 @@ export function agentRoutes(
|
||||
allowedSandboxProviders: allowedSandboxProvidersForAgent(createInput.adapterType),
|
||||
});
|
||||
|
||||
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
|
||||
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
|
||||
const managedConnectionId = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
companyId,
|
||||
@@ -5560,12 +5592,12 @@ export function agentRoutes(
|
||||
});
|
||||
}
|
||||
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection;
|
||||
const nextAiBinding = aiConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
|
||||
const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
|
||||
if (nextAiBinding) {
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
|
||||
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
|
||||
if (!isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
|
||||
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
|
||||
if (changed) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
|
||||
}
|
||||
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
|
||||
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
type AiProvider,
|
||||
type AiConnectionBinding,
|
||||
type AiConnectionList,
|
||||
aiConnectionPoolConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
|
||||
@@ -173,6 +175,28 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
|
||||
const router = Router();
|
||||
const service = aiConnectionService(db);
|
||||
const localLogin = localAiLoginService(db);
|
||||
const pools = aiConnectionRouterService(db);
|
||||
router.get("/companies/:companyId/ai-connection-pools", 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");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(await pools.list(companyId));
|
||||
});
|
||||
router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", 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");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), getActorInfo(req).actorId));
|
||||
});
|
||||
router.post("/companies/:companyId/ai-connection-pools", 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 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));
|
||||
});
|
||||
function assertLocalOperator(req: Request) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, req.params.companyId as string);
|
||||
@@ -216,6 +240,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
|
||||
throw unprocessable("Invalid agent ID");
|
||||
res.json({
|
||||
currentUserId,
|
||||
pools: await pools.selectable(companyId, currentUserId),
|
||||
canManageConnections: await canManageAiConnections(db, req, companyId),
|
||||
connections: await service.list(
|
||||
companyId,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, 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";
|
||||
import { models as claudeModels, claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local";
|
||||
import { models as grokModels, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
|
||||
import { aiConnectionService } from "./ai-connections.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { forbidden, notFound, conflict, unprocessable } from "../errors.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
|
||||
const usageCache = new Map<string, { expires: number; value: AiConnectionUsage }>();
|
||||
const routedRuntimeKeys = new Set(["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"]);
|
||||
export function applyAiConnectionRouterTaskSettings(config: Record<string, unknown>, selection: AiConnectionRouterSelection) {
|
||||
return { ...Object.fromEntries(Object.entries(config).filter(([key]) => !routedRuntimeKeys.has(key))), ...selection.runtimeConfig };
|
||||
}
|
||||
export class AiConnectionPoolExhausted extends Error {
|
||||
readonly code = "ai_connection_pool_exhausted";
|
||||
constructor(readonly retryAt: string, readonly poolId: string) { super("All eligible pool accounts are over their usage limit. Waiting for usage to reset."); }
|
||||
}
|
||||
|
||||
/** Routing can override provider/model only, never environment, permissions, or instructions. */
|
||||
export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterType: string, overrides: Record<string, unknown> = {}) {
|
||||
const notes: string[] = [];
|
||||
const provider = member.binding.provider;
|
||||
const profile = member.profile;
|
||||
const expected = provider === "openai" ? profile.provider === "codex"
|
||||
: provider === "anthropic" ? profile.provider === "acpx" && profile.acpxAgent === "claude"
|
||||
: provider === "xai" ? profile.provider === "acpx" && profile.acpxAgent === "grok"
|
||||
: profile.provider === "opencode";
|
||||
if (!expected) throw unprocessable("Pool member harness does not match its account");
|
||||
const supportsModel = (value: string) => provider === "openai" ? isCodexLocalKnownModel(value)
|
||||
: provider === "anthropic" ? claudeModels.some((m) => m.id === value)
|
||||
: provider === "xai" ? grokModels.some((m) => m.id === value) : value.startsWith("openrouter/");
|
||||
const config: Record<string, unknown> = { provider: profile.provider, ...(profile.acpxAgent ? { acpxAgent: profile.acpxAgent } : {}), model: profile.model };
|
||||
const validate = () => {
|
||||
if (!isAiConnectionCompatible(member.binding, adapterType, config.model, config.provider, config.acpxAgent)) throw unprocessable("Pool member is incompatible with this harness");
|
||||
if (adapterType === "paperclip_runner") {
|
||||
const resolved = resolvePaperclipRunnerProviderProfile(config);
|
||||
resolvePaperclipRunnerNativeProviderInput({ backend: resolved.backend, adapterConfig: config });
|
||||
}
|
||||
};
|
||||
validate();
|
||||
if (typeof overrides.model === "string" && overrides.model !== profile.model) {
|
||||
if (supportsModel(overrides.model)) {
|
||||
config.model = overrides.model;
|
||||
try { validate(); } catch { config.model = profile.model; notes.push("Model override is unavailable for this member; using its default."); }
|
||||
} else notes.push("Model override is unavailable for this member; using its default.");
|
||||
}
|
||||
const model = String(config.model);
|
||||
const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model)
|
||||
: provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model)
|
||||
: provider === "xai" ? grokLocalReasoningEffortsForModel(model) : [];
|
||||
// ACPX currently has no qualified effort override; keep that host limitation explicit.
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" ? "modelReasoningEffort" : null
|
||||
: provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant";
|
||||
const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant;
|
||||
const effort = typeof requested === "string" && efforts.includes(requested) && effortKey ? requested
|
||||
: profile.effort && efforts.includes(profile.effort) && effortKey ? profile.effort : null;
|
||||
if (requested && requested !== effort) notes.push("Effort override is unavailable for this member; using its default.");
|
||||
if (effort && effortKey) config[effortKey] = effort;
|
||||
validate();
|
||||
return { config, notes };
|
||||
}
|
||||
|
||||
export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerManager) {
|
||||
async function owner(pluginKey: string) {
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.pluginKey, pluginKey));
|
||||
if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("ai.connections.route")) throw unprocessable("Enable a compatible AI connection router plugin");
|
||||
return plugin;
|
||||
}
|
||||
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 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 }));
|
||||
}
|
||||
async function selectable(companyId: string, userId: string) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) return [];
|
||||
const accounts = await aiConnectionService(db).list(companyId, userId);
|
||||
const result: AiConnectionPool[] = [];
|
||||
for (const pool of await list(companyId)) {
|
||||
if (!pool.enabled) continue;
|
||||
try { await owner(pool.pluginKey); } catch { continue; }
|
||||
const members = pool.members.filter(member => accounts.some(account => account.id === member.binding.connectionId && account.grantId === member.binding.grantId));
|
||||
if (members.length) result.push({ ...pool, members });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function inspect(companyId: string, poolId: string, userId: string) {
|
||||
const pool = (await list(companyId)).find(pool => pool.id === poolId);
|
||||
if (!pool) throw notFound("Connection pool not found");
|
||||
const visible = await aiConnectionService(db).list(companyId, userId);
|
||||
const result: Record<string, { reason: string; checkedAt: string | null; usage?: AiConnectionUsage }> = {};
|
||||
for (const member of pool.members) {
|
||||
const account = visible.find(account => account.id === member.binding.connectionId && account.grantId === member.binding.grantId);
|
||||
const key = account ? await usageCacheKey(member, companyId) : null;
|
||||
const cached = key ? usageCache.get(key) : undefined;
|
||||
const observation = cached && cached.expires > Date.now() ? cached.value : undefined;
|
||||
result[member.id] = { reason: account?.status === "connected" ? "Eligible if agent access permits" : account?.status ?? "Not permitted", checkedAt: account && observation ? observation.checkedAt : null, ...(account && observation ? { usage: observation } : {}) };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string) {
|
||||
await enabled();
|
||||
const plugin = await owner(pluginKey);
|
||||
const config = aiConnectionPoolConfigSchema.parse(input.config);
|
||||
const visible = await aiConnectionService(db).list(input.companyId, userId);
|
||||
for (const member of config.members) {
|
||||
if (!visible.some((v) => v.id === member.binding.connectionId && v.grantId === member.binding.grantId)) throw forbidden("Choose an account you may use in this company");
|
||||
poolMemberRuntimeConfig(member, "paperclip_runner");
|
||||
}
|
||||
const id = input.id ?? randomUUID();
|
||||
await db.transaction(async (tx) => {
|
||||
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");
|
||||
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)));
|
||||
} 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();
|
||||
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(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 });
|
||||
}
|
||||
await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } });
|
||||
});
|
||||
return (await list(input.companyId)).find((p) => p.id === id)!;
|
||||
}
|
||||
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;
|
||||
const ref = grant.credentialSecretRefs.find((r) => r.configPath === "ai.credential");
|
||||
const [secret] = ref ? await db.select({ version: companySecrets.latestVersion }).from(companySecrets).where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, ref.secretId))) : [];
|
||||
return `${companyId}:${grant.id}:${secret?.version}:${grant.updatedAt.toISOString()}`;
|
||||
}
|
||||
async function probe(member: AiConnectionPoolMember, companyId: string, userId: string, deadline: number) {
|
||||
const key = await usageCacheKey(member, companyId);
|
||||
if (!key) return undefined;
|
||||
const cached = usageCache.get(key);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const value = await Promise.race([
|
||||
aiConnectionService(db).probeUsage(companyId, userId, member.binding.connectionId, member.binding.grantId).catch(() => undefined),
|
||||
new Promise<undefined>((resolve) => { timer = setTimeout(() => resolve(undefined), Math.max(0, deadline - Date.now())); }),
|
||||
]);
|
||||
if (value) {
|
||||
const resets = value.limits.map((v) => Date.parse(v.resetsAt ?? "")).filter((v) => Number.isFinite(v) && v > Date.now());
|
||||
if (usageCache.size > 1000) usageCache.clear();
|
||||
usageCache.set(key, { value, expires: Math.min(Date.now() + 60_000, ...resets) });
|
||||
}
|
||||
return value;
|
||||
} finally { if (timer) clearTimeout(timer); }
|
||||
}
|
||||
async function resolve(input: { companyId: string; poolId: string; agentId: string; userId: string | null; adapterType: string; taskKey: string; overrides?: Record<string, unknown>; existingGrantId?: string; requireExisting?: boolean; persisted?: AiConnectionRouterSelection }) {
|
||||
const pinWhere = and(eq(aiConnectionTaskPins.companyId, input.companyId), eq(aiConnectionTaskPins.poolId, input.poolId), eq(aiConnectionTaskPins.agentId, input.agentId), eq(aiConnectionTaskPins.taskKey, input.taskKey));
|
||||
const validateSelection = async (selection: AiConnectionRouterSelection, client = db) => aiConnectionService(client).select({ companyId: input.companyId, agentId: input.agentId, userId: input.userId, adapterType: input.adapterType, binding: selection.binding, model: selection.runtimeConfig.model, runnerProvider: selection.runtimeConfig.provider, acpxAgent: selection.runtimeConfig.acpxAgent });
|
||||
if (input.persisted) {
|
||||
if (input.persisted.poolId !== input.poolId) throw unprocessable("Persisted pool selection does not match this run");
|
||||
const [pin] = await db.select().from(aiConnectionTaskPins).where(pinWhere);
|
||||
// Uninstall can remove the virtual connection. Server-owned native recovery
|
||||
// evidence still authorizes the original concrete selection, never a new one.
|
||||
if (pin && (pin.selection.memberId !== input.persisted.memberId || JSON.stringify(pin.selection.binding) !== JSON.stringify(input.persisted.binding))) throw unprocessable("Persisted pool allocation is inconsistent");
|
||||
await validateSelection(input.persisted);
|
||||
return input.persisted;
|
||||
}
|
||||
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 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);
|
||||
const [cursor] = await db.select().from(aiConnectionRouterCursors).where(and(eq(aiConnectionRouterCursors.poolId, row.id), eq(aiConnectionRouterCursors.companyId, input.companyId)));
|
||||
if (!cursor) throw unprocessable("The connection pool cursor is unavailable");
|
||||
const pool: AiConnectionPool = { ...row.config, id: row.id, companyId: row.companyId, pluginKey: row.pluginKey, revision: row.revision };
|
||||
const deadline = Date.now() + 15_000;
|
||||
const candidates: AiConnectionRouterRequest["candidates"] = [];
|
||||
for (const member of pin ? [pin.member] : pool.members) {
|
||||
try {
|
||||
const effective = poolMemberRuntimeConfig(member, input.adapterType, input.overrides);
|
||||
const selection: AiConnectionRouterSelection = { poolId: row.id, memberId: member.id, binding: member.binding, runtimeConfig: effective.config, notes: effective.notes };
|
||||
await validateSelection(selection);
|
||||
candidates.push({ member, runtimeConfig: effective.config, notes: effective.notes });
|
||||
} catch (error) { if (pin) throw error; }
|
||||
}
|
||||
if (!pin && input.requireExisting) {
|
||||
const existing = candidates.find((candidate) => candidate.member.binding.grantId === input.existingGrantId);
|
||||
if (!existing) throw unprocessable("Reset this task session before adopting the connection pool");
|
||||
candidates.splice(0, candidates.length, existing);
|
||||
}
|
||||
if (pool.mode === "usage_aware" && input.userId) {
|
||||
// At most four provider calls at once; the shared deadline bounds the whole pool.
|
||||
for (let i = 0; i < candidates.length && Date.now() < deadline; i += 4) await Promise.all(candidates.slice(i, i + 4).map(async (candidate) => { candidate.usage = await probe(candidate.member, input.companyId, input.userId!, deadline); }));
|
||||
}
|
||||
const authorizedPool = { ...pool, members: candidates.map((candidate) => candidate.member) };
|
||||
const result = await workerManager.call(plugin.id, "routeAiConnection", { companyId: input.companyId, pool: authorizedPool, memberOrder: pool.members.map((member) => member.id), agentId: input.agentId, taskKey: input.taskKey, lastMemberId: cursor.lastMemberId, cursorVersion: cursor.version, ...(pin ? { pinnedMemberId: pin.member.id } : {}), candidates, now: new Date().toISOString() }, 5000);
|
||||
if (result.kind === "exhausted") {
|
||||
const retry = Date.parse(result.retryAt);
|
||||
throw new AiConnectionPoolExhausted(new Date(Number.isFinite(retry) ? Math.max(Date.now() + 1000, retry) : Date.now() + 60_000).toISOString(), pool.id);
|
||||
}
|
||||
if (result.kind !== "selected") throw unprocessable("No pool account is permitted and compatible for this task");
|
||||
const candidate = candidates.find((v) => v.member.id === result.memberId);
|
||||
if (!candidate || (pin && candidate.member.id !== pin.member.id)) throw unprocessable("The router returned an unauthorized member");
|
||||
const selected: AiConnectionRouterSelection = { poolId: row.id, memberId: candidate.member.id, binding: candidate.member.binding, runtimeConfig: candidate.runtimeConfig, notes: candidate.notes };
|
||||
if (pin) return selected;
|
||||
const committed = await db.transaction(async (tx) => {
|
||||
const [locked] = await tx.select().from(aiConnectionRouterCursors).where(and(eq(aiConnectionRouterCursors.poolId, row.id), eq(aiConnectionRouterCursors.companyId, input.companyId))).for("update");
|
||||
const [existing] = await tx.select().from(aiConnectionTaskPins).where(pinWhere);
|
||||
if (existing) return { retry: true };
|
||||
const [current] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, row.id), eq(aiConnectionPools.companyId, input.companyId))).for("share");
|
||||
if (!locked || locked.version !== cursor.version || !current || current.revision !== row.revision) return { retry: true };
|
||||
const [currentPlugin] = await tx.select().from(plugins).where(eq(plugins.id, plugin.id)).for("share");
|
||||
if (currentPlugin?.status !== "ready") throw unprocessable("The router plugin was disabled during allocation");
|
||||
await tx.select({ id: instanceSettings.id }).from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")).for("share");
|
||||
if (!(await instanceSettingsService(tx as unknown as Db).getExperimental()).enableAiConnectionRouters) throw unprocessable("AI connection routers were disabled during allocation");
|
||||
await validateSelection(selected, tx as unknown as Db);
|
||||
await tx.insert(aiConnectionTaskPins).values({ companyId: input.companyId, poolId: row.id, agentId: input.agentId, taskKey: input.taskKey, selection: selected, member: candidate.member });
|
||||
await tx.update(aiConnectionRouterCursors).set({ lastMemberId: candidate.member.id, version: sql`${aiConnectionRouterCursors.version} + 1` }).where(eq(aiConnectionRouterCursors.poolId, row.id));
|
||||
await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "system", actorId: "system", agentId: input.agentId, action: "ai_connection.pool_selected", entityType: "tool_connection", entityId: row.id, details: { memberId: candidate.member.id, connectionId: candidate.member.binding.connectionId, taskKey: input.taskKey } });
|
||||
return { retry: false };
|
||||
});
|
||||
if (!committed.retry) return selected;
|
||||
}
|
||||
throw conflict("Concurrent pool selections changed the cursor; retry this task");
|
||||
}
|
||||
return { list, selectable, inspect, save, resolve };
|
||||
}
|
||||
@@ -189,7 +189,13 @@ export function managedAiSessionFingerprintConfig(
|
||||
for (const [key, value] of Object.entries(managedAiHomeEnvironment(managedHome))) {
|
||||
if (env[key] === value) env[key] = stable[key];
|
||||
}
|
||||
return { ...config, env };
|
||||
const managed = config.managedAiConnection as Record<string, unknown> | undefined;
|
||||
if (managed?.sessionIdentity) {
|
||||
for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GROK_API_KEY", "OPENCODE_AUTH_JSON", "OPENCODE_CONFIG_CONTENT"]) {
|
||||
if (env[key]) env[key] = "<managed-ai-credential>";
|
||||
}
|
||||
}
|
||||
return { ...config, env, ...(managed?.sessionIdentity ? { managedAiConnection: { ...managed, identity: managed.sessionIdentity } } : {}) };
|
||||
}
|
||||
|
||||
export async function prepareManagedAiRuntime(
|
||||
@@ -251,7 +257,14 @@ export async function prepareManagedAiRuntime(
|
||||
throw unprocessable(
|
||||
"The selected default changed. Retry this execution.",
|
||||
);
|
||||
const credentialRef = selection.grant.credentialSecretRefs.find((ref) => ref.configPath === "ai.credential");
|
||||
if (!credentialRef) throw unprocessable("The selected AI credential is unavailable");
|
||||
const readFreshness = async () => (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
|
||||
.from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0];
|
||||
const freshness = await readFreshness();
|
||||
const value = await service.credential(selection);
|
||||
const afterRead = await readFreshness();
|
||||
if (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch) throw unprocessable("The AI credential changed during preparation; retry this execution");
|
||||
home = await mkdtemp(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
@@ -298,11 +311,13 @@ export async function prepareManagedAiRuntime(
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
|
||||
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${freshness.epoch}`;
|
||||
return {
|
||||
sessionIdentity,
|
||||
config: {
|
||||
...input.config,
|
||||
env,
|
||||
managedAiConnection: { ...selection.attribution, identity },
|
||||
managedAiConnection: { ...selection.attribution, identity, sessionIdentity },
|
||||
},
|
||||
attribution: selection.attribution,
|
||||
accountName: selection.connection.name,
|
||||
@@ -367,7 +382,7 @@ export async function prepareManagedAiRuntime(
|
||||
if (decision !== 10) return;
|
||||
await secretService(tx).rotate(
|
||||
ref.secretId,
|
||||
{ value: refreshed },
|
||||
{ value: refreshed, preserveAiSessionEpoch: true },
|
||||
{ userId: grant.subjectUserId },
|
||||
);
|
||||
await tx
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("failure attempts across resource waits", () => {
|
||||
});
|
||||
|
||||
describe("persisted independent accounting", () => {
|
||||
it.each(["max_turns_continuation", "issue_disposition_repair", "workspace_busy", "ai_connection_busy"])("%s cannot erase prior infrastructure debits or spend more", scheduledRetryReason => {
|
||||
it.each(["max_turns_continuation", "issue_disposition_repair", "workspace_busy", "ai_connection_busy", "ai_connection_pool_wait"])("%s cannot erase prior infrastructure debits or spend more", scheduledRetryReason => {
|
||||
expect(executionFailureRetryCount({ scheduledRetryReason, scheduledRetryAttempt: 20,
|
||||
contextSnapshot: { executionRetryAccounting: { version: 1, failureRetries: 2, maxTurnContinuations: 1 } },
|
||||
})).toBe(2);
|
||||
|
||||
@@ -28,7 +28,7 @@ function savedAccounting(run: RetryRun): ExecutionRetryAccounting | null {
|
||||
|
||||
function historicalFailureCount(run: RetryRun): number {
|
||||
if (run.scheduledRetryReason === "max_turns_continuation" || run.scheduledRetryReason === "issue_disposition_repair") return 0;
|
||||
if (run.scheduledRetryReason === "ai_connection_busy") {
|
||||
if (run.scheduledRetryReason === "ai_connection_busy" || run.scheduledRetryReason === "ai_connection_pool_wait") {
|
||||
const saved = count(run.contextSnapshot?.failureRetriesBeforeAiConnectionWait);
|
||||
if (saved !== null) return saved;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function historicalFailureCount(run: RetryRun): number {
|
||||
|
||||
export function executionRetryAccounting(run: RetryRun): ExecutionRetryAccounting {
|
||||
const saved = savedAccounting(run);
|
||||
const nonFailureLane = ["max_turns_continuation", "issue_disposition_repair", "workspace_busy", "ai_connection_busy"].includes(run.scheduledRetryReason ?? "");
|
||||
const nonFailureLane = ["max_turns_continuation", "issue_disposition_repair", "workspace_busy", "ai_connection_busy", "ai_connection_pool_wait"].includes(run.scheduledRetryReason ?? "");
|
||||
return {
|
||||
version: 1,
|
||||
failureRetries: Math.max(saved?.failureRetries ?? 0, saved && nonFailureLane ? 0 : historicalFailureCount(run)),
|
||||
@@ -57,7 +57,7 @@ export function executionFailureRetryCount(run: RetryRun): number {
|
||||
}
|
||||
|
||||
export function executionRetryAttemptCount(run: RetryRun, reason: string): number {
|
||||
if (reason === "workspace_busy" || reason === "ai_connection_busy") {
|
||||
if (reason === "workspace_busy" || reason === "ai_connection_busy" || reason === "ai_connection_pool_wait") {
|
||||
return run.scheduledRetryReason === reason ? count(run.scheduledRetryAttempt) ?? 0 : 0;
|
||||
}
|
||||
const accounting = executionRetryAccounting(run);
|
||||
@@ -67,6 +67,6 @@ export function executionRetryAttemptCount(run: RetryRun, reason: string): numbe
|
||||
export function accountingForScheduledRetry(run: RetryRun, reason: string, attempt: number): ExecutionRetryAccounting {
|
||||
const accounting = executionRetryAccounting(run);
|
||||
if (reason === "max_turns_continuation") accounting.maxTurnContinuations = attempt;
|
||||
else if (reason !== "workspace_busy" && reason !== "ai_connection_busy") accounting.failureRetries = attempt;
|
||||
else if (reason !== "workspace_busy" && reason !== "ai_connection_busy" && reason !== "ai_connection_pool_wait") accounting.failureRetries = attempt;
|
||||
return accounting;
|
||||
}
|
||||
@@ -34,7 +34,8 @@ import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAs
|
||||
import { admitExplicitNativeContinuation, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js";
|
||||
import { connectionIntentService } from "./connection-intents.js";
|
||||
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js";
|
||||
import { aiConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
|
||||
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
|
||||
import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
|
||||
import { recordExecutionWait } from "./execution-wait.js";
|
||||
@@ -535,6 +536,7 @@ import {
|
||||
MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
WORKSPACE_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_POOL_WAIT_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
||||
WAKE_COMMENT_IDS_KEY,
|
||||
@@ -15542,7 +15544,7 @@ export function heartbeatService(
|
||||
const requiresIssueGate =
|
||||
isTransientWorkspaceGitScanCode(run.errorCode) ||
|
||||
hasConversationContinuationPolicy(run.resultJson) ||
|
||||
retryReason === AI_CONNECTION_BUSY_RETRY_REASON ||
|
||||
(retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) ||
|
||||
retryReason === MAX_TURN_CONTINUATION_RETRY_REASON ||
|
||||
retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON;
|
||||
if (requiresIssueGate) {
|
||||
@@ -15612,7 +15614,7 @@ export function heartbeatService(
|
||||
executionFailureRetryCount(run),
|
||||
}
|
||||
: {}),
|
||||
...(retryReason === AI_CONNECTION_BUSY_RETRY_REASON
|
||||
...((retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON)
|
||||
? { failureRetriesBeforeAiConnectionWait: executionFailureRetryCount(run) }
|
||||
: {}),
|
||||
...(shouldQuarantineWorkspaceForRetry
|
||||
@@ -15938,7 +15940,7 @@ export function heartbeatService(
|
||||
}
|
||||
|
||||
if (
|
||||
retryReason === AI_CONNECTION_BUSY_RETRY_REASON && issueId &&
|
||||
(retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) && issueId &&
|
||||
!isNonAssigneeWorkspaceBusyRetry(retryReason, contextSnapshot)
|
||||
) {
|
||||
// The issue row is locked above. Recheck after the preflight gate so
|
||||
@@ -20433,7 +20435,7 @@ export function heartbeatService(
|
||||
let readFailureReportSecrets: () => string[] = () => [];
|
||||
|
||||
try {
|
||||
const agent = await getAgent(run.agentId);
|
||||
let agent = await getAgent(run.agentId);
|
||||
if (!agent) {
|
||||
await setRunStatus(runId, "failed", {
|
||||
error: "Agent not found",
|
||||
@@ -20684,7 +20686,7 @@ export function heartbeatService(
|
||||
: row;
|
||||
})
|
||||
: null;
|
||||
const issueAssigneeOverrides =
|
||||
let issueAssigneeOverrides =
|
||||
issueContext && issueContext.assigneeAgentId === agent.id
|
||||
? parseIssueAssigneeAdapterOverrides(
|
||||
issueContext.assigneeAdapterOverrides,
|
||||
@@ -20890,7 +20892,7 @@ export function heartbeatService(
|
||||
// durable for authorization and operation-time credential resolution.
|
||||
context.executionPolicy = retainedTrust.executionPolicy;
|
||||
}
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
let config = parseObject(agent.adapterConfig);
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(
|
||||
agent.companyId,
|
||||
@@ -20899,6 +20901,46 @@ export function heartbeatService(
|
||||
taskKey,
|
||||
)
|
||||
: null;
|
||||
const routerHasPersistedInput = Object.keys(parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput)).length > 0;
|
||||
const persistedRouterPoolId = routerHasPersistedInput ? readNonEmptyString(parseObject(context.aiRouterSelection).poolId) : null;
|
||||
const requestedAiBinding = persistedRouterPoolId ? { mode: "router" as const, connectionId: persistedRouterPoolId } : agent.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
|
||||
let aiBinding = requestedAiBinding?.mode === "router" ? undefined : requestedAiBinding;
|
||||
if (requestedAiBinding?.mode === "router") {
|
||||
try {
|
||||
context.aiRouterTaskKey = readNonEmptyString(context.aiRouterTaskKey) ?? taskKey ?? run.id;
|
||||
const savedIdentity = taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity;
|
||||
const selection = await aiConnectionRouterService(db, options.pluginWorkerManager).resolve({
|
||||
companyId: agent.companyId, poolId: requestedAiBinding.connectionId, agentId: agent.id,
|
||||
userId: responsibleUserId, adapterType: agent.adapterType, taskKey: String(context.aiRouterTaskKey),
|
||||
overrides: issueAssigneeOverrides?.adapterConfig ?? {},
|
||||
existingGrantId: typeof savedIdentity === "string" ? savedIdentity.split(":")[0] : undefined,
|
||||
requireExisting: Boolean(taskSession?.sessionDisplayId) && !shouldResetTaskSessionForWake(context),
|
||||
persisted: routerHasPersistedInput ? context.aiRouterSelection as AiConnectionRouterSelection | undefined : undefined,
|
||||
});
|
||||
aiBinding = selection.binding;
|
||||
// Provider-specific fields from the configured harness must not leak into the selected member.
|
||||
config = applyAiConnectionRouterTaskSettings(config, selection);
|
||||
if (issueAssigneeOverrides) issueAssigneeOverrides = { ...issueAssigneeOverrides, adapterConfig: applyAiConnectionRouterTaskSettings(issueAssigneeOverrides.adapterConfig ?? {}, selection) };
|
||||
agent = { ...agent, adapterConfig: config };
|
||||
context.aiRouterSelection = selection;
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiRouterTaskKey: context.aiRouterTaskKey, aiRouterSelection: selection })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
await appendRunEvent(run, { eventType: "lifecycle", stream: "system", level: "info", message: "Using task-pinned pool account", payload: { poolId: selection.poolId, memberId: selection.memberId, provider: aiBinding.provider, model: config.model, notes: selection.notes } });
|
||||
} catch (error) {
|
||||
if (error instanceof AiConnectionPoolExhausted && !routerHasPersistedInput) {
|
||||
const now = new Date();
|
||||
context.aiConnectionBusyDeferredWhileAssignee = issueContext?.assigneeAgentId === agent.id;
|
||||
const cancelled = await setRunStatusIfRunning(run.id, "cancelled", { error: error.message, errorCode: error.code, finishedAt: now, resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, retryAt: error.retryAt }, contextSnapshot: context });
|
||||
if (cancelled.updated) {
|
||||
await setWakeupStatus(run.wakeupRequestId, "cancelled", { finishedAt: now, error: error.message });
|
||||
const retry = await scheduleBoundedRetryForRun(cancelled.run ?? run, agent, { now, retryReason: AI_CONNECTION_POOL_WAIT_RETRY_REASON, wakeReason: "ai_connection_pool_retry", maxAttempts: (run.scheduledRetryAttempt ?? 0) + 1, delayMs: Math.max(1000, Date.parse(error.retryAt) - Date.now()) });
|
||||
if (retry.outcome !== "scheduled") await releaseIssueExecutionAndPromote(cancelled.run ?? run);
|
||||
await finalizeAgentStatus(run.agentId, "cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this connection pool", { configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId, actionUrl: `/agents/${agent.id}/runtime`, fingerprint: `ai-router:${requestedAiBinding.connectionId}` } });
|
||||
}
|
||||
}
|
||||
if (isConversation(issueContext)) {
|
||||
delete context.resumeSessionParams;
|
||||
delete context.resumeSessionDisplayId;
|
||||
@@ -21569,7 +21611,8 @@ export function heartbeatService(
|
||||
});
|
||||
const mergedConfig = {
|
||||
...workspaceManagedConfig,
|
||||
...(issueAssigneeOverrides?.adapterConfig ?? {}),
|
||||
...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))),
|
||||
...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}),
|
||||
// The base below is already task-owned. Keep directory transport while
|
||||
// preserving isolated mode and the mandatory sandbox preflight.
|
||||
...(useIsolatedTaskDirectory ? { workspaceStrategy: { type: "project_primary" } } : {}),
|
||||
@@ -21606,7 +21649,7 @@ export function heartbeatService(
|
||||
["local", "ssh"].includes(
|
||||
selectedEnvironmentForConfig?.driver ?? "local",
|
||||
);
|
||||
const aiBinding = agent.runtimeConfig?.aiConnection ? aiConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
|
||||
|
||||
const { resolvedConfig, secretKeys, secretManifest } =
|
||||
await resolveExecutionRunAdapterConfig({
|
||||
managedAiCredentials: Boolean(aiBinding),
|
||||
@@ -21656,13 +21699,16 @@ export function heartbeatService(
|
||||
fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` },
|
||||
});
|
||||
}
|
||||
if (persistedNativeExecutionInput && parseObject(run.contextSnapshot?.aiConnection).identity !== managedAiRuntime.identity) {
|
||||
const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection);
|
||||
if (persistedNativeExecutionInput && (savedAiAccount.sessionIdentity
|
||||
? savedAiAccount.sessionIdentity !== managedAiRuntime.sessionIdentity
|
||||
: savedAiAccount.identity !== managedAiRuntime.identity)) {
|
||||
throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
|
||||
}
|
||||
Object.assign(resolvedConfig, managedAiRuntime.config);
|
||||
for (const key of AI_AUTH_ENV_KEYS) secretKeys.add(key);
|
||||
context.aiConnection = { ...managedAiRuntime.attribution, identity: managedAiRuntime.identity };
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
context.aiConnection = { ...managedAiRuntime.attribution, accountName: managedAiRuntime.accountName, identity: managedAiRuntime.identity, sessionIdentity: managedAiRuntime.sessionIdentity };
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection, ...(context.aiRouterTaskKey ? { aiRouterTaskKey: context.aiRouterTaskKey } : {}), ...(context.aiRouterSelection ? { aiRouterSelection: context.aiRouterSelection } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
@@ -23044,10 +23090,10 @@ export function heartbeatService(
|
||||
|
||||
const taskSessionCredentialCompatible = isTaskSessionCredentialCompatible(
|
||||
taskSession?.sessionParamsJson,
|
||||
managedAiRuntime?.identity,
|
||||
);
|
||||
managedAiRuntime?.sessionIdentity,
|
||||
) || Boolean(managedAiRuntime && isTaskSessionCredentialCompatible(taskSession?.sessionParamsJson, managedAiRuntime.identity));
|
||||
if (managedAiRuntime) {
|
||||
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.identity;
|
||||
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
|
||||
if (!taskSessionCredentialCompatible) {
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
|
||||
@@ -223,6 +223,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
|
||||
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
|
||||
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
|
||||
@@ -268,6 +269,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableNativeRunner: true,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
|
||||
@@ -2397,6 +2397,8 @@ export function secretService(db: Db | DbTransaction) {
|
||||
// update matches the latest version, so a concurrent rotation between the
|
||||
// read and the write cannot pass. A mismatch throws a 409 conflict.
|
||||
expectedLatestVersion?: number;
|
||||
/** Internal authenticated refresh write-back only; never accepted by HTTP schemas. */
|
||||
preserveAiSessionEpoch?: boolean;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) {
|
||||
@@ -2534,6 +2536,7 @@ export function secretService(db: Db | DbTransaction) {
|
||||
.update(companySecrets)
|
||||
.set({
|
||||
latestVersion: nextVersion,
|
||||
aiSessionEpoch: input.preserveAiSessionEpoch ? secret.aiSessionEpoch : sql`${companySecrets.aiSessionEpoch} + 1`,
|
||||
externalRef: prepared.externalRef,
|
||||
providerConfigId,
|
||||
lastRotatedAt: new Date(),
|
||||
@@ -4589,6 +4592,8 @@ export function secretService(db: Db | DbTransaction) {
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
expectedLatestVersion?: number;
|
||||
/** Internal authenticated refresh write-back only; never accepted by HTTP schemas. */
|
||||
preserveAiSessionEpoch?: boolean;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
|
||||
@@ -226,11 +226,12 @@ export const agentsApi = {
|
||||
adapterModels: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
options?: { refresh?: boolean; environmentId?: string | null; provider?: string },
|
||||
options?: { refresh?: boolean; environmentId?: string | null; provider?: string; poolId?: string },
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.refresh) params.set("refresh", "1");
|
||||
if (options?.provider) params.set("provider", options.provider);
|
||||
if (options?.poolId) params.set("poolId", options.poolId);
|
||||
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api.get<AdapterModel[]>(
|
||||
@@ -246,7 +247,7 @@ export const agentsApi = {
|
||||
type: string,
|
||||
data: {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding;
|
||||
agentId?: string;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId?: string | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AiConnectionField } from "./ai-connections/AiConnectionField";
|
||||
import { aiConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { testAgentSetup } from "@/lib/test-agent-setup";
|
||||
import { setupEfforts } from "../lib/agent-setup-fields";
|
||||
import { RuntimeTestCard } from "./RuntimeTestCard";
|
||||
@@ -1061,7 +1061,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
});
|
||||
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
|
||||
const agentId = isCreate ? undefined : props.agent.id;
|
||||
const aiConnection = isCreate ? undefined : aiConnectionBindingSchema.safeParse(
|
||||
const aiConnection = isCreate ? undefined : aiRuntimeConnectionBindingSchema.safeParse(
|
||||
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
|
||||
).data;
|
||||
if (props.compactTestFeedback) {
|
||||
@@ -1659,7 +1659,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
)}
|
||||
|
||||
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "grok" ? "grok_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
|
||||
value={aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
|
||||
routerAdapterType={adapterType} value={aiRuntimeConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
|
||||
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
|
||||
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
aiConnectionBindingSchema,
|
||||
isAiConnectionCompatible,
|
||||
type AiConnectionBinding,
|
||||
type AiRuntimeConnectionBinding,
|
||||
type AiAuthMethod,
|
||||
type AiProvider,
|
||||
type AiManagedConnectionSummary,
|
||||
@@ -46,28 +47,30 @@ export function AiConnectionField({
|
||||
environmentId,
|
||||
legacy = false,
|
||||
readOnly = false,
|
||||
routerAdapterType,
|
||||
}: {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
model?: string;
|
||||
value?: AiConnectionBinding;
|
||||
onChange: (binding: AiConnectionBinding) => void;
|
||||
value?: AiRuntimeConnectionBinding;
|
||||
onChange: (binding: AiRuntimeConnectionBinding) => void;
|
||||
environmentId?: string;
|
||||
legacy?: boolean;
|
||||
readOnly?: boolean;
|
||||
routerAdapterType?: string;
|
||||
}) {
|
||||
const provider = aiProviderForAdapter(adapterType);
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
const restoreFocus = (event: Event) => { event.preventDefault(); returnFocus.current?.focus(); };
|
||||
const [adopting, setAdopting] = useState(false);
|
||||
const [pendingAdoption, setPendingAdoption] = useState<AiConnectionBinding>();
|
||||
const [pendingAdoption, setPendingAdoption] = useState<AiRuntimeConnectionBinding>();
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState<AiManagedConnectionSummary>();
|
||||
const [allAgents, setAllAgents] = useState(true);
|
||||
const [savedAccount, setSavedAccount] = useState<{ connectionId: string; grantId: string; method: AiAuthMethod }>();
|
||||
const changeBinding = (next: AiConnectionBinding) => {
|
||||
const changeBinding = (next: AiRuntimeConnectionBinding) => {
|
||||
if (legacy && !value) { if (!connecting) returnFocus.current = document.activeElement as HTMLElement; setPendingAdoption(next); }
|
||||
else onChange(next);
|
||||
};
|
||||
@@ -99,9 +102,10 @@ export function AiConnectionField({
|
||||
selectDefault.reset();
|
||||
setConnecting(true);
|
||||
};
|
||||
const method: AiAuthMethod = (value?.mode !== "responsible_user" ? value?.method : undefined)
|
||||
const method: AiAuthMethod = (value && value.mode !== "router" && value.mode !== "responsible_user" ? value.method : undefined)
|
||||
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
|
||||
?? (provider === "openrouter" ? "api_key" : "subscription");
|
||||
const compatiblePools = agentId ? accounts.data?.pools?.filter(pool => pool.enabled && pool.members.some(member => isAiConnectionCompatible(member.binding, routerAdapterType ?? adapterType, member.profile.model, member.profile.provider, member.profile.acpxAgent))) ?? [] : [];
|
||||
if (!provider) return null;
|
||||
if (legacy && !value && !adopting)
|
||||
return (
|
||||
@@ -112,13 +116,25 @@ export function AiConnectionField({
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && (
|
||||
{value && value.mode !== "router" && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
This connection does not support the current harness and model. Choose
|
||||
a compatible connection before saving.
|
||||
</p>
|
||||
)}
|
||||
<AiConnectionPicker
|
||||
{(compatiblePools.length > 0 || value?.mode === "router") && <label className="block space-y-1 text-sm">
|
||||
AI connection
|
||||
<select className="block w-full rounded-md border bg-background px-3 py-2" disabled={readOnly} value={value?.mode === "router" ? value.connectionId : ""} onChange={event => {
|
||||
if (event.target.value) changeBinding({ mode: "router", connectionId: event.target.value });
|
||||
else changeBinding({ mode: "responsible_user", provider, method });
|
||||
}}>
|
||||
<option value="">Individual account</option>
|
||||
{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>}
|
||||
</label>}
|
||||
{value?.mode !== "router" && <AiConnectionPicker
|
||||
requirement={{ companyId, provider }}
|
||||
connections={accounts.data?.connections ?? []}
|
||||
value={value}
|
||||
@@ -134,7 +150,7 @@ export function AiConnectionField({
|
||||
onConnect={() => openConnection()}
|
||||
onReconnect={(!value || value.mode === "responsible_user") && personalDefault && personalDefault.status !== "connected" ? () => openConnection(personalDefault) : undefined}
|
||||
onRetry={() => void accounts.refetch()}
|
||||
/>
|
||||
/>}
|
||||
<Dialog
|
||||
open={Boolean(pendingAdoption)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -145,15 +161,13 @@ export function AiConnectionField({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adopt Connections for {agentName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Saving tests this account in {agentName}’s environment before
|
||||
replacing its existing authentication. Other agents keep their
|
||||
current configuration.
|
||||
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 === "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.`
|
||||
: accounts.data?.connections.find(
|
||||
: pendingAdoption?.mode === "router" ? `Task-pinned connection pool. Existing sessions must use a pool member or be reset.` : accounts.data?.connections.find(
|
||||
(account) => account.id === pendingAdoption?.connectionId,
|
||||
)?.name}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
|
||||
export function AiConnectionPoolRunDetails({ context }: { context: Record<string, unknown> | null }) {
|
||||
const selection = context?.aiRouterSelection as AiConnectionRouterSelection | undefined;
|
||||
if (!selection) return null;
|
||||
const account = context?.aiConnection as { accountName?: string; connectionId?: string } | undefined;
|
||||
const config = selection.runtimeConfig;
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.variant;
|
||||
return <div className="rounded-lg border p-4 text-sm space-y-1">
|
||||
<p className="font-medium">Task-pinned pool account · {account?.accountName ?? account?.connectionId ?? "Selected account"}</p>
|
||||
<p>{[selection.binding.provider, config.provider, config.acpxAgent, config.model, effort].filter(Boolean).map(String).join(" · ")}</p>
|
||||
{selection.notes.length > 0 && <p className="text-muted-foreground">{selection.notes.join(" ")}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -845,7 +845,7 @@ function Setup({
|
||||
</div>
|
||||
) : (
|
||||
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
|
||||
onChange={binding => { setRuntimeAiBinding(binding); resetTest(); }} />
|
||||
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest(); }} />
|
||||
)
|
||||
)}
|
||||
{models.error && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { agentsApi, type AdapterModel } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -51,6 +51,8 @@ function harnessLabel(agent: Agent | undefined): string {
|
||||
if (!agent) return "Choose an agent";
|
||||
const harness = HARNESS_LABELS[agent.adapterType] ?? agent.adapterType;
|
||||
const provider = composerCatalogProvider(agent);
|
||||
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
|
||||
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
|
||||
return provider === "openrouter" ? `${harness} · OpenRouter` : harness;
|
||||
}
|
||||
|
||||
@@ -99,12 +101,14 @@ export function ComposerRunSettingsPicker({
|
||||
const agent = agents.get(agentId);
|
||||
const modelSupported = supportsComposerModel(agent);
|
||||
const provider = composerCatalogProvider(agent);
|
||||
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
|
||||
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
|
||||
const { data: fetchedModels = [], isPending: modelsPending } = useQuery({
|
||||
queryKey: agent && modelSupported
|
||||
? queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider)
|
||||
? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null]
|
||||
: ["agents", "composer-models", "none"],
|
||||
queryFn: () => agentsApi.adapterModels(companyId, agent!.adapterType, {
|
||||
environmentId: agent!.defaultEnvironmentId ?? null, provider,
|
||||
environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId,
|
||||
}),
|
||||
enabled: Boolean(agent && modelSupported && !modelOptionsOverride),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCo
|
||||
import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
|
||||
import { aiConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
|
||||
export interface ComposerRunSettings {
|
||||
model: string | null;
|
||||
@@ -40,6 +40,8 @@ export function composerEfforts(agent: Agent | undefined, model: string, catalog
|
||||
const effectiveModel = model.trim() || (agent.adapterType === "grok_local" ? DEFAULT_GROK_LOCAL_MODEL
|
||||
: agent.adapterType === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : "");
|
||||
if (!effectiveModel) return [];
|
||||
const pool = aiRuntimeConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data?.mode === "router";
|
||||
if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
|
||||
if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) {
|
||||
return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type RunRetryStateSummary = {
|
||||
};
|
||||
|
||||
const RETRY_REASON_LABELS: Record<string, string> = {
|
||||
ai_connection_pool_wait: "Waiting for pool usage to recover",
|
||||
transient_failure: "Transient failure",
|
||||
missing_issue_comment: "Missing task comment",
|
||||
process_lost: "Process lost",
|
||||
@@ -65,7 +66,7 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary
|
||||
if (run.status === "scheduled_retry") {
|
||||
return {
|
||||
kind: "scheduled",
|
||||
badgeLabel: isMaxTurnContinuation ? "Continuation scheduled" : "Retry scheduled",
|
||||
badgeLabel: run.scheduledRetryReason === "ai_connection_pool_wait" ? "Pool exhausted" : isMaxTurnContinuation ? "Continuation scheduled" : "Retry scheduled",
|
||||
tone: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
detail: joinFragments([attemptLabel, reasonLabel]),
|
||||
secondary: dueAt
|
||||
|
||||
@@ -27,6 +27,12 @@ const ready = {
|
||||
checks: [{ code: "runtime", level: "info", message: "Ready" }],
|
||||
};
|
||||
beforeEach(() => testEnvironment.mockReset());
|
||||
it("does not launch an ambient provider hello test for a pool preview", async () => {
|
||||
testEnvironment.mockResolvedValue({ ...ready, status: "warn", checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Run a task" }] });
|
||||
await testAgentSetup({ ...input, aiConnection: { mode: "router", connectionId: "pool-id" } });
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(testEnvironment.mock.calls[0]?.[2].aiConnection).toEqual({ mode: "router", connectionId: "pool-id" });
|
||||
});
|
||||
it("does not report a connection when runtime readiness passes but provider authentication fails", async () => {
|
||||
testEnvironment
|
||||
.mockResolvedValueOnce(ready)
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function testAgentSetup(input: {
|
||||
adapterType: string;
|
||||
providerAdapter: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
|
||||
aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId: string | null;
|
||||
}): Promise<AdapterEnvironmentTestResult> {
|
||||
@@ -27,6 +27,7 @@ export async function testAgentSetup(input: {
|
||||
payload,
|
||||
);
|
||||
if (
|
||||
input.aiConnection?.mode === "router" ||
|
||||
runtime.status === "fail" ||
|
||||
runtime.checks.some(
|
||||
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AiConnectionPoolRunDetails } from "@/components/ai-connections/AiConnectionPoolRunDetails";
|
||||
import type { AgentInstructionCandidate, AgentInstructionsBundle } from "@paperclipai/shared";
|
||||
import { InstructionHistory } from "../components/InstructionHistory";
|
||||
import { AgentCharacter } from "../components/AgentCharacter";
|
||||
@@ -3576,6 +3577,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
git workspace it could not validate, wired to the same reconcile / repair / re-issue /
|
||||
break-glass handlers as the task detail page. */}
|
||||
<RunWorkspaceRecoverySurface run={run} />
|
||||
<AiConnectionPoolRunDetails context={run.contextSnapshot} />
|
||||
{/* Run summary card */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
|
||||
@@ -72,6 +72,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableNativeRunner: false,
|
||||
enableAiConnectionRouters: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIsolatedWorkspacesByDefault: false,
|
||||
|
||||
@@ -444,6 +444,17 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle memory connectors experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="AI connection routers"
|
||||
description="Allow experimental plugins to rotate new tasks through authorized AI accounts. Each task keeps its account and harness; exhausted tasks wait for usage to recover. Install a compatible router plugin and enable a pool separately."
|
||||
checked={experimentalQuery.data?.enableAiConnectionRouters === true}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableAiConnectionRouters: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableAiConnectionRouters"
|
||||
managed={managedKeys.enableAiConnectionRouters}
|
||||
ariaLabel="Toggle AI connection routers experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Paperclip Runner"
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner, including authenticated runner ingress when a sandbox requires it. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
|
||||
Reference in new issue
Block a user