Deliver durable resource lifecycle events to plugins without polling (#16203)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - Plugins keep external resources aligned with Paperclip projects and
agents.
> - The durable resource journal stores changes, but its plugin
interface requires polling.
> - Project deletion has no journal entry, so consumers must scan for
missing projects.
> - This PR delivers committed journal events through a native plugin
callback and records deletion.
> - Plugins can recover unfinished work without periodic change scans.

## Linked Issues or Issue Description

Related: #15343 introduced the durable inbox. #15631 added required
agent lifecycle participation. This change keeps both contracts and adds
automatic delivery for the resource inbox.

**What happened?**

Plugins must schedule reads of the durable inbox. A deleted project has
no durable deletion event. Consumers must scan saved bindings to detect
its removal.

**Expected behavior**

Core should deliver committed resource events and replay unfinished
work. Empty queues should have no polling timer. Project removal should
leave a durable event.

**Steps to reproduce**

1. Install a plugin that consumes project lifecycle events.
2. Create or update a project. The plugin must call `listLifecycle` to
discover it.
3. Delete the project. The journal has no deletion entry.

## What Changed

- Add `resourceLifecycle` to the manifest and `onResourceLifecycle` to
the SDK. Require `events.subscribe` and a configured, enabled company
scope.
- Reuse transaction-aware work signals and the delivery coordinator.
Wake after commit, on startup and plugin activation, and after
configuration changes.
- Acknowledge only a matching successful result. Retry unfinished or
interrupted work. Retain deferred events without an idle retry timer.
- Wake again after successful worker configuration application, and
retain failed scans through idle drain (both Apex findings fixed).
- Keep ordering per resource and deliver past failed or deferred
resources. Keep the manual inbox methods for existing consumers.
- Record project deletion in the removal transaction. Extend the journal
constraint with a generated migration.

## Verification

- Apex follow-up: 129 focused worker, delivery, coordinator, and
application tests passed; server typecheck passed. Regression coverage
verifies configuration-application races and failed scans across idle
drain.

- Focused lifecycle, transaction coordinator, manifest, and SDK dispatch
suites: 105 tests passed, including single-connection saves, redacted
failure diagnostics, wakeups retained during idle drain, and per-event
retry deadlines.
- Complete SDK suite plus delivery database tests: 128 tests passed.
- Shared, database, and SDK typechecks passed. Direct server `tsc
--noEmit` passed against rebuilt SDK declarations.
- Full local `pnpm test:run` finished with 19,491 passed, 148 skipped,
and 6 failed tests across 5 files (plus two suite setup errors). This
run overlapped branch updates and is not final-head evidence: failures
included missing Cargo, local Git default-branch assumptions, and stale
in-process lifecycle modules. Final Apex-fix commit `75d028788` passed
all CI gates, including the full test shards, and received Apex 5/5 with
no unresolved comments.
- `pnpm -r typecheck` and `pnpm build` were attempted. Both stopped
because Cargo is unavailable locally. CI supplies the Rust toolchain.
- Local installation used the repository's documented lockfile fallback
because master has dependencies missing from its lockfile. No dependency
changes are included here.

## Risks

- Delivery is at least once. Plugins must re-read current state and make
external effects idempotent.
- Signals are process-local, like the other delivery queues. Startup
recovers committed rows. Independent database writers or multiple active
server processes require an explicit ownership and wake protocol.
- Apply migration `0340_open_warlock.sql` before running code that
records project deletion. Earlier deletions cannot be reconstructed.
- Automatic delivery selects plugins with company configuration rows.
`deferred` waits for a later startup, configuration change, or other
work notification.
- This does not replace required agent lifecycle participation or
establish that external mounts are unused.

## Model Used

OpenAI Codex (GPT-6), with tool-assisted code editing, code inspection,
and test execution.

## 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
- [ ] 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>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-13 12:00:30 -07:00
1 parent 5a6d0d08a7
commit db65a0863e
27 files changed
+1252 -145

No files matched your search

@@ -20,7 +20,7 @@ rl.on("line", (line) => {
id: message.id,
result: {
ok: true,
supportedMethods: ["environmentExecute"],
supportedMethods: ["environmentExecute", "configChanged"],
},
});
return;
@@ -44,6 +44,16 @@ rl.on("line", (line) => {
return;
}
if (method === "configChanged") {
setTimeout(() => send({
jsonrpc: "2.0", id: message.id,
...(message.params.config.fail
? { error: { code: -32000, message: "configuration rejected" } }
: { result: {} }),
}), Number(message.params.config.delayMs ?? 0));
return;
}
if (method === "shutdown") {
send({
jsonrpc: "2.0",
@@ -0,0 +1,217 @@
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { eq } from "drizzle-orm";
import { companies, createDb, 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 type { 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 support = 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;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-plugin-delivery-"); db = createDb(database.connectionString); }, 90_000);
afterAll(async () => { await database?.cleanup(); });
afterEach(() => { vi.restoreAllMocks(); });
beforeEach(async () => {
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 = 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() }; }) };
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);
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?.() };
}
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(); 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"]);
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([]);
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);
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); 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); 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, { configJson: { enabled: true } });
await registry.patchConfig(pluginId, 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);
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([]);
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();
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([]);
d.worker.dispose();
});
it("saves config and settings without borrowing another pool connection", async () => {
const single = createDb(database.connectionString, { maxConnections: 1 });
const registry = pluginRegistryService(single);
await registry.upsertConfig(pluginId, companyId, { configJson: { first: true } });
await registry.patchConfig(pluginId, companyId, { configJson: { second: true } });
await registry.upsertCompanySettings(pluginId, companyId, { enabled: true, settingsJson: {} });
expect((await registry.getConfig(pluginId, companyId))?.configJson).toEqual({ first: true, second: true });
}, 5000);
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(); 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([]);
d.worker.dispose();
});
});
@@ -31,6 +31,7 @@ import {
appendStderrExcerpt,
createDuplexRouteSlotController,
createPluginWorkerHandle,
createPluginWorkerManager,
formatWorkerFailureMessage,
resolveRpcCallTimeoutMs,
} from "../services/plugin-worker-manager.js";
@@ -56,6 +57,28 @@ const TEST_MANIFEST: PaperclipPluginManifestV1 = {
entrypoints: { worker: "dist/worker.js" },
};
describe("worker configuration completion", () => {
it("notifies only after a successful config callback and releases subscriptions", async () => {
const manager = createPluginWorkerManager();
const applied = vi.fn(), unsubscribe = manager.onConfigApplied(applied);
try {
await manager.startWorker("test.plugin", {
entrypointPath: DELAYED_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1, hostHandlers: {},
});
expect(applied).not.toHaveBeenCalled();
const updating = manager.call("test.plugin", "configChanged", { companyId: "company-1", config: { delayMs: 20 } });
expect(applied).not.toHaveBeenCalled();
await updating; expect(applied).toHaveBeenCalledTimes(1);
await expect(manager.call("test.plugin", "configChanged", { companyId: "company-1", config: { fail: true } })).rejects.toThrow("configuration rejected");
expect(applied).toHaveBeenCalledTimes(1);
unsubscribe();
await manager.call("test.plugin", "configChanged", { companyId: "company-1", config: {} });
expect(applied).toHaveBeenCalledTimes(1);
} finally { unsubscribe(); await manager.stopAll(); }
});
});
describe("resolveRpcCallTimeoutMs", () => {
const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000;
const MAX_NODE_TIMER_TIMEOUT_MS = 2_147_483_647;
@@ -53,7 +53,7 @@ describePostgres("Resource lifecycle events", () => {
it("records direct and approval-free hires once, including concurrent duplicate submissions", async () => {
const agent = await createAgent();
await Promise.all(Array.from({ length: 5 }, () => recordResourceCreationEvent(db, companyId, "agent", agent.id)));
await Promise.all(Array.from({ length: 5 }, () => db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", agent.id))));
expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id, action: "create" })]);
});
@@ -235,11 +235,11 @@ describePostgres("Resource lifecycle events", () => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-token");
const id = randomUUID();
await recordResourceCreationEvent(db, companyId, "agent", id);
await recordResourceCreationEvent(db, companyId, "project", id);
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", id));
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, companyId, "project", id));
const otherCompanyId = randomUUID();
await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" });
await recordResourceCreationEvent(db, otherCompanyId, "agent", id);
await db.transaction(tx => recordResourceCreationEvent(tx as unknown as Db, otherCompanyId, "agent", id));
expect(await events()).toHaveLength(2);
expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toHaveLength(1);
await db.delete(companies).where(eq(companies.id, otherCompanyId));
@@ -267,6 +267,23 @@ describePostgres("Resource lifecycle events", () => {
expect(beforeArchive.sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "update", "update", "update", "update", "update"]);
});
it("records deletion atomically and retains the event after removing the project", async () => {
const service = projectService(db);
const project = await service.create(companyId, { name: "Delete fixture" });
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_delete CHECK (action <> 'delete') NOT VALID`);
try {
await expect(service.remove(project.id)).rejects.toThrow();
expect(await service.getById(project.id)).not.toBeNull();
expect((await events()).map(row => row.action)).toEqual(["create"]);
} finally {
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_delete`);
}
await service.remove(project.id);
await service.remove(project.id);
expect(await service.getById(project.id)).toBeNull();
expect((await events()).map(row => row.action)).toEqual(["create", "delete"]);
});
it("records archive once per transition and update on restore, retaining repositories", async () => {
const service = projectService(db);
const project = await service.createWithRepositories(companyId, { name: "Archive fixture" }, [
+4 -1
View File
@@ -191,6 +191,7 @@ import {
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
import { heartbeatService } from "./services/heartbeat.js";
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
import { registerPluginLifecycleDelivery } from "./services/plugin-lifecycle-delivery.js";
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
import {
buildHostServices,
@@ -1251,6 +1252,7 @@ export async function createApp(
onError: (err, queue) => logger.error({ err, queue }, "Delivery reconciliation failed"),
});
app.locals.deliveryWork = deliveryWork;
const resourceLifecycleDelivery = registerPluginLifecycleDelivery(deliveryWork, db, workerManager, lifecycle);
if (opts.feedbackExportService) {
deliveryWork.register(DELIVERY_QUEUES.feedback, {
retryMs: FEEDBACK_EXPORT_FLUSH_INTERVAL_MS,
@@ -1409,7 +1411,7 @@ export async function createApp(
logger.error({ err }, "Failed to load ready plugins on startup");
});
app.locals.bundledPluginsStartup = trackIdleWork(bundledPluginsStartup);
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; return agentLifecycle.sweep(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; resourceLifecycleDelivery.wake(); return agentLifecycle.sweep(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
// The shutdown hook runs at most once. It caches the in-flight promise, so a
// second caller (for example the `exit` handler) awaits the same completion
// instead of starting a second teardown.
@@ -1424,6 +1426,7 @@ export async function createApp(
await publicMcpEvents?.stop();
await dotMcpEvents?.stop();
jobCoordinator.stop();
resourceLifecycleDelivery.dispose();
await deliveryWork.stop();
unsubscribeChatPublicationSignals();
chatReconciliation.stop();
@@ -23,6 +23,7 @@ export const DELIVERY_QUEUES = {
chatCompletion: "chat-completions",
connection: "connection-continuations",
question: "question-responses",
pluginLifecycle: "plugin-resource-lifecycle",
toolAction: "tool-action-receipts",
} as const;
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
@@ -0,0 +1,122 @@
import { and, asc, eq, isNull, or } from "drizzle-orm";
import { pluginCompanySettings, pluginConfig, plugins, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
type Coordinator = ReturnType<typeof createDeliveryWorkCoordinator>;
/** The journal is the queue. Notifications only wake its single local dispatcher. */
export function registerPluginLifecycleDelivery(
coordinator: Coordinator,
db: Db,
manager: Pick<PluginWorkerManager, "call" | "isRunning" | "onConfigApplied">,
lifecycle: Pick<PluginLifecycleManager, "on" | "off">,
) {
// Startup and every wake remain pending until an admitted run inspects the inbox.
// The coordinator may reconcile signals while idle drain prevents dispatch.
let pending = true;
const retryAt = new Map<string, number>();
const retryMs = 30_000;
const delivery = coordinator.register(DELIVERY_QUEUES.pluginLifecycle, {
retryMs,
hasPending: async () => pending,
onSettled: () => { pending = true; },
nextRunAt() {
if (!pending) return null;
let next: number | null = null;
for (const at of retryAt.values()) {
// An expired deadline during idle drain must not schedule a busy loop.
if (at > Date.now() && (next === null || at < next)) next = at;
}
return next;
},
async run(signal) {
pending = false;
try {
for (const [key, at] of retryAt) if (at <= Date.now()) retryAt.delete(key);
const configured = await db.select({ plugin: plugins, companyId: pluginConfig.companyId })
.from(plugins).innerJoin(pluginConfig, eq(pluginConfig.pluginId, plugins.id))
.where(eq(plugins.status, "ready"))
.orderBy(asc(plugins.installOrder), asc(pluginConfig.companyId));
for (const { plugin, companyId } of configured) {
const types = plugin.manifestJson.resourceLifecycle;
if (!types?.length || !plugin.manifestJson.capabilities.includes("events.subscribe")) continue;
const [disabled] = await db.select({ id: pluginCompanySettings.id }).from(pluginCompanySettings)
.where(and(eq(pluginCompanySettings.pluginId, plugin.id), eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.enabled, false)));
if (disabled) continue;
const inbox = pluginLifecycleInbox(db, plugin.id);
let afterId: string | undefined;
// Visit independent resources past a failed or deferred head.
for (;;) {
if (signal.aborted) return;
const events = await inbox.list(companyId, 100, afterId, types);
for (const event of events) {
if (signal.aborted) return;
const retryKey = `${plugin.id}:${event.id}`;
if (retryAt.has(retryKey)) { pending = true; continue; }
if (!manager.isRunning(plugin.id)) { pending = true; break; }
let stage = "eligibility";
try {
// Recheck enablement for each invocation; a disable during a batch must stop delivery.
const [enabled] = await db.select({ manifest: plugins.manifestJson }).from(plugins)
.leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id), eq(pluginCompanySettings.companyId, companyId)))
.where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"),
or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break;
stage = "callback";
const result = await manager.call(plugin.id, "resourceLifecycle", event, 30_000);
stage = "result";
if (result?.eventId !== event.id || !["acknowledged", "retry", "deferred"].includes(result.status)) {
throw new Error("Invalid resource lifecycle result");
}
if (signal.aborted) return;
if (result.status === "acknowledged") {
stage = "acknowledgment";
await inbox.acknowledge(companyId, event.id);
// An acknowledgment exposes the next event for this resource. Drain immediately.
wake();
} else if (result.status === "retry") {
retryAt.set(retryKey, Date.now() + retryMs);
pending = true;
}
} catch (error) {
retryAt.set(retryKey, Date.now() + retryMs);
// Provider error messages/data may contain secrets; retain only routing
// identifiers, the failed stage, and the protocol's numeric error code.
const code = (error as { code?: unknown } | null)?.code;
logger.warn({ pluginId: plugin.id, companyId, eventId: event.id, stage,
...(typeof code === "number" ? { rpcCode: code } : {}) }, "Resource lifecycle delivery failed; retry scheduled");
// Failed RPCs and uncertain acknowledgments leave durable work for replay.
pending = true;
}
}
if (events.length < 100) break;
afterId = events.at(-1)!.id;
}
}
} catch (error) {
pending = true; // A failed scan must survive a retry skipped during idle drain.
throw error;
}
},
});
function wake() { pending = true; delivery.wake(); }
const unsubscribeConfig = manager.onConfigApplied(wake);
lifecycle.on("plugin.loaded", wake);
lifecycle.on("plugin.enabled", wake);
lifecycle.on("plugin.worker_started", wake);
return {
...delivery,
wake,
dispose() {
unsubscribeConfig();
lifecycle.off("plugin.loaded", wake);
lifecycle.off("plugin.enabled", wake);
lifecycle.off("plugin.worker_started", wake);
},
};
}
@@ -1,10 +1,10 @@
import { pluginCompanySettings, pluginLifecycleAcknowledgments, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db";
import type { ResourceLifecycleEvent } from "@paperclipai/plugin-sdk";
import { z } from "zod";
import { and, asc, eq, gt, sql } from "drizzle-orm";
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm";
import { badRequest, conflict, forbidden, notFound } from "../errors.js";
/** Durable pull delivery. Provider work stays outside tenant transactions. */
/** Durable delivery inbox. Provider work stays outside tenant transactions. */
export function pluginLifecycleInbox(db: Db, pluginId: string) {
const unacknowledged = sql`NOT EXISTS (
SELECT 1 FROM plugin_lifecycle_acknowledgments ack
@@ -31,12 +31,12 @@ export function pluginLifecycleInbox(db: Db, pluginId: string) {
}
return {
async list(companyId: string, limit = 50, afterId?: string): Promise<ResourceLifecycleEvent[]> {
async list(companyId: string, limit = 50, afterId?: string, resourceTypes?: ResourceLifecycleEvent["resourceType"][]): Promise<ResourceLifecycleEvent[]> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("limit must be an integer from 1 to 100");
if (afterId !== undefined && (typeof afterId !== "string" || !/^[1-9]\d*$/.test(afterId) || !Number.isSafeInteger(Number(afterId)))) throw badRequest("Invalid lifecycle page id");
await assertAvailable(db, companyId);
const rows = await db.select().from(resourceLifecycleEvents)
.where(and(eq(resourceLifecycleEvents.companyId, companyId), unacknowledged, firstForResource, afterId === undefined ? undefined : gt(resourceLifecycleEvents.id, Number(afterId))))
.where(and(eq(resourceLifecycleEvents.companyId, companyId), unacknowledged, firstForResource, resourceTypes ? inArray(resourceLifecycleEvents.resourceType, resourceTypes) : undefined, afterId === undefined ? undefined : gt(resourceLifecycleEvents.id, Number(afterId))))
.orderBy(asc(resourceLifecycleEvents.id)).limit(limit);
return rows.map(row => ({ ...row, id: String(row.id), createdAt: row.createdAt.toISOString() })) as ResourceLifecycleEvent[];
},
+99 -86
View File
@@ -28,6 +28,7 @@ import type {
PluginWebhookDeliveryStatus,
} from "@paperclipai/shared";
import { conflict, notFound } from "../errors.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -65,8 +66,8 @@ export function pluginRegistryService(db: Db) {
// Internal helpers
// -----------------------------------------------------------------------
async function getById(id: string) {
return db
async function getById(id: string, database: Db = db) {
return database
.select()
.from(plugins)
.where(eq(plugins.id, id))
@@ -323,37 +324,41 @@ export function pluginRegistryService(db: Db) {
* otherwise a new row is inserted.
*/
upsertConfig: async (pluginId: string, companyId: string, input: UpsertPluginConfig) => {
const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found");
return db.transaction(async tx => {
const db = tx as unknown as Db;
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
const plugin = await getById(pluginId, db);
if (!plugin) throw notFound("Plugin not found");
const existing = await db
.select()
.from(pluginConfig)
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (existing) {
return db
.update(pluginConfig)
.set({
configJson: input.configJson,
lastError: null,
updatedAt: new Date(),
})
const existing = await db
.select()
.from(pluginConfig)
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (existing) {
return db
.update(pluginConfig)
.set({
configJson: input.configJson,
lastError: null,
updatedAt: new Date(),
})
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginConfig)
.values({
pluginId,
companyId,
configJson: input.configJson,
})
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginConfig)
.values({
pluginId,
companyId,
configJson: input.configJson,
})
.returning()
.then((rows) => rows[0]);
});
},
/**
@@ -361,38 +366,42 @@ export function pluginRegistryService(db: Db) {
* If no config row exists yet one is created with the supplied values.
*/
patchConfig: async (pluginId: string, companyId: string, input: PatchPluginConfig) => {
const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found");
return db.transaction(async tx => {
const db = tx as unknown as Db;
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
const plugin = await getById(pluginId, db);
if (!plugin) throw notFound("Plugin not found");
const existing = await db
.select()
.from(pluginConfig)
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (existing) {
const merged = { ...existing.configJson, ...input.configJson };
return db
.update(pluginConfig)
.set({
configJson: merged,
lastError: null,
updatedAt: new Date(),
})
const existing = await db
.select()
.from(pluginConfig)
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (existing) {
const merged = { ...existing.configJson, ...input.configJson };
return db
.update(pluginConfig)
.set({
configJson: merged,
lastError: null,
updatedAt: new Date(),
})
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginConfig)
.values({
pluginId,
companyId,
configJson: input.configJson,
})
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginConfig)
.values({
pluginId,
companyId,
configJson: input.configJson,
})
.returning()
.then((rows) => rows[0]);
});
},
/**
@@ -439,43 +448,47 @@ export function pluginRegistryService(db: Db) {
companyId: string,
input: { enabled?: boolean; settingsJson: Record<string, unknown>; lastError?: string | null },
): Promise<PluginCompanySettings> => {
const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found");
return db.transaction(async tx => {
const db = tx as unknown as Db;
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
const plugin = await getById(pluginId, db);
if (!plugin) throw notFound("Plugin not found");
const existing = await db
.select()
.from(pluginCompanySettings)
.where(and(
eq(pluginCompanySettings.pluginId, pluginId),
eq(pluginCompanySettings.companyId, companyId),
))
.then((rows) => rows[0] ?? null);
const existing = await db
.select()
.from(pluginCompanySettings)
.where(and(
eq(pluginCompanySettings.pluginId, pluginId),
eq(pluginCompanySettings.companyId, companyId),
))
.then((rows) => rows[0] ?? null);
if (existing) {
return db
.update(pluginCompanySettings)
.set({
enabled: input.enabled ?? existing.enabled,
settingsJson: input.settingsJson,
lastError: input.lastError ?? null,
updatedAt: new Date(),
})
.where(eq(pluginCompanySettings.id, existing.id))
.returning()
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
}
if (existing) {
return db
.update(pluginCompanySettings)
.set({
enabled: input.enabled ?? existing.enabled,
.insert(pluginCompanySettings)
.values({
pluginId,
companyId,
enabled: input.enabled ?? true,
settingsJson: input.settingsJson,
lastError: input.lastError ?? null,
updatedAt: new Date(),
})
.where(eq(pluginCompanySettings.id, existing.id))
.returning()
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
}
return db
.insert(pluginCompanySettings)
.values({
pluginId,
companyId,
enabled: input.enabled ?? true,
settingsJson: input.settingsJson,
lastError: input.lastError ?? null,
})
.returning()
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
});
},
// ----- Entities -------------------------------------------------------
+15 -1
View File
@@ -796,6 +796,8 @@ export interface WorkerDiagnostics {
* for starting/stopping all workers and routing RPC calls.
*/
export interface PluginWorkerManager {
/** Subscribe after a worker successfully applies live company configuration. */
onConfigApplied(listener: () => void): () => void;
inspectIdleSleep?(hold: { ownerId: string; expiresAt: number }): Promise<{
backgroundWork: "none" | "present" | "unknown"; pluginIds: string[];
}>;
@@ -3726,6 +3728,7 @@ export function createPluginWorkerManager(
): PluginWorkerManager {
const log = logger.child({ service: "plugin-worker-manager" });
const workers = new Map<string, PluginWorkerHandle>();
const configApplied = new Set<() => void>();
/** Per-plugin startup locks to prevent concurrent spawn races. */
const startupLocks = new Map<string, Promise<PluginWorkerHandle>>();
// The one shared, process-scoped aggregate route-slot controller. The manager
@@ -3736,6 +3739,10 @@ export function createPluginWorkerManager(
);
return {
onConfigApplied(listener) {
configApplied.add(listener);
return () => { configApplied.delete(listener); };
},
async inspectIdleSleep(hold) {
if (startupLocks.size) return { backgroundWork: "unknown", pluginIds: [] };
const snapshot = [...workers.entries()];
@@ -3872,7 +3879,14 @@ export function createPluginWorkerManager(
new Error(`No worker registered for plugin "${pluginId}"`),
);
}
return handle.call(method, params, timeoutMs, executeLogSink);
const result = handle.call(method, params, timeoutMs, executeLogSink);
if (method === "configChanged") void result.then(() => {
for (const listener of configApplied) {
try { listener(); }
catch { log.warn({ pluginId }, "Plugin configuration listener failed"); }
}
}, () => {});
return result;
},
openLoginPtySession(pluginId: string, input: LoginPtyOpenInput) {
+1
View File
@@ -1073,6 +1073,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
const workspaces = await tx.select().from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
for (const workspace of workspaces) await preserveConfiguredProjectWorkspaceRootProvenance(tx, workspace);
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
}),
@@ -1,5 +1,6 @@
import { resourceLifecycleEvents, type Db } from "@paperclipai/db";
import { sql } from "drizzle-orm";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
/** Call inside the transaction that creates the resource or approves the hire. */
export async function recordResourceCreationEvent(
@@ -8,6 +9,7 @@ export async function recordResourceCreationEvent(
resourceType: "agent" | "project",
resourceId: string,
): Promise<void> {
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType, resourceId, action: "create" }).onConflictDoNothing({
target: [resourceLifecycleEvents.companyId, resourceLifecycleEvents.resourceType, resourceLifecycleEvents.resourceId],
where: sql`${resourceLifecycleEvents.action} = 'create'`,
@@ -21,10 +23,12 @@ export async function recordAgentStatusEvent(db: Db, companyId: string, agentId:
: after === "paused" ? "pause"
: before === "paused" && ["active", "idle", "running", "error"].includes(after) ? "resume" : null;
if (!action) return;
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action });
}
/** Call while holding the project row lock, in the mutation transaction. */
export async function recordProjectLifecycleEvent(db: Db, companyId: string, projectId: string, action: "update" | "archive" = "update"): Promise<void> {
export async function recordProjectLifecycleEvent(db: Db, companyId: string, projectId: string, action: "update" | "archive" | "delete" = "update"): Promise<void> {
await notifyDeliveryWork(db, DELIVERY_QUEUES.pluginLifecycle);
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: projectId, action });
}