mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugins receive durable agent lifecycle events through a registered host. > - The host uses a PostgreSQL LISTEN connection to wake delivery. > - An idle report could return `none` while that host-owned connection stayed open. > - This change gives that listener an exclusive owner and closes it during the current idle hold. > - Release or expiry restores listening before journal replay. > - Failed close and pending work continue to block sleep. ## Linked Issues or Issue Description Refs #18988, which is now merged. This focused ten-file change now targets master. Current integrated candidate: `0f83005122ef1bdb1f1e08f8f58424c9a840ebb1`, with actual master `644ee66f5d4ae8365fe8b9eca57df2be7405b5ab` preserved through normal merges. No history was rewritten. **What happened?** A normal local App with an installed lifecycle plugin could return an idle `none` report while its lifecycle LISTEN backend and socket stayed open. **Expected behavior** The host must close its own listener before it reports idle safety. It must retain other subscriptions, durable events, company scope, retry deadlines and useful reactivation. **Steps to reproduce** Start the normal server with a lifecycle plugin. Create an agent and wait for its durable acknowledgment. Stop the server, create another agent while it is stopped, and restart with the same files. Request an owned idle hold after ordinary replay. Check the exact listener backend and matched Node socket before shutdown. Let the valid hold expire naturally, then make an ordinary authorized agent update and check its durable plugin acknowledgment. Live executing or persistent preview services remain active. The separate hosted preview dependency [paperclip-cloud#478](https://github.com/paperclipai/paperclip-cloud/pull/478) retains its own App and operator prerequisites. This PR does not claim deployed preview or suspension acceptance. ## What Changed - Add a private, exclusive database listener owner that inherits the normal client policy. - Prepare plugin lifecycle retirement after native retirement under the same current idle hold. - Use one delivery-eligibility helper for dispatch and idle journal inspection. It preserves the same manifest, capability, company-disable query, and skip rules. - Close only the owned listener. Keep failed close ownership and inspection failures visible. - Resume listening on normal release or expiry, before journal replay. - Preserve disabled-feature retry history and the original deadline on re-enable. - Await lifecycle disposal during normal shutdown and consumer teardown. - Add owner regressions and document the lifecycle contract. ## Verification **Current integrated source:** `0f83005122ef1bdb1f1e08f8f58424c9a840ebb1`. Fresh normal Runner TypeScript passed in 0.785 seconds. The complete normal App layered semantic check passed in 28.284 seconds. All tracked source, the lockfile and the 18 separate private witness inputs stayed unchanged. Known compiler owners closed and the isolated temporary home was removed. **Current product qualification:** at measured integration `896cc9b62993e6ed10418d7a7417e1ddbf8dc89c`, all three whole files passed with no failures or skips: lifecycle delivery 40, runtime hooks 20, and runtime assignments 24. The actual physical LISTEN and natural-expiry controls passed. All owned clients and captured processes closed. Independent final session checks were zero before normal deletion of each successful test database. **Normal full-App physical retirement:** the original case passed in 47.574 seconds. It exercised real startup, readiness, SDK callbacks, durable ACK, foreign-company exclusion, physical stop, offline input and cold replay. The initial unknown hold and its ordinary release remain in the evidence. After the original 30.5-second cadence, a new valid hold returned quiescent. The exact current cold-App LISTEN backend and its matched Node socket closed before server stop. **Normal full-App natural expiry:** the separate original case passed in 77.391 seconds under its original 90-second body cap. The final valid 30-second hold expired naturally. A new listener backend and matched current App socket appeared before fresh input. An ordinary authorized agent-name PATCH produced journal event 20, the actual SDK callback and durable ACK. Foreign exclusion and readiness assertions passed. The final measured hold had no manual release, replacement hold or shortened TTL. The original preparatory unknown hold, ordinary release and 30.5-second cadence remain explicit. Both App cases used the normal current server helpers and actual SDK bundling/worker entry. A private compatibility change supplied the same owned HOME at the exact existing child-fork boundary. It did not change the product helpers, business assertions or timing limits. These separate acceptance fixtures remain outside the focused product diff. **Source carry to the current candidate:** all nine other owned files, listener mechanics, actual producer paths, SDK paths and measured hold behavior are unchanged. The sole owned intersection from newer master is an independent environment-delete cleanup callback in `app.ts`. The plugin witnesses do not call that route. Normal merges preserve every unrelated master blob and file mode. The fresh current semantic checks above qualify the integrated source. New master Native crash-recovery and saved-copyback Retry capabilities are not claimed by these plugin cases. **Actual master merge target:** newer master contains retained plugin Runner release/resume and other Native recovery fixes. The projected combined target passed fresh normal Runner TypeScript in 0.799 seconds and the full App semantic check in 29.715 seconds. All ten plugin-owned blobs and modes remain exact to the published head; every unrelated master change is preserved. The measured plugin cases create no Native provider turn or warm Native session. The unchanged native-first/plugin-second ordering still checks the native result before listener retirement. Later sparse-checkpoint and Native response-publication changes do not enter those paths. This is a scoped source carry, not new hosted Runner acceptance. **Failures retained:** an earlier reactivation fixture's real timer returned one millisecond before its original wall-clock expiry. Its private fixture now waits any positive remainder and retains the same expiry assertion and limits. The earlier partial-composition `unknown` result remains distinct from normal-startup proof. Current qualification also exposed missing normal workspace dependencies and a private guard that assumed loose Git refs. Normal owned dependency setup and an exact loose-or-packed reader fixed those setup failures. No business assertion, timeout, permission boundary or product sleep gate was relaxed. Repeat focused product coverage with a fresh external fixture database and the normal server Vitest command, one worker: ```sh pnpm --filter @paperclipai/server exec vitest run src/__tests__/plugin-lifecycle-delivery.test.ts --maxWorkers=1 --no-file-parallelism pnpm --filter @paperclipai/server exec vitest run src/__tests__/agent-runtime-hooks.test.ts --maxWorkers=1 --no-file-parallelism pnpm --filter @paperclipai/server exec vitest run src/__tests__/agent-runtime-assignments.test.ts --maxWorkers=1 --no-file-parallelism ``` Exact current head `0f83005122ef1bdb1f1e08f8f58424c9a840ebb1` has 60 successful checks, three skips and one neutral result, with no pending or failing contexts. Greptile completed its full review at 5/5 with no actionable issue. All root and nested review pages were read; the earlier eligibility finding is resolved. Ponytail's latest turn stopped before a verdict. A normal retry was requested; no current Ponytail verdict or score is claimed. Previous `3ca86ec` CI and scores are historical. A full local repository typecheck/test/build or browser run is not claimed. Controlled local App/SDK/PostgreSQL proof does not establish live provider, deployed Railway/Neon suspension, signed Cloud alarms or global exactly-once delivery. ## Risks - A listener close failure or uncertain inspection must continue to block sleep. - Release, expiry, configuration changes, and journal arrival can race with retirement. Epoch and current-hold checks retain conservative results. - Delivery remains at least once across uncertain ACK boundaries. This change does not add global exactly-once delivery. - The database helper closes only its exclusive listener client. Other owners and subscriptions keep their resources. ## Model Used OpenAI `gpt-6.1-sol`, reasoning and code/tool use. The managing Codex agent implemented shared database/composition seams and reviewed the change; its exact model ID was not exposed. The runtime does not expose a verified context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
412 lines
32 KiB
TypeScript
412 lines
32 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { agents, heartbeatRuns, issues, companies, companyMemberships, applyPendingMigrations, closeFileStorageDb, createDb, instanceSettings, pluginConfig, plugins, projects, toolApplications, toolConnections, toolConnectionInstalls, toolProfiles, toolMcpGateways, toolProfileBindings, resourceLifecycleEvents, type Db } from "@paperclipai/db";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { pluginAgentRuntimeState } from "../services/plugin-agent-runtime-state.js";
|
|
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
|
|
import { registerPluginLifecycleDelivery } from "../services/plugin-lifecycle-delivery.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { createToolGatewayService } from "../services/tool-gateway.js";
|
|
import { connectionIntentService } from "../services/connection-intents.js";
|
|
import { aiConnectionService } from "../services/ai-connections.js";
|
|
|
|
vi.mock("../services/subscription-refresh.js", () => ({ refreshSubscriptionConnection: vi.fn().mockResolvedValue(undefined) }));
|
|
|
|
const externalUrl = process.env.PAPERCLIP_AGENT_RUNTIME_ASSIGNMENTS_TEST_DATABASE_URL;
|
|
const expectedDatabase = process.env.PAPERCLIP_AGENT_RUNTIME_ASSIGNMENTS_EXPECTED_DATABASE;
|
|
if (externalUrl || expectedDatabase) {
|
|
if (!externalUrl || !expectedDatabase || !/^sleep2_[a-z0-9_]+_test$/.test(expectedDatabase)) {
|
|
throw new Error("Explicit external fixture URL and expected database are required");
|
|
}
|
|
const target = new URL(externalUrl);
|
|
if (!["postgres:", "postgresql:"].includes(target.protocol) || target.hostname !== "127.0.0.1" || target.port !== "55448" || target.pathname !== `/${expectedDatabase}` || target.search) {
|
|
throw new Error("External fixture must match the exact owned database");
|
|
}
|
|
}
|
|
const support = externalUrl ? { supported: true as const } : await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
|
|
describeDatabase("connection assignment synchronization", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let companyId: string;
|
|
let agentId: string;
|
|
let peerId: string;
|
|
let pluginId: string;
|
|
let applicationId: string;
|
|
let connectionId: string;
|
|
beforeAll(async () => {
|
|
if (externalUrl) await applyPendingMigrations(externalUrl);
|
|
else database = await startEmbeddedPostgresTestDatabase("paperclip-runtime-assignments-");
|
|
db = createDb(externalUrl ?? database.connectionString, { maxConnections: 4 });
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
try { if (db) await closeFileStorageDb(db, 1); }
|
|
finally {
|
|
try { await db?.$client.end({ timeout: 1 }); }
|
|
finally { await database?.cleanup(); }
|
|
}
|
|
});
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
beforeEach(async () => {
|
|
companyId = randomUUID(); agentId = randomUUID(); peerId = randomUUID(); pluginId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Connector runtime fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "runtime-fixture", version: "1.0.0", status: "ready", manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] } as never });
|
|
await db.insert(agents).values([agentId, peerId].map(id => ({ id, companyId, name: id, adapterType: "paperclip_runner", lifecycleRequiredPluginIds: [pluginId] })));
|
|
const [application] = await db.insert(toolApplications).values({ companyId, name: "Fixture MCP", type: "mcp_http" }).returning(); applicationId = application.id;
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId, name: "Fixture connection", uid: randomUUID(), transport: "mcp_remote", authKind: "none", enabled: true, status: "active" }).returning(); connectionId = connection.id;
|
|
});
|
|
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"))).orderBy(resourceLifecycleEvents.id);
|
|
|
|
it("journals gateway definitions while preserving ordered agent locks", async () => {
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "gateway-fixture", name: "Gateway fixture" }).returning();
|
|
const [lower, higher] = [agentId, peerId].sort();
|
|
let locked!: () => void; let release!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(agents).where(eq(agents.id, lower)).for("no key update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const before = await events();
|
|
const pids: number[] = [];
|
|
const saves = [higher, lower].map((target, index) => db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(row.pid));
|
|
return createToolGatewayService(tx as unknown as Db).createNamedGateway({ companyId, body: { name: `Gateway ${index}`, profileId: profile.id, agentId: target } });
|
|
}));
|
|
const completed = Promise.allSettled(saves);
|
|
try {
|
|
await vi.waitFor(async () => {
|
|
expect(pids).toHaveLength(2);
|
|
for (const pid of pids) {
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}
|
|
}, { timeout: 5000 });
|
|
} finally { release(); await blocker; }
|
|
const results = await completed;
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
expect(results.map(result => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
expect((await events()).slice(before.length).map(row => row.resourceId).sort()).toEqual([agentId, agentId, peerId, peerId].sort());
|
|
expect(await db.select().from(toolMcpGateways).where(eq(toolMcpGateways.companyId, companyId))).toHaveLength(2);
|
|
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, companyId))).toHaveLength(2);
|
|
}, 15000);
|
|
it("captures profile assignment and revocation without exposing another agent's assignments", async () => {
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "fixture", name: "Fixture profile" }).returning();
|
|
const [binding] = await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId }).returning();
|
|
const [install] = await db.insert(toolConnectionInstalls).values({ companyId, connectionId, targetType: "agent", targetId: agentId }).returning();
|
|
const owner = await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
expect(owner!.connectionAssignments).toMatchObject({ connections: [expect.objectContaining({ id: connectionId })], profileIds: [profile.id] });
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, peerId))!.connectionAssignments).toMatchObject({ connections: [], profileIds: [] });
|
|
const before = (await events()).length;
|
|
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.id, binding.id));
|
|
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.id, install.id));
|
|
expect(await events()).toHaveLength(before + 2);
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.connectionAssignments).toMatchObject({ connections: [], profileIds: [] });
|
|
});
|
|
it("journals only snapshot recipients when bindings and installs change", async () => {
|
|
const service = toolAccessService(db);
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "scoped", name: "Scoped profile" }).returning();
|
|
const [project] = await db.insert(projects).values({ companyId, name: "Project binding fixture" }).returning();
|
|
for (let round = 0; round < 2; round++) {
|
|
let before = (await events()).length;
|
|
await service.bindProfile(profile.id, { targetType: "agent", targetId: agentId, priority: 100 });
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
|
before = (await events()).length;
|
|
await service.unbindProfile(profile.id, { targetType: "agent", targetId: agentId });
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
|
}
|
|
const beforeProject = await events();
|
|
await service.bindProfile(profile.id, { targetType: "project", targetId: project.id, priority: 100 });
|
|
await service.unbindProfile(profile.id, { targetType: "project", targetId: project.id });
|
|
expect(await events()).toEqual(beforeProject);
|
|
let before = (await events()).length;
|
|
await db.transaction(async tx => {
|
|
const [binding] = await tx.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId }).returning();
|
|
await tx.delete(toolProfileBindings).where(eq(toolProfileBindings.id, binding.id));
|
|
});
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
|
before = (await events()).length;
|
|
const [install] = await db.insert(toolConnectionInstalls).values({ companyId, connectionId, targetType: "agent", targetId: agentId }).returning();
|
|
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
|
|
before = (await events()).length;
|
|
await db.update(toolConnectionInstalls).set({ targetId: peerId }).where(eq(toolConnectionInstalls.id, install.id));
|
|
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
|
|
before = (await events()).length;
|
|
await service.bindProfile(profile.id, { targetType: "company", targetId: companyId, priority: 100 });
|
|
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
|
|
});
|
|
it("keeps connector changes and their events atomic and company-scoped", async () => {
|
|
const before = await events();
|
|
await expect(db.transaction(async tx => {
|
|
await tx.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, connectionId));
|
|
throw new Error("rollback");
|
|
})).rejects.toThrow("rollback");
|
|
expect(await events()).toEqual(before);
|
|
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT connector_fixture_failure CHECK (resource_type <> 'agent_runtime') NOT VALID`);
|
|
try { await expect(db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, connectionId))).rejects.toThrow(); }
|
|
finally { await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT connector_fixture_failure`); }
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)))[0].enabled).toBe(true);
|
|
const other = randomUUID();
|
|
await db.insert(companies).values({ id: other, name: "Other company", issuePrefix: `X${other.slice(0, 6)}` });
|
|
await db.insert(toolApplications).values({ companyId: other, name: "Other MCP", type: "mcp_http" });
|
|
expect(await events()).toEqual(before);
|
|
});
|
|
it("fails closed when standing assignments exceed the bounded snapshot", async () => {
|
|
const added = await db.insert(toolConnections).values(Array.from({ length: 513 }, (_, index) => ({ companyId, applicationId, name: `bounded-${index}`, uid: randomUUID(), transport: "mcp_remote" as const, authKind: "none" as const }))).returning({ id: toolConnections.id });
|
|
await db.insert(toolConnectionInstalls).values(added.map(connection => ({ companyId, connectionId: connection.id, targetType: "agent" as const, targetId: agentId })));
|
|
await expect(pluginAgentRuntimeState(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 422 });
|
|
});
|
|
it("invalidates connector feature switches and returns their effective standing state", async () => {
|
|
await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
const before = (await events()).length;
|
|
await db.update(instanceSettings).set({ experimental: { enableChatConnectors: true } });
|
|
expect(await events()).toHaveLength(before + 2);
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.connectionAssignments.features).toEqual({ publicMcp: false, chatConnectors: true });
|
|
});
|
|
it("refreshes acknowledged runtime state after managed feature overlays change across restarts", async () => {
|
|
await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
await db.insert(pluginConfig).values({ pluginId, companyId, configJson: {} });
|
|
const inbox = pluginLifecycleInbox(db, pluginId);
|
|
for (const event of await events()) await inbox.acknowledge(companyId, String(event.id));
|
|
for (const enabled of [true, false]) {
|
|
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: { enablePublicMcp: enabled, enableChatConnectors: enabled }, plugins: { autoInstall: [] } }));
|
|
const before = (await events()).length;
|
|
const snapshots: Array<{ publicMcp: boolean; chatConnectors: boolean }> = [];
|
|
const call = vi.fn(async (_plugin, _method, event) => {
|
|
const state = await pluginAgentRuntimeState(db, pluginId, companyId, event.resourceId);
|
|
snapshots.push(state!.connectionAssignments.features);
|
|
return { eventId: event.id, status: "acknowledged" };
|
|
});
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
|
|
const delivery = registerPluginLifecycleDelivery(coordinator, db, { call, isRunning: () => true, onConfigApplied: () => () => {} } as never, new EventEmitter() as never);
|
|
try {
|
|
await delivery.ready;
|
|
await vi.waitFor(() => expect(snapshots).toEqual(Array(2).fill({ publicMcp: enabled, chatConnectors: enabled })), { timeout: 5000 });
|
|
await vi.waitFor(async () => expect(await inbox.list(companyId, 100, undefined, ["agent_runtime"])).toEqual([]), { timeout: 5000 });
|
|
expect(await events()).toHaveLength(before + 2);
|
|
} finally { await delivery.dispose(); await coordinator.stop(); }
|
|
}
|
|
});
|
|
|
|
it("versions every returned connection field and skips unrelated bookkeeping", async () => {
|
|
await db.insert(toolConnectionInstalls).values({ companyId, connectionId, targetType: "company", targetId: companyId });
|
|
const initial = await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
const before = await events();
|
|
await db.update(toolConnections).set({ healthCheckedAt: new Date(), lastError: "fixture", updatedAt: new Date() }).where(eq(toolConnections.id, connectionId));
|
|
expect(await events()).toEqual(before);
|
|
await db.transaction(async tx => {
|
|
await tx.update(toolConnections).set({ name: "Renamed", enabled: false }).where(eq(toolConnections.id, connectionId));
|
|
await tx.update(toolConnections).set({ status: "disabled", transport: "local_stdio" }).where(eq(toolConnections.id, connectionId));
|
|
});
|
|
expect(await events()).toHaveLength(before.length + 2);
|
|
const current = await pluginAgentRuntimeState(db, pluginId, companyId, agentId);
|
|
expect(current!.revision).not.toBe(initial!.revision);
|
|
expect(current!.connectionAssignments.connections).toEqual([{ id: connectionId, name: "Renamed", transport: "local_stdio", enabled: false, status: "disabled" }]);
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "status-fixture", name: "Status fixture" }).returning();
|
|
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "company", targetId: companyId });
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.connectionAssignments.profileIds).toContain(profile.id);
|
|
const active = await events();
|
|
await db.update(toolProfiles).set({ updatedAt: new Date() }).where(eq(toolProfiles.id, profile.id));
|
|
expect(await events()).toEqual(active);
|
|
await db.update(toolProfiles).set({ status: "archived" }).where(eq(toolProfiles.id, profile.id));
|
|
expect(await events()).toHaveLength(active.length + 2);
|
|
expect((await pluginAgentRuntimeState(db, pluginId, companyId, agentId))!.connectionAssignments.profileIds).not.toContain(profile.id);
|
|
});
|
|
|
|
it("rejects excessive active profile assignments", async () => {
|
|
const profiles = await db.insert(toolProfiles).values(Array.from({ length: 129 }, (_, index) => ({ companyId, profileKey: `bound-${index}`, name: `Bound ${index}` }))).returning();
|
|
await db.insert(toolProfileBindings).values(profiles.map(profile => ({ companyId, profileId: profile.id, targetType: "company" as const, targetId: companyId })));
|
|
await expect(pluginAgentRuntimeState(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 422 });
|
|
});
|
|
|
|
it.each(["bind", "unbind", "delete", "reassign"] as const)("completes profile %s with a single database connection", async change => {
|
|
const [source, target] = await db.insert(toolProfiles).values([
|
|
{ companyId, profileKey: "single-source", name: "Single source" },
|
|
{ companyId, profileKey: "single-target", name: "Single target" },
|
|
]).returning();
|
|
if (change !== "bind") await db.insert(toolProfileBindings).values({ companyId, profileId: source.id, targetType: "agent", targetId: agentId });
|
|
const single = createDb(externalUrl ?? database.connectionString, { maxConnections: 1 });
|
|
const service = toolAccessService(single);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const work = change === "bind" ? service.bindProfile(source.id, { targetType: "agent", targetId: agentId, priority: 100 })
|
|
: change === "unbind" ? service.unbindProfile(source.id, { targetType: "agent", targetId: agentId })
|
|
: service.deleteProfile(source.id, change === "reassign" ? { force: false, reassignToProfileId: target.id } : { force: true });
|
|
const settled = Promise.allSettled([work]);
|
|
try {
|
|
await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("Profile operation waited for another pooled connection")), 3000); })]);
|
|
if (change === "bind") expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, source.id))).toHaveLength(1);
|
|
else if (change === "unbind") expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, source.id))).toHaveLength(0);
|
|
else {
|
|
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, source.id))).toHaveLength(0);
|
|
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, target.id))).toHaveLength(change === "reassign" ? 1 : 0);
|
|
}
|
|
} finally { clearTimeout(timer); await single.$client.end({ timeout: 1 }); await settled; }
|
|
}, 10000);
|
|
|
|
it.each(["bind", "unbind", "reassign"] as const)("orders profile %s with setup finalization before assignment fanout", async change => {
|
|
const targetAgent = [agentId, peerId].sort()[0];
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: `app:${connectionId}`, name: "Managed profile" }).returning();
|
|
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "company", targetId: companyId });
|
|
if (change === "unbind") await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: targetAgent });
|
|
const [source] = change === "reassign" ? await db.insert(toolProfiles).values({ companyId, profileKey: "source", name: "Source profile" }).returning() : [];
|
|
if (source) await db.insert(toolProfileBindings).values({ companyId, profileId: source.id, targetType: "agent", targetId: targetAgent });
|
|
let locked!: () => void;
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(agents).where(eq(agents.id, [agentId, peerId].sort()[0])).for("no key update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const pids: number[] = [];
|
|
const run = (work: (service: ReturnType<typeof toolAccessService>) => Promise<unknown>) => db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [backend] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(backend.pid));
|
|
return work(toolAccessService(tx as unknown as Db));
|
|
});
|
|
const waitForLock = (index: number) => vi.waitFor(async () => {
|
|
expect(pids.length).toBeGreaterThan(index);
|
|
const [activity] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pids[index]}`);
|
|
expect(activity.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
const mutation = run(writer => change === "bind"
|
|
? writer.bindProfile(profile.id, { targetType: "agent", targetId: targetAgent, priority: 100 })
|
|
: change === "unbind" ? writer.unbindProfile(profile.id, { targetType: "agent", targetId: targetAgent })
|
|
: writer.deleteProfile(source!.id, { force: false, reassignToProfileId: profile.id }));
|
|
const mutationResult = Promise.allSettled([mutation]);
|
|
let finishResult: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
|
try {
|
|
await waitForLock(0);
|
|
finishResult = Promise.allSettled([run(writer => writer.finishGalleryAppConnection(companyId, connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" }))]);
|
|
await waitForLock(1);
|
|
} finally { release(); await blocker; }
|
|
for (const result of [...await mutationResult, ...await finishResult!]) if (result.status === "rejected") throw result.reason;
|
|
expect((await db.select().from(toolProfiles).where(eq(toolProfiles.id, profile.id)))[0].status).toBe("active");
|
|
if (source) expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, source.id))).toEqual([]);
|
|
}, 15000);
|
|
|
|
it.each(["connection settings", "profile binding"] as const)("orders AI adoption with concurrent %s using foreign-key-compatible locks", async change => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-adoption-fixture-")));
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "adoption-fixture");
|
|
const [lower, higher] = [agentId, peerId].sort();
|
|
const userId = `adoption-${randomUUID()}`;
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
let release!: () => void;
|
|
try {
|
|
await db.update(toolConnections).set({ config: { url: "https://mcp.example.test/mcp" } }).where(eq(toolConnections.id, connectionId));
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
|
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }).where(eq(agents.id, higher));
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Legacy AI adoption", status: "in_progress", assigneeAgentId: higher, responsibleUserId: userId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: higher, nativeIssueId: issueId, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: userId, contextSnapshot: {} });
|
|
const intents = connectionIntentService(db);
|
|
const card = await intents.requestForRunAuthFailure(runId);
|
|
const setup = await intents.setupOptions(card!.interactionId!);
|
|
expect(setup.aiConnectionRequiresAdoption).toBe(true);
|
|
const account = await aiConnectionService(db).save(companyId, userId, { provider: "openai", method: "api_key", ownership: "personal", name: "Adopted account", apiKey: "fixture", allAgents: true, agentIds: [] }, "fixture-api-key");
|
|
const [bindingProfile] = change === "profile binding" ? await db.insert(toolProfiles).values({ companyId, profileKey: "adoption-binding", name: "Adoption binding" }).returning() : [];
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, higher));
|
|
let locked!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(agents).where(eq(agents.id, lower)).for("no key update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const pids: number[] = [];
|
|
const run = (work: (client: Db) => Promise<unknown>) => db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [backend] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(backend.pid));
|
|
return work(tx as unknown as Db);
|
|
});
|
|
const waitForLock = (index: number) => vi.waitFor(async () => {
|
|
expect(pids.length).toBeGreaterThan(index);
|
|
const [activity] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pids[index]}`);
|
|
expect(activity.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
const adopt = (client: Db) => connectionIntentService(client).complete(card!.interactionId!, account.connectionId, userId, { validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection! } });
|
|
const first = run(change === "connection settings" ? client => toolAccessService(client, { remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }).updateConnection(connectionId, { name: "Concurrent settings" }) : adopt);
|
|
let second: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
|
const firstResult = Promise.allSettled([first]);
|
|
try {
|
|
await waitForLock(0);
|
|
second = Promise.allSettled([run(change === "connection settings" ? adopt : client => toolAccessService(client).bindProfile(bindingProfile!.id, { targetType: "agent", targetId: lower, priority: 100 }, { actorType: "agent", actorId: higher }))]);
|
|
await waitForLock(1);
|
|
} finally { release(); await blocker; }
|
|
for (const result of [...await firstResult, ...await second!]) if (result.status === "rejected") throw result.reason;
|
|
expect((await db.select().from(agents).where(eq(agents.id, higher)))[0].runtimeConfig.aiConnection).toEqual(setup.aiConnection);
|
|
if (change === "connection settings") expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)))[0].name).toBe("Concurrent settings");
|
|
else expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, bindingProfile!.id))).toEqual(expect.arrayContaining([expect.objectContaining({ targetType: "agent", targetId: lower })]));
|
|
expect(await toolAccessService(db).listConnectionInstalls(account.connectionId, companyId)).toEqual(expect.arrayContaining([expect.objectContaining({ targetType: "agent", targetId: higher })]));
|
|
expect((await intents.setupOptions(card!.interactionId!)).interaction.status).toBe("accepted");
|
|
} finally { release?.(); await rm(home, { recursive: true, force: true }); }
|
|
}, 20000);
|
|
|
|
it.each(["installs", "finish", "activate", "enable", "revive", "reassign"] as const)("orders %s replacement before scoped and company runtime locks", async change => {
|
|
const [lower, higher] = [agentId, peerId].sort();
|
|
await db.update(toolConnections).set({ config: { url: "https://mcp.example.test/mcp" } }).where(eq(toolConnections.id, connectionId));
|
|
const [app] = await db.insert(toolApplications).values({ companyId, name: "Replacement app", type: "mcp_http" }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: app.id, name: "Replacement connection", uid: randomUUID(), transport: "mcp_remote", status: change === "activate" ? "draft" : "active", enabled: change !== "enable" }).returning();
|
|
const [source, target] = await db.insert(toolProfiles).values([
|
|
{ companyId, profileKey: `app:${connection.id}`, name: "Replacement source", status: change === "revive" ? "archived" : "active" },
|
|
{ companyId, profileKey: "replacement-target", name: "Replacement target" },
|
|
]).returning();
|
|
await db.insert(toolProfileBindings).values({ companyId, profileId: source.id, targetType: "agent", targetId: higher });
|
|
if (change === "installs") await db.insert(toolConnectionInstalls).values({ companyId, connectionId: connection.id, targetType: "agent", targetId: higher });
|
|
let locked!: () => void; let release!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(agents).where(eq(agents.id, lower)).for("no key update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const pids: number[] = [];
|
|
const run = (work: (service: ReturnType<typeof toolAccessService>) => Promise<unknown>) => db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [backend] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(backend.pid));
|
|
return work(toolAccessService(tx as unknown as Db, { remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }));
|
|
});
|
|
const waitForLock = (index: number) => vi.waitFor(async () => {
|
|
expect(pids.length).toBeGreaterThan(index);
|
|
const [activity] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pids[index]}`);
|
|
expect(activity.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
const settings = Promise.allSettled([run(service => service.updateConnection(connectionId, { name: "Ordered replacement settings" }))]);
|
|
let replacement: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
|
try {
|
|
await waitForLock(0);
|
|
replacement = Promise.allSettled([run(service => change === "installs"
|
|
? service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: lower }] })
|
|
: change === "reassign" ? service.deleteProfile(source.id, { force: false, reassignToProfileId: target.id })
|
|
: service.finishGalleryAppConnection(companyId, connection.id, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [change === "finish" ? lower : higher] } }))]);
|
|
await waitForLock(1);
|
|
} finally { release(); await blocker; }
|
|
for (const result of [...await settings, ...await replacement!]) if (result.status === "rejected") throw result.reason;
|
|
if (change === "installs") expect(await toolAccessService(db).listConnectionInstalls(connection.id, companyId)).toEqual([expect.objectContaining({ targetType: "agent", targetId: lower })]);
|
|
else expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, change === "reassign" ? target.id : source.id))).toEqual([expect.objectContaining({ targetType: "agent", targetId: change === "finish" ? lower : higher })]);
|
|
if (change === "activate" || change === "enable" || change === "revive") {
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))[0].status).toBe("active");
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))[0].enabled).toBe(true);
|
|
expect((await db.select().from(toolProfiles).where(eq(toolProfiles.id, source.id)))[0].status).toBe("active");
|
|
}
|
|
}, 15000);
|
|
|
|
});
|