mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +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>
791 lines
51 KiB
TypeScript
791 lines
51 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import * as databaseModule from "@paperclipai/db";
|
|
import * as idleWorkModule from "../services/idle-local-work.js";
|
|
import { idleWorkSnapshot, isIdleTaskDrainActive, registerTaskDrainExpiryWork, startTaskDrain, stopTaskDrain } from "../services/task-admission.js";
|
|
import { agents, companies, applyPendingMigrations, createDb, companySecrets, projects, pluginCompanySettings, pluginConfig, plugins, 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 { logger } from "../middleware/logger.js";
|
|
import { registerPluginLifecycleDelivery } from "../services/plugin-lifecycle-delivery.js";
|
|
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
|
|
import { pluginRegistryService } from "../services/plugin-registry.js";
|
|
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "../services/resource-lifecycle-events.js";
|
|
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import type { PluginLifecycleManager } from "../services/plugin-lifecycle.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
|
|
const externalUrl = process.env.PAPERCLIP_PLUGIN_LIFECYCLE_TEST_DATABASE_URL;
|
|
const expectedDatabase = process.env.PAPERCLIP_PLUGIN_LIFECYCLE_EXPECTED_DATABASE;
|
|
if (externalUrl || expectedDatabase) {
|
|
if (!externalUrl || !expectedDatabase || !/^sleep2_[a-z0-9_]+_test$/.test(expectedDatabase)) throw new Error("Explicit lifecycle test database 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("Unexpected lifecycle test database");
|
|
}
|
|
const support = externalUrl ? { supported: true as const } : await getEmbeddedPostgresTestSupport();
|
|
const describePostgres = support.supported ? describe : describe.skip;
|
|
if (!support.supported) console.warn(`Skipping lifecycle delivery database tests: ${support.reason}`);
|
|
|
|
describePostgres("durable plugin lifecycle delivery", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let companyId: string;
|
|
let pluginId: string;
|
|
let listenerOwners: Array<{ listen: Db["$client"]["listen"]; close: ReturnType<typeof vi.fn> }>;
|
|
const deliveries = new Set<ReturnType<typeof registerPluginLifecycleDelivery>>();
|
|
beforeAll(async () => {
|
|
if (externalUrl) await applyPendingMigrations(externalUrl);
|
|
else database = await startEmbeddedPostgresTestDatabase("paperclip-plugin-delivery-");
|
|
db = createDb(externalUrl ?? database.connectionString, { maxConnections: 4 });
|
|
}, 90_000);
|
|
afterAll(async () => { try { await db?.$client.end({ timeout: 1 }); } finally { await database?.cleanup(); } });
|
|
afterEach(async () => {
|
|
try { await Promise.all([...deliveries].map(worker => worker.dispose())); }
|
|
finally { deliveries.clear(); stopTaskDrain(); vi.restoreAllMocks(); }
|
|
});
|
|
beforeEach(async () => {
|
|
listenerOwners = [];
|
|
vi.spyOn(db.$client, "listen").mockResolvedValue({ unlisten: vi.fn(), state: {} } as never);
|
|
vi.spyOn(databaseModule, "createOwnedDatabaseListener").mockImplementation(() => {
|
|
const owner = { listen: db.$client.listen, close: vi.fn().mockResolvedValue(undefined) };
|
|
listenerOwners.push(owner);
|
|
return owner;
|
|
});
|
|
await db.delete(plugins);
|
|
companyId = randomUUID(); pluginId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Delivery fixture", issuePrefix: `L${companyId.slice(0, 6)}` });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "delivery-fixture", version: "1.0.0", status: "ready",
|
|
manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe"] } as never });
|
|
await db.insert(pluginConfig).values({ pluginId, companyId, configJson: {} });
|
|
});
|
|
async function event(resourceId: string = randomUUID(), action: "create" | "update" | "delete" = "create") {
|
|
await db.transaction(async tx => {
|
|
if (action === "create") await recordResourceCreationEvent(tx as unknown as Db, companyId, "project", resourceId);
|
|
else await recordProjectLifecycleEvent(tx as unknown as Db, companyId, resourceId, action);
|
|
});
|
|
return resourceId;
|
|
}
|
|
function delivery() {
|
|
let task!: Parameters<ReturnType<typeof createDeliveryWorkCoordinator>["register"]>[1];
|
|
const wake = vi.fn();
|
|
const coordinator = { register: vi.fn((_queue, input) => { task = input; return { wake, ready: Promise.resolve(), stop: vi.fn().mockResolvedValue(undefined) }; }) };
|
|
const call = vi.fn(async (_pluginId, _method, input) => ({ eventId: input.id, status: "acknowledged" }));
|
|
const running = vi.fn(() => true);
|
|
const lifecycle = new EventEmitter();
|
|
const configApplied = new Set<() => void>();
|
|
const onConfigApplied = (listener: () => void) => { configApplied.add(listener); return () => { configApplied.delete(listener); }; };
|
|
const worker = registerPluginLifecycleDelivery(coordinator as never, db, { call, isRunning: running, onConfigApplied } as unknown as PluginWorkerManager, lifecycle as unknown as PluginLifecycleManager);
|
|
deliveries.add(worker);
|
|
return { call, running, wake, lifecycle, worker, applied: () => { for (const listener of configApplied) listener(); }, run: () => task.run(new AbortController().signal), pending: () => task.hasPending(), nextRunAt: () => task.nextRunAt?.(), settled: () => task.onSettled?.() };
|
|
}
|
|
async function withManagedPlugin(enabledCompanies: string[], run: () => Promise<void>) {
|
|
const root = await mkdtemp(path.join(tmpdir(), "delivery-managed-"));
|
|
await mkdir(path.join(root, "sandbox-providers/daytona"), { recursive: true });
|
|
vi.stubEnv("PAPERCLIP_BUNDLED_PLUGIN_ROOT", root);
|
|
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "1", features: {},
|
|
plugins: { autoInstall: ["daytona"], workerEnv: { daytona: ["PROVIDER_ORIGIN"] }, enabledCompanies: { daytona: enabledCompanies } } }));
|
|
try { await run(); }
|
|
finally { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); }
|
|
}
|
|
it("delivers to a managed plugin with no config row and a stale saved path only in the companies the document enables", async () => {
|
|
const unlistedCompanyId = randomUUID(), disabledCompanyId = randomUUID(), absentCompanyId = randomUUID();
|
|
await withManagedPlugin([companyId, disabledCompanyId, absentCompanyId], async () => {
|
|
const managedId = randomUUID(), ungrantedId = randomUUID();
|
|
const manifestJson = { resourceLifecycle: ["project"], capabilities: ["events.subscribe"] } as never;
|
|
await db.insert(companies).values([
|
|
{ id: unlistedCompanyId, name: "Unlisted company", issuePrefix: `O${unlistedCompanyId.slice(0, 6)}` },
|
|
{ id: disabledCompanyId, name: "Disabled company", issuePrefix: `D${disabledCompanyId.slice(0, 6)}` },
|
|
]);
|
|
await db.insert(plugins).values([
|
|
{ id: managedId, pluginKey: "paperclip.daytona-sandbox-provider", packageName: "managed", version: "1.0.0", status: "ready", packagePath: "/old/image/plugins/daytona", manifestJson },
|
|
{ id: ungrantedId, pluginKey: ungrantedId, packageName: "ungranted", version: "1.0.0", status: "ready", manifestJson },
|
|
]);
|
|
await db.insert(pluginCompanySettings).values({ pluginId: managedId, companyId: disabledCompanyId, enabled: false });
|
|
const resourceId = await event();
|
|
await db.transaction(async tx => {
|
|
await recordResourceCreationEvent(tx as unknown as Db, unlistedCompanyId, "project", randomUUID());
|
|
await recordResourceCreationEvent(tx as unknown as Db, disabledCompanyId, "project", randomUUID());
|
|
});
|
|
const d = delivery();
|
|
await d.run();
|
|
const delivered = (id: string) => d.call.mock.calls.filter(([plugin]) => plugin === id).map(([, , input]) => input.resourceId);
|
|
expect(delivered(managedId)).toEqual([resourceId]);
|
|
expect(delivered(ungrantedId)).toEqual([]);
|
|
expect(delivered(pluginId)).toEqual([resourceId]);
|
|
await d.worker.dispose();
|
|
});
|
|
});
|
|
it("listens for a managed plugin with no config row until its last enabled company disables it", async () => {
|
|
await withManagedPlugin([companyId], async () => {
|
|
await db.delete(plugins);
|
|
const managedId = randomUUID();
|
|
await db.insert(plugins).values({ id: managedId, pluginKey: "paperclip.daytona-sandbox-provider", packageName: "managed", version: "1.0.0", status: "ready",
|
|
manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never });
|
|
const subscription = { unlisten: vi.fn(), state: {} };
|
|
vi.mocked(db.$client.listen).mockResolvedValueOnce(subscription as never);
|
|
const d = delivery();
|
|
await d.worker.ready;
|
|
expect(db.$client.listen).toHaveBeenCalledWith("paperclip_plugin_lifecycle", expect.any(Function), expect.any(Function));
|
|
await db.insert(pluginCompanySettings).values({ pluginId: managedId, companyId, enabled: false });
|
|
d.applied();
|
|
await vi.waitFor(() => expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1));
|
|
await d.worker.dispose();
|
|
});
|
|
});
|
|
it("does not listen for a managed plugin that the document enables for no company", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "delivery-managed-"));
|
|
await mkdir(path.join(root, "sandbox-providers/daytona"), { recursive: true });
|
|
vi.stubEnv("PAPERCLIP_BUNDLED_PLUGIN_ROOT", root);
|
|
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "1", features: {},
|
|
plugins: { autoInstall: ["daytona"], workerEnv: { daytona: ["PROVIDER_ORIGIN"] } } }));
|
|
try {
|
|
await db.delete(plugins);
|
|
await db.insert(plugins).values({ id: randomUUID(), pluginKey: "paperclip.daytona-sandbox-provider", packageName: "managed", version: "1.0.0", status: "ready",
|
|
manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never });
|
|
const d = delivery();
|
|
await d.worker.ready;
|
|
expect(db.$client.listen).not.toHaveBeenCalled();
|
|
await d.worker.dispose();
|
|
} finally { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it("delivers project events to a storage provider only for projects attached to it", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId));
|
|
const [core] = await db.insert(projects).values({ companyId, name: "Core project" }).returning();
|
|
const [attached] = await db.insert(projects).values({ companyId, name: "Attached project", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).returning();
|
|
await event(core.id);
|
|
await event(attached.id);
|
|
const d = delivery();
|
|
try {
|
|
await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(1);
|
|
expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: attached.id });
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: pluginId } }).where(eq(projects.id, core.id));
|
|
await event(core.id, "update");
|
|
await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(2);
|
|
expect(d.call.mock.calls[1][2]).toMatchObject({ resourceId: core.id, action: "update" });
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
|
|
it("delivers a deleted project's event to a storage provider", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "workspaces.manage"], workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"] } } as never }).where(eq(plugins.id, pluginId));
|
|
const removed = await event(randomUUID(), "delete");
|
|
const d = delivery();
|
|
try {
|
|
await d.run();
|
|
expect(d.call.mock.calls[0][2]).toMatchObject({ resourceId: removed, action: "delete" });
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
|
|
it("supplies project native credentials only to opted-in callbacks and saves returned patches before acknowledgment", async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project"], capabilities: ["events.subscribe", "projects.instance-secrets"] } as never }).where(eq(plugins.id, pluginId));
|
|
const [project] = await db.insert(projects).values({ companyId, name: "Native credentials owner" }).returning();
|
|
await event(project.id);
|
|
const d = delivery();
|
|
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
|
expect(input.instanceSecrets).toEqual({});
|
|
return { eventId: input.id, status: "acknowledged", instanceSecrets: { mount_grants: "fixture-private-grants" } };
|
|
});
|
|
try {
|
|
await d.run();
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.ownerProjectId, project.id))).toHaveLength(1);
|
|
await event(project.id, "update");
|
|
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
|
expect(input.instanceSecrets).toEqual({ mount_grants: "fixture-private-grants" });
|
|
return { eventId: input.id, status: "retry", instanceSecrets: { mount_grants: "must-not-save" } };
|
|
});
|
|
await d.run();
|
|
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerProjectId, project.id));
|
|
expect(stored.latestVersion).toBe(1);
|
|
expect(JSON.stringify(await pluginLifecycleInbox(db, pluginId).list(companyId))).not.toContain("fixture-private-grants");
|
|
} finally { await d.worker.dispose(); vi.unstubAllEnvs(); }
|
|
});
|
|
it("rejects credential patches from plugins without project instance secret scope", async () => {
|
|
await event(); const d = delivery();
|
|
d.call.mockImplementationOnce(async (_p, _m, input) => {
|
|
expect(input.instanceSecrets).toBeUndefined();
|
|
return { eventId: input.id, status: "acknowledged", instanceSecrets: { mount_grants: "private" } };
|
|
});
|
|
try { await d.run(); expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toHaveLength(1); }
|
|
finally { await d.worker.dispose(); }
|
|
});
|
|
it.each([false, true])("does not wait for queued plugin callbacks during startup (runtime subscriber: %s)", async runtime => {
|
|
if (runtime) await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["project", "agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
await event();
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
|
const call = vi.fn(async (_plugin, _method, input) => { await blocked; return { eventId: input.id, status: "acknowledged" }; });
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: () => {} });
|
|
const worker = registerPluginLifecycleDelivery(coordinator, db, { call, isRunning: () => true, onConfigApplied: () => () => {} } as never, new EventEmitter() as never);
|
|
let ready = false;
|
|
void worker.ready.then(() => { ready = true; });
|
|
try {
|
|
await vi.waitFor(() => expect(call).toHaveBeenCalled(), { timeout: 5000 });
|
|
await vi.waitFor(() => expect(ready).toBe(true));
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toHaveLength(1);
|
|
} finally { release(); await worker.dispose(); await coordinator.stop(); }
|
|
});
|
|
describe("agent runtime events", () => {
|
|
const runtimeManifest = (capabilities: string[]) => ({ resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", ...capabilities] }) as never;
|
|
async function agent(name: string, requiredPluginIds: string[] | null) {
|
|
const [row] = await db.insert(agents).values({ companyId, name, adapterType: "process" }).returning({ id: agents.id });
|
|
await db.update(agents).set({ lifecycleRequiredPluginIds: requiredPluginIds }).where(eq(agents.id, row.id));
|
|
return row.id;
|
|
}
|
|
const delivered = (d: ReturnType<typeof delivery>) => new Set(d.call.mock.calls.map(c => c[2].resourceId));
|
|
async function drain(d: ReturnType<typeof delivery>) {
|
|
do { await d.run(); } while (await d.pending());
|
|
}
|
|
|
|
it("delivers only the agents a lifecycle plugin owns and acknowledges the rest", async () => {
|
|
await db.update(plugins).set({ manifestJson: runtimeManifest(["agents.lifecycle.manage"]) }).where(eq(plugins.id, pluginId));
|
|
const owned = await agent("Owned", [pluginId]);
|
|
await agent("Other plugin", [randomUUID()]);
|
|
await agent("No plugins", []);
|
|
await agent("Not selected", null);
|
|
const d = delivery();
|
|
try {
|
|
await drain(d);
|
|
expect(delivered(d)).toEqual(new Set([owned]));
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId, 100, undefined, ["agent_runtime"])).toEqual([]);
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
|
|
it("delivers a new event after an agent selects the plugin and reaches ready", async () => {
|
|
await db.update(plugins).set({ manifestJson: runtimeManifest(["agents.lifecycle.manage"]) }).where(eq(plugins.id, pluginId));
|
|
const id = await agent("Later owned", null);
|
|
const d = delivery();
|
|
try {
|
|
await drain(d);
|
|
expect(d.call).not.toHaveBeenCalled();
|
|
await db.update(agents).set({ lifecycleState: "preparing", lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, id));
|
|
await db.update(agents).set({ lifecycleState: "ready" }).where(eq(agents.id, id));
|
|
await drain(d);
|
|
expect(delivered(d)).toEqual(new Set([id]));
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
|
|
it("delivers a deleted agent's event to a lifecycle plugin", async () => {
|
|
await db.update(plugins).set({ manifestJson: runtimeManifest(["agents.lifecycle.manage"]) }).where(eq(plugins.id, pluginId));
|
|
const id = await agent("Removed", [pluginId]);
|
|
await db.delete(agents).where(eq(agents.id, id));
|
|
const d = delivery();
|
|
try {
|
|
await drain(d);
|
|
expect(d.call.mock.calls.at(-1)?.[2]).toMatchObject({ resourceId: id, action: "delete" });
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps delivering to a subscriber that does not manage agent lifecycles", async () => {
|
|
await db.update(plugins).set({ manifestJson: runtimeManifest([]) }).where(eq(plugins.id, pluginId));
|
|
const id = await agent("Observed", null);
|
|
const d = delivery();
|
|
try {
|
|
await drain(d);
|
|
expect(delivered(d)).toEqual(new Set([id]));
|
|
} finally {
|
|
await d.worker.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("drains creation before transitions, recovers acknowledgments across dispatcher replacement, and ignores agent events", async () => {
|
|
const id = await event(); await event(id, "update"); await event(id, "delete");
|
|
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", randomUUID()));
|
|
const first = delivery(); await first.run(); await first.worker.dispose();
|
|
expect(first.call.mock.calls.map(c => c[2].action)).toEqual(["create"]);
|
|
const restarted = delivery(); await restarted.run(); await restarted.run(); await restarted.run();
|
|
expect(restarted.call.mock.calls.map(c => c[2].action)).toEqual(["update", "delete"]);
|
|
expect(await restarted.pending()).toBe(false);
|
|
expect((await pluginLifecycleInbox(db, pluginId).list(companyId)).map(e => e.resourceType)).toEqual(["agent"]);
|
|
await restarted.worker.dispose();
|
|
});
|
|
it("replays failures and invalid acknowledgments without blocking independent resources", async () => {
|
|
let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
const failed = await event(); await event(failed, "delete"); await event();
|
|
const d = delivery();
|
|
d.call.mockImplementationOnce(async () => { throw new Error("worker exited after provider effect"); });
|
|
await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(2); expect(await d.pending()).toBe(true);
|
|
d.call.mockImplementationOnce(async () => ({ eventId: "wrong", status: "acknowledged" }));
|
|
now += 30_000; await d.run(); expect(await d.pending()).toBe(true);
|
|
now += 30_000; await d.run(); await d.run();
|
|
expect(d.call.mock.calls.map(c => c[2].action)).toEqual(["create", "create", "create", "create", "delete"]);
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
await d.worker.dispose();
|
|
});
|
|
it("healthy acknowledgments and new events do not bypass a failed event's retry deadline", async () => {
|
|
let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
const failed = await event(), healthy = await event();
|
|
await event(healthy, "update"); await event(healthy, "delete");
|
|
const d = delivery();
|
|
d.call.mockImplementation(async (_p, _m, e) => ({ eventId: e.id, status: e.resourceId === failed ? "retry" : "acknowledged" }));
|
|
await d.run();
|
|
const deadline = now + 30_000;
|
|
expect(d.nextRunAt()).toBe(deadline);
|
|
now += 1000;
|
|
await d.run(); await d.run(); await event(); d.settled(); await d.run();
|
|
expect(d.call.mock.calls.filter(c => c[2].resourceId === healthy).map(c => c[2].action)).toEqual(["create", "update", "delete"]);
|
|
expect(d.call.mock.calls.filter(c => c[2].resourceId === failed)).toHaveLength(1);
|
|
expect(d.nextRunAt()).toBe(deadline);
|
|
now = deadline;
|
|
expect(d.nextRunAt()).toBeNull(); // Paused dispatch waits for the normal retry, not a zero-delay loop.
|
|
await d.run();
|
|
expect(d.call.mock.calls.filter(c => c[2].resourceId === failed)).toHaveLength(2);
|
|
expect(d.nextRunAt()).toBe(now + 30_000);
|
|
await d.worker.dispose();
|
|
});
|
|
it("defers without a retry deadline, retries unfinished work, and wakes on plugin startup", async () => {
|
|
await event(); const d = delivery();
|
|
d.call.mockImplementationOnce(async (_p, _m, e) => ({ eventId: e.id, status: "deferred" }));
|
|
await d.run(); expect(await d.pending()).toBe(false); expect(d.wake).not.toHaveBeenCalled();
|
|
d.lifecycle.emit("plugin.worker_started"); expect(d.wake).toHaveBeenCalledTimes(1);
|
|
d.call.mockImplementationOnce(async (_p, _m, e) => ({ eventId: e.id, status: "retry" }));
|
|
await d.run(); expect(await d.pending()).toBe(true);
|
|
d.running.mockReturnValue(false); await d.run(); expect(await d.pending()).toBe(true);
|
|
expect(d.call).toHaveBeenCalledTimes(2); await d.worker.dispose();
|
|
d.lifecycle.emit("plugin.worker_started"); expect(d.wake).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("requires ready, configured, company-enabled plugins and rechecks disabling within a batch", async () => {
|
|
await event(); await event(); const d = delivery(); const registry = pluginRegistryService(db);
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
|
await d.run(); expect(d.call).not.toHaveBeenCalled();
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
d.call.mockImplementationOnce(async (_p, _m, e) => {
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
|
return { eventId: e.id, status: "acknowledged" };
|
|
});
|
|
await d.run(); expect(d.call).toHaveBeenCalledTimes(1);
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
|
|
await d.run(); expect(d.call).toHaveBeenCalledTimes(1);
|
|
await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId));
|
|
await db.delete(pluginConfig).where(eq(pluginConfig.pluginId, pluginId));
|
|
await d.run(); expect(d.call).toHaveBeenCalledTimes(1); await d.worker.dispose();
|
|
});
|
|
it("notifies only after the outer transaction settles and wakes deferred work for config changes", async () => {
|
|
const wake = vi.fn(); const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle, wake);
|
|
try {
|
|
await db.transaction(async tx => {
|
|
await eventInTransaction(tx as unknown as Db);
|
|
expect(wake).not.toHaveBeenCalled();
|
|
});
|
|
expect(wake).toHaveBeenCalledTimes(1);
|
|
const registry = pluginRegistryService(db);
|
|
await registry.upsertConfig(pluginId, companyId, { companyId, configJson: { enabled: true } });
|
|
await registry.patchConfig(pluginId, companyId, { companyId, configJson: { enabled: false } });
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
expect(wake).toHaveBeenCalledTimes(4);
|
|
} finally { unsubscribe(); }
|
|
async function eventInTransaction(tx: Db) {
|
|
await tx.transaction(inner => recordResourceCreationEvent(inner as unknown as Db, companyId, "project", randomUUID()));
|
|
}
|
|
});
|
|
it("retains startup and settled work while idle drain prevents dispatch", async () => {
|
|
const d = delivery();
|
|
// The coordinator can call hasPending without run when admission is paused.
|
|
expect(await d.pending()).toBe(true);
|
|
await d.run(); expect(await d.pending()).toBe(false);
|
|
await event(); d.settled();
|
|
expect(await d.pending()).toBe(true);
|
|
expect(d.call).not.toHaveBeenCalled();
|
|
await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(1);
|
|
expect(await d.pending()).toBe(true); // An acknowledgment must drain its successor.
|
|
await d.run(); expect(await d.pending()).toBe(false);
|
|
d.lifecycle.emit("plugin.enabled"); expect(await d.pending()).toBe(true);
|
|
await d.run(); expect(await d.pending()).toBe(false);
|
|
d.worker.wake(); expect(await d.pending()).toBe(true);
|
|
await d.worker.dispose();
|
|
});
|
|
it("wakes deferred events after live configuration finishes applying", async () => {
|
|
await event(); const d = delivery();
|
|
let configured = false;
|
|
d.call.mockImplementation(async (_p, _m, e) => ({ eventId: e.id, status: configured ? "acknowledged" : "deferred" }));
|
|
await d.run(); expect(await d.pending()).toBe(false);
|
|
// Commit notification races ahead of the asynchronous worker callback.
|
|
d.settled(); await d.run(); expect(await d.pending()).toBe(false);
|
|
configured = true; d.applied();
|
|
expect(await d.pending()).toBe(true); expect(d.wake).toHaveBeenCalledTimes(1);
|
|
await d.run(); expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
await d.worker.dispose(); d.wake.mockClear(); d.applied(); expect(d.wake).not.toHaveBeenCalled();
|
|
});
|
|
it("retains a failed scan across a retry skipped during idle drain", async () => {
|
|
await event(); const d = delivery();
|
|
await d.worker.ready;
|
|
const select = vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("database unavailable"); });
|
|
await expect(d.run()).rejects.toThrow("database unavailable");
|
|
// The coordinator's drain path checks hasPending without calling run.
|
|
expect(await d.pending()).toBe(true); expect(d.call).not.toHaveBeenCalled();
|
|
select.mockRestore(); await d.run(); await d.run();
|
|
expect(await d.pending()).toBe(false);
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
await d.worker.dispose();
|
|
});
|
|
it("saves config and settings without borrowing another pool connection", async () => {
|
|
const single = createDb(externalUrl ?? database.connectionString, { maxConnections: 1 });
|
|
try {
|
|
const registry = pluginRegistryService(single);
|
|
await registry.upsertConfig(pluginId, companyId, { companyId, configJson: { first: true } });
|
|
await registry.patchConfig(pluginId, companyId, { companyId, configJson: { second: true } });
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
expect((await registry.getConfig(pluginId, companyId))?.configJson).toEqual({ first: true, second: true });
|
|
} finally { await single.$client.end({ timeout: 1 }); }
|
|
}, 5000);
|
|
it("retries listener setup through durable delivery after an enable-time failure", async () => {
|
|
const d = delivery();
|
|
await d.worker.ready;
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const listen = vi.mocked(db.$client.listen);
|
|
listen.mockRejectedValueOnce(new Error("temporary listener outage"));
|
|
d.lifecycle.emit("plugin.enabled");
|
|
await vi.waitFor(() => expect(listen).toHaveBeenCalledTimes(1));
|
|
expect(await d.pending()).toBe(true);
|
|
await d.run();
|
|
expect(listen).toHaveBeenCalledTimes(2);
|
|
expect(await d.pending()).toBe(false);
|
|
await d.worker.dispose();
|
|
});
|
|
it("unregisters the listener on the last disable or unload and can subscribe again", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const otherPluginId = randomUUID();
|
|
await db.insert(plugins).values({ id: otherPluginId, pluginKey: otherPluginId, packageName: "other-runtime", version: "1.0.0", status: "ready", manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never });
|
|
await db.insert(pluginConfig).values({ pluginId: otherPluginId, companyId, configJson: {} });
|
|
const first = { unlisten: vi.fn(), state: {} };
|
|
const second = { unlisten: vi.fn(), state: {} };
|
|
const third = { unlisten: vi.fn(), state: {} };
|
|
const listen = vi.mocked(db.$client.listen).mockResolvedValueOnce(first as never).mockResolvedValueOnce(second as never).mockResolvedValueOnce(third as never);
|
|
const d = delivery();
|
|
await d.worker.ready;
|
|
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
|
|
d.lifecycle.emit("plugin.status_changed");
|
|
await d.run();
|
|
expect(listenerOwners[0]!.close).not.toHaveBeenCalled();
|
|
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, otherPluginId));
|
|
d.lifecycle.emit("plugin.disabled");
|
|
await vi.waitFor(() => expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1));
|
|
await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId));
|
|
d.lifecycle.emit("plugin.enabled");
|
|
await d.run();
|
|
expect(listen).toHaveBeenCalledTimes(2);
|
|
const registry = pluginRegistryService(db);
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
|
d.applied();
|
|
await vi.waitFor(() => expect(listenerOwners[1]!.close).toHaveBeenCalledTimes(1));
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
d.applied(); await d.run();
|
|
expect(listen).toHaveBeenCalledTimes(3);
|
|
await db.delete(plugins).where(eq(plugins.id, pluginId));
|
|
d.lifecycle.emit("plugin.unloaded");
|
|
await vi.waitFor(() => expect(listenerOwners[2]!.close).toHaveBeenCalledTimes(1));
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
expect(listenerOwners[1]!.close).toHaveBeenCalledTimes(1);
|
|
await d.worker.dispose();
|
|
expect(d.lifecycle.listenerCount("plugin.status_changed")).toBe(0);
|
|
expect(d.lifecycle.listenerCount("plugin.disabled")).toBe(0);
|
|
expect(d.lifecycle.listenerCount("plugin.unloaded")).toBe(0);
|
|
});
|
|
it("retires disabled-feature retry history without discarding the original backoff on re-enable", async () => {
|
|
const register = idleWorkModule.registerIdleLocalWorkProbe;
|
|
let inspect: (() => string) | undefined;
|
|
vi.spyOn(idleWorkModule, "registerIdleLocalWorkProbe").mockImplementation(probe => {
|
|
inspect = () => probe({}); return register(probe);
|
|
});
|
|
let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
await event();
|
|
const d = delivery(); await d.worker.ready;
|
|
d.call.mockImplementationOnce(async () => { throw new Error("provider retry fixture"); });
|
|
await d.run();
|
|
const originalDeadline = now + 30_000;
|
|
expect(await d.pending()).toBe(true);
|
|
expect(d.nextRunAt()).toBe(originalDeadline);
|
|
expect(d.call).toHaveBeenCalledTimes(1);
|
|
const registry = pluginRegistryService(db);
|
|
now += 1_000;
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: false, settingsJson: {} });
|
|
d.applied(); await d.run();
|
|
expect(await d.pending()).toBe(false);
|
|
expect(d.nextRunAt()).toBeNull();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
expect(inspect?.()).toBe("none");
|
|
stopTaskDrain();
|
|
now += 1_000;
|
|
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
|
|
d.applied(); await d.run();
|
|
expect(await d.pending()).toBe(true);
|
|
expect(d.nextRunAt()).toBe(originalDeadline);
|
|
expect(d.call).toHaveBeenCalledTimes(1);
|
|
expect(inspect?.()).toBe("present");
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("present");
|
|
stopTaskDrain();
|
|
now = originalDeadline - 1; await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(1);
|
|
expect(d.nextRunAt()).toBe(originalDeadline);
|
|
now = originalDeadline; await d.run(); await d.run();
|
|
expect(d.call).toHaveBeenCalledTimes(2);
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId)).toEqual([]);
|
|
expect(await d.pending()).toBe(false);
|
|
expect(d.nextRunAt()).toBeNull();
|
|
});
|
|
it("requires a current owned idle drain even when the caller supplies a true predicate", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("unknown");
|
|
expect(listenerOwners[0]!.close).not.toHaveBeenCalled();
|
|
});
|
|
it("retains an uncertain listener close and refuses retirement after authority changes", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
const close = listenerOwners[0]!.close;
|
|
close.mockRejectedValueOnce(new Error("listener close uncertain"));
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("unknown");
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => false })).toBe("unknown");
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
close.mockRejectedValueOnce(new Error("listener close still uncertain"));
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("unknown");
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
expect(close).toHaveBeenCalledTimes(3);
|
|
});
|
|
it("recovers a post-close journal inspection failure without creating another listener", async () => {
|
|
const register = idleWorkModule.registerIdleLocalWorkProbe;
|
|
let inspect: (() => string) | undefined;
|
|
vi.spyOn(idleWorkModule, "registerIdleLocalWorkProbe").mockImplementation(probe => {
|
|
inspect = () => probe({});
|
|
return register(probe);
|
|
});
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
listenerOwners[0]!.close.mockImplementationOnce(async () => {
|
|
vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("post-close journal unavailable"); });
|
|
});
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("unknown");
|
|
expect(inspect?.()).toBe("unknown");
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
vi.mocked(db.select).mockRestore();
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
expect(inspect?.()).toBe("none");
|
|
expect(listenerOwners).toHaveLength(1);
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("recovers an absent-feature inspection failure without manufacturing a listener", async () => {
|
|
const register = idleWorkModule.registerIdleLocalWorkProbe;
|
|
let inspect: (() => string) | undefined;
|
|
vi.spyOn(idleWorkModule, "registerIdleLocalWorkProbe").mockImplementation(probe => {
|
|
inspect = () => probe({});
|
|
return register(probe);
|
|
});
|
|
const d = delivery(); await d.worker.ready; await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
const select = vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("journal unavailable"); });
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("unknown");
|
|
expect(inspect?.()).toBe("unknown");
|
|
select.mockRestore();
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
expect(inspect?.()).toBe("none");
|
|
expect(listenerOwners).toHaveLength(0);
|
|
});
|
|
it("retries failed normal disposal without losing its owned listener", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready;
|
|
listenerOwners[0]!.close.mockRejectedValueOnce(new Error("owned disposal failed"));
|
|
await expect(d.worker.dispose()).rejects.toThrow("owned disposal failed");
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
await d.worker.dispose();
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(2);
|
|
});
|
|
it("invalidates a retirement when configuration arrives during its owned close", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
let release!: () => void, entered!: () => void;
|
|
const began = new Promise<void>(resolve => { entered = resolve; });
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
listenerOwners[0]!.close.mockImplementationOnce(async () => { entered(); await gate; });
|
|
const preparation = d.worker.prepareForIdleSleep({ canRetire: () => true });
|
|
try { await began; d.applied(); }
|
|
finally { release(); }
|
|
expect(await preparation).toBe("unknown");
|
|
expect(await d.pending()).toBe(true);
|
|
expect(listenerOwners).toHaveLength(1);
|
|
});
|
|
it("refuses an owned retirement while a real lifecycle callback remains active", async () => {
|
|
await event(); const d = delivery(); await d.worker.ready;
|
|
let release!: () => void, entered!: () => void;
|
|
const began = new Promise<void>(resolve => { entered = resolve; });
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
d.call.mockImplementationOnce(async (_p, _m, value) => { entered(); await gate; return { eventId: value.id, status: "acknowledged" }; });
|
|
const run = d.run();
|
|
try {
|
|
await began;
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("present");
|
|
expect(listenerOwners.every(owner => owner.close.mock.calls.length === 0)).toBe(true);
|
|
} finally { release(); await run; }
|
|
});
|
|
it("keeps journal arrivals during an owned close as actual pending work", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
let release!: () => void, entered!: () => void;
|
|
const began = new Promise<void>(resolve => { entered = resolve; });
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
listenerOwners[0]!.close.mockImplementationOnce(async () => { entered(); await gate; });
|
|
const preparation = d.worker.prepareForIdleSleep({ canRetire: () => true });
|
|
try { await began; await db.insert(agents).values({ companyId, name: "Arrived during retirement", adapterType: "process" }); }
|
|
finally { release(); }
|
|
expect(await preparation).toBe("present");
|
|
expect(await pluginLifecycleInbox(db, pluginId).list(companyId, 1, undefined, ["agent_runtime"])).not.toHaveLength(0);
|
|
});
|
|
it("finishes exact already-started cleanup but rejects an invalidated retirement permit", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const d = delivery(); await d.worker.ready; await d.run(); await d.run();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
let release!: () => void, entered!: () => void, permitted = true;
|
|
const began = new Promise<void>(resolve => { entered = resolve; });
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
listenerOwners[0]!.close.mockImplementationOnce(async () => { entered(); await gate; });
|
|
const preparation = d.worker.prepareForIdleSleep({ canRetire: () => permitted });
|
|
try { await began; permitted = false; }
|
|
finally { release(); }
|
|
expect(await preparation).toBe("unknown");
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("joins an in-progress listener creation on idempotent normal disposal", async () => {
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
let release!: () => void, entered!: () => void;
|
|
const began = new Promise<void>(resolve => { entered = resolve; });
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
vi.mocked(db.$client.listen).mockImplementationOnce(async () => { entered(); await gate; return { state: {}, unlisten: vi.fn() } as never; });
|
|
const d = delivery();
|
|
await began;
|
|
const disposal = d.worker.dispose();
|
|
release();
|
|
await disposal;
|
|
await d.worker.dispose();
|
|
expect(listenerOwners[0]!.close).toHaveBeenCalledTimes(1);
|
|
expect(d.lifecycle.listenerCount("plugin.enabled")).toBe(0);
|
|
});
|
|
it("reopens a physically retired listener at normal TTL expiry and delivers newly found journal debt without a manual wake", async () => {
|
|
vi.mocked(databaseModule.createOwnedDatabaseListener).mockRestore();
|
|
const createListener = databaseModule.createOwnedDatabaseListener;
|
|
let created = 0, arrivalId: string | undefined;
|
|
const foreignCompany = randomUUID();
|
|
await db.insert(companies).values({ id: foreignCompany, name: "Foreign expiry runtime", issuePrefix: `X${foreignCompany.slice(0, 6)}` });
|
|
vi.spyOn(databaseModule, "createOwnedDatabaseListener").mockImplementation(owner => {
|
|
const listener = createListener(owner), ordinal = ++created;
|
|
return { listen: listener.listen, close: async () => {
|
|
await listener.close();
|
|
if (ordinal === 1 && !arrivalId) {
|
|
await db.transaction(async tx => {
|
|
const [arrival] = await tx.insert(agents).values({ companyId, name: "Arrived after physical close", adapterType: "process" }).returning();
|
|
arrivalId = arrival!.id;
|
|
await tx.insert(agents).values({ companyId: foreignCompany, name: "Foreign after physical close", adapterType: "process" });
|
|
});
|
|
}
|
|
} };
|
|
});
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const calls = vi.fn(async (_plugin, _method, value) => ({ eventId: value.id, status: "acknowledged" }));
|
|
const errors: unknown[] = [];
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !isIdleTaskDrainActive(), canReconcile: () => true, onError: error => errors.push(error) });
|
|
const expiry = registerTaskDrainExpiryWork(coordinator, () => {});
|
|
const worker = registerPluginLifecycleDelivery(coordinator, db, { call: calls, isRunning: () => true, onConfigApplied: () => () => {} } as never, new EventEmitter() as never);
|
|
try {
|
|
await expiry.ready; await worker.ready;
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
|
|
const identity = () => db.execute(sql`SELECT pid, backend_start::text AS started, client_port FROM pg_stat_activity WHERE datname = current_database() AND query = 'listen "paperclip_plugin_lifecycle"'`);
|
|
const [before] = await identity(); expect(before).toBeDefined();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 1_000 });
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
|
|
expect(await worker.prepareForIdleSleep({ canRetire: () => true })).toBe("present");
|
|
expect(await db.execute(sql`SELECT 1 FROM pg_stat_activity WHERE datname = current_database() AND pid = ${before!.pid}::integer AND backend_start = ${before!.started}::timestamptz AND client_port = ${before!.client_port}::integer`)).toHaveLength(0);
|
|
expect(created).toBe(1);
|
|
await vi.waitFor(() => expect(calls.mock.calls.some(([, , value]) => value.resourceId === arrivalId && value.companyId === companyId)).toBe(true), { timeout: 5_000 });
|
|
expect(created).toBe(2);
|
|
expect(calls.mock.calls.some(([, , value]) => value.companyId === foreignCompany)).toBe(false);
|
|
const [resumed] = await identity(); expect(resumed).toBeDefined(); expect(resumed!.pid).not.toBe(before!.pid);
|
|
await vi.waitFor(async () => expect(await db.execute(sql`SELECT 1 FROM plugin_lifecycle_acknowledgments ack JOIN resource_lifecycle_events event ON event.id = ack.event_id WHERE ack.plugin_id = ${pluginId}::uuid AND event.resource_id = ${arrivalId}::uuid AND event.company_id = ${companyId}::uuid`)).not.toHaveLength(0), { timeout: 5_000 });
|
|
expect(errors).toEqual([]);
|
|
} finally {
|
|
try { await worker.dispose(); } finally { try { await expiry.stop(); } finally { await coordinator.stop(); } }
|
|
}
|
|
});
|
|
it("physically closes only its owned LISTEN and recreates it for useful same-company work", async () => {
|
|
vi.mocked(databaseModule.createOwnedDatabaseListener).mockRestore();
|
|
vi.mocked(db.$client.listen).mockRestore();
|
|
await db.update(plugins).set({ manifestJson: { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId));
|
|
const foreign = await db.$client.listen("fixture_other_lifecycle", () => {});
|
|
const d = delivery();
|
|
try {
|
|
await d.worker.ready; await d.run(); await d.run();
|
|
const identity = async (channel: string) => db.execute(sql`SELECT pid, backend_start::text AS started, client_port FROM pg_stat_activity WHERE datname = current_database() AND query = ${'listen "' + channel + '"'}`);
|
|
const [owned] = await identity("paperclip_plugin_lifecycle");
|
|
const [other] = await identity("fixture_other_lifecycle");
|
|
expect(owned).toBeDefined(); expect(other).toBeDefined(); expect(owned!.pid).not.toBe(other!.pid);
|
|
startTaskDrain({ purpose: "idle", ttlMs: 30_000 });
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
expect(await db.execute(sql`SELECT 1 FROM pg_stat_activity WHERE datname = current_database() AND pid = ${owned!.pid}::integer AND backend_start = ${owned!.started}::timestamptz AND client_port = ${owned!.client_port}::integer`)).toHaveLength(0);
|
|
expect((await identity("fixture_other_lifecycle"))[0]?.pid).toBe(other!.pid);
|
|
expect(await d.worker.prepareForIdleSleep({ canRetire: () => true })).toBe("none");
|
|
stopTaskDrain();
|
|
await d.run(); await d.run();
|
|
const [resumed] = await identity("paperclip_plugin_lifecycle");
|
|
expect(resumed).toBeDefined(); expect(resumed!.pid).not.toBe(owned!.pid);
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Useful resumed runtime", adapterType: "process" }).returning();
|
|
const foreignCompany = randomUUID();
|
|
await db.insert(companies).values({ id: foreignCompany, name: "Foreign runtime", issuePrefix: `F${foreignCompany.slice(0, 6)}` });
|
|
await db.insert(agents).values({ companyId: foreignCompany, name: "Excluded runtime", adapterType: "process" });
|
|
await d.run(); await d.run();
|
|
expect(d.call.mock.calls.some(([, , value]) => value.resourceId === agent!.id && value.companyId === companyId)).toBe(true);
|
|
expect(d.call.mock.calls.some(([, , value]) => value.companyId === foreignCompany)).toBe(false);
|
|
const acknowledged = await db.execute(sql`SELECT 1 FROM plugin_lifecycle_acknowledgments ack JOIN resource_lifecycle_events event ON event.id = ack.event_id WHERE ack.plugin_id = ${pluginId}::uuid AND event.company_id = ${companyId}::uuid AND event.resource_id = ${agent!.id}::uuid`);
|
|
expect(acknowledged.length).toBeGreaterThan(0);
|
|
} finally { await d.worker.dispose(); await foreign.unlisten(); }
|
|
});
|
|
it("records failed delivery scope and stage without provider secrets", async () => {
|
|
await event(); const d = delivery();
|
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
try {
|
|
d.call.mockImplementationOnce(async () => { throw Object.assign(new Error("provider-secret-fixture"), { code: -32001 }); });
|
|
await d.run();
|
|
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ pluginId, companyId, eventId: expect.any(String), stage: "callback", rpcCode: -32001 }), expect.any(String));
|
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("provider-secret-fixture");
|
|
expect(await d.pending()).toBe(true);
|
|
} finally { warn.mockRestore(); await d.worker.dispose(); }
|
|
});
|
|
it("rollbacks never expose queue rows even when settlement wakes the dispatcher", async () => {
|
|
const d = delivery();
|
|
await expect(db.transaction(async tx => {
|
|
await recordResourceCreationEvent(tx as unknown as Db, companyId, "project", randomUUID());
|
|
throw new Error("rollback");
|
|
})).rejects.toThrow("rollback");
|
|
await d.run(); expect(d.call).not.toHaveBeenCalled(); expect(await d.pending()).toBe(false);
|
|
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId))).toEqual([]);
|
|
await d.worker.dispose();
|
|
});
|
|
});
|