Expose a durable resource lifecycle inbox to plugins

Let plugins poll company-scoped resource lifecycle records and acknowledge
successful work. Keep progress per plugin and preserve resource ordering.
Add pagination so failed resources do not block later work.

Verified workspace typechecks, production build, and 164 targeted tests.
The full local test run is in progress.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-06 10:24:54 -07:00
1 parent 0fe47882cf
commit 8f6ce2bf0f
18 files changed
+396 -8

No files matched your search

+20 -5
View File
@@ -511,7 +511,7 @@ Termination commits API-key revocation in that same transaction.
Hire approval and rejection commit with agent activation or termination, so a
failed event write leaves the decision pending and retryable.
The numeric event ID orders transitions for a resource. Future plugin delivery
The numeric event ID orders transitions for a resource. Plugin delivery
must enforce company scope, preserve resource order, and track acknowledgments
per plugin. A global high-water mark can skip transactions that have not yet
committed; it is not a safe delivery cursor. The journal stores only identity,
@@ -519,11 +519,26 @@ action, and timestamps, not repository snapshots, credentials, provider config,
or resource health. Company deletion cascades to its events. Resource deletion
retains events, so consumers must revalidate existence and eligibility and load
current authorized repository data. A termination hook does not authorize
removing persistent VM or project data.
provider cleanup without the plugin's own authorization and retention policy.
The migration creates an empty table. It does not scan or backfill existing
installs. Plugin delivery, retention, retries, and provider integration are
separate work. This change makes no provider calls and adds no plugin read API.
The migrations create empty journal and acknowledgment tables. They do not scan or backfill existing
installs. Plugins use `ctx.events.listLifecycle(companyId, limit?, afterId?)` and
`ctx.events.acknowledgeLifecycle(companyId, eventId)` with `events.subscribe`.
The host requires a matching company invocation (or configured-company proactive
access) and a ready plugin enabled for that company.
`plugin_lifecycle_acknowledgments` stores progress independently
for each plugin and event; plugin and event deletion cascade acknowledgments.
Reads return only the earliest unacknowledged event for each resource, up to 100
resources. Acknowledging a later event is rejected. Reads never consume work, so
crashes, retries, and restarts cannot lose a hook; concurrent reads can repeat an
event. There is no global cursor or backfill scan. Consumers must serialize their
processing and make provider operations idempotent before acknowledging success.
Retention and provider integration remain separate work.
Lifecycle polls can page past failed resources using the last returned event id as
`afterId`. Reset `afterId` at the start of every polling sweep: it is a page
cursor, never a persisted high-water mark. This retries failures and includes
transactions that commit later with lower ids.
## Legacy controller ownership
+36
View File
@@ -25,6 +25,42 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
building custom versions.
- `ctx.assets` is not supported in the current runtime.
## Durable resource lifecycle inbox
Plugins with `events.subscribe` can read durable, company-scoped resource hooks
through `ctx.events.listLifecycle(companyId, limit?, afterId?)` and acknowledge successful
work with `ctx.events.acknowledgeLifecycle(companyId, eventId)`. Use an existing
plugin job to poll each configured company; the host checks invocation scope and whether
the plugin is ready and enabled for that company. These methods are separate
from the fire-and-forget `ctx.events.on()` bus.
Proactive jobs and timers may read only companies authorized by the plugin's
company configuration. Calls inside a host-issued invocation must match its company.
An event has `id`, `companyId`, `resourceType`, `resourceId`, `action`, and
`createdAt`. Agent actions are `create`, `pause`, `resume`, and `terminate`;
project actions are `create` and `update`. Pending hires produce creation after
approval. Project updates include repository/workspace mutations, but archive-only
changes produce no hook. Provider cleanup and retention policy belong to the plugin.
Reads return at most one pending event per resource (default 50, maximum 100).
After acknowledging that event, a later read exposes its successor. A failed
resource remains pending without blocking other resources; process the rest of
the batch independently. Progress is stored per plugin, survives worker restarts,
and is not a global sequence cursor. New consumers can see previously recorded
events, but no events are synthesized for resources that predate capture.
Delivery is at least once: concurrent reads or a crash after a provider operation
can repeat an event. Serialize polling and use stable company/event idempotency
keys, then acknowledge only after successful completion. Load current authorized
agent/project/workspace data before acting; the journal contains no configuration
snapshots, repository credentials, or deletion authority. For offline plugin tests,
seed `lifecycleEvents` with `createTestHarness().seed()`.
Lifecycle polls can page past failed resources using the last returned event id as
`afterId`. Reset `afterId` at the start of every polling sweep: it is a page
cursor, never a persisted high-water mark. This retries failures and includes
transactions that commit later with lower ids.
## External object reference providers
Plugins can contribute provider-neutral object reference detection and status
@@ -0,0 +1,11 @@
CREATE TABLE "plugin_lifecycle_acknowledgments" (
"plugin_id" uuid NOT NULL,
"event_id" bigint NOT NULL,
"acknowledged_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plugin_lifecycle_acknowledgments_plugin_id_event_id_pk" PRIMARY KEY("plugin_id","event_id")
);
--> statement-breakpoint
ALTER TABLE "plugin_lifecycle_acknowledgments" ADD CONSTRAINT "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plugin_lifecycle_acknowledgments" ADD CONSTRAINT "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."resource_lifecycle_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "plugin_lifecycle_acknowledgments_event_idx" ON "plugin_lifecycle_acknowledgments" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "resource_lifecycle_events_resource_order_idx" ON "resource_lifecycle_events" USING btree ("company_id","resource_type","resource_id","id");
+1
View File
@@ -201,6 +201,7 @@ export { pluginLogs } from "./plugin_logs.js";
export { runIdentityContexts } from "./run_identity_contexts.js";
export { connectionIntentDeliveries } from "./connection_intent_deliveries.js";
export { resourceLifecycleEvents } from "./resource_lifecycle_events.js";
export { pluginLifecycleAcknowledgments } from "./plugin_lifecycle_acknowledgments.js";
export { toolActionDeliveries } from "./tool_action_deliveries.js";
export { chatTeamsFileTransfers } from "./chat_teams_file_transfers.js";
@@ -0,0 +1,12 @@
import { bigint, index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import { resourceLifecycleEvents } from "./resource_lifecycle_events.js";
export const pluginLifecycleAcknowledgments = pgTable("plugin_lifecycle_acknowledgments", {
pluginId: uuid("plugin_id").notNull().references(() => plugins.id, { onDelete: "cascade" }),
eventId: bigint("event_id", { mode: "number" }).notNull().references(() => resourceLifecycleEvents.id, { onDelete: "cascade" }),
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
pk: primaryKey({ columns: [table.pluginId, table.eventId] }),
eventIdx: index("plugin_lifecycle_acknowledgments_event_idx").on(table.eventId),
}));
@@ -13,6 +13,7 @@ export const resourceLifecycleEvents = pgTable("resource_lifecycle_events", {
}, (table) => ({
resourceIdx: uniqueIndex("resource_lifecycle_events_creation_idx").on(table.companyId, table.resourceType, table.resourceId).where(sql`${table.action} = 'create'`),
companyIdx: index("resource_lifecycle_events_company_idx").on(table.companyId, table.id),
resourceOrderIdx: index("resource_lifecycle_events_resource_order_idx").on(table.companyId, table.resourceType, table.resourceId, table.id),
resourceTypeCheck: check("resource_lifecycle_events_resource_type_check", sql`${table.resourceType} IN ('agent', 'project')`),
actionCheck: check("resource_lifecycle_events_action_check", sql`${table.action} = 'create' OR (${table.resourceType} = 'project' AND ${table.action} = 'update') OR (${table.resourceType} = 'agent' AND ${table.action} IN ('pause', 'resume', 'terminate'))`),
}));
@@ -139,6 +139,8 @@ export interface HostServices {
/** Provides `events.emit` and `events.subscribe`. */
events: {
listLifecycle(params: WorkerToHostMethods["events.listLifecycle"][0]): Promise<WorkerToHostMethods["events.listLifecycle"][1]>;
acknowledgeLifecycle(params: WorkerToHostMethods["events.acknowledgeLifecycle"][0]): Promise<void>;
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
subscribe(params: WorkerToHostMethods["events.subscribe"][0]): Promise<void>;
};
@@ -405,6 +407,8 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Events
"events.emit": "events.emit",
"events.subscribe": "events.subscribe",
"events.listLifecycle": "events.subscribe",
"events.acknowledgeLifecycle": "events.subscribe",
// HTTP
"http.fetch": "http.outbound",
@@ -762,6 +766,8 @@ export function createHostClientHandlers(
}),
// Events
"events.listLifecycle": gated("events.listLifecycle", async (params) => services.events.listLifecycle(params)),
"events.acknowledgeLifecycle": gated("events.acknowledgeLifecycle", async (params) => services.events.acknowledgeLifecycle(params)),
"events.emit": gated("events.emit", async (params) => {
return services.events.emit(params);
}),
+1
View File
@@ -238,6 +238,7 @@ export type {
PluginLocalFolderListing,
PluginLocalFoldersClient,
PluginEventsClient,
ResourceLifecycleEvent,
PluginJobsClient,
PluginLaunchersClient,
PluginHttpClient,
+3
View File
@@ -54,6 +54,7 @@ export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
import type {
PluginEvent,
ResourceLifecycleEvent,
PluginIssueCheckoutOwnership,
PluginIssueOrchestrationSummary,
PluginIssueRelationSummary,
@@ -1642,6 +1643,8 @@ export interface WorkerToHostMethods {
];
// Events
"events.listLifecycle": [params: { companyId: string; limit?: number; afterId?: string }, result: ResourceLifecycleEvent[]];
"events.acknowledgeLifecycle": [params: { companyId: string; eventId: string }, result: void];
"events.emit": [
params: { name: string; companyId: string; payload: unknown },
result: void,
+31
View File
@@ -30,6 +30,7 @@ import type {
PluginJobContext,
PluginLauncherRegistration,
PluginEvent,
ResourceLifecycleEvent,
ScopeKey,
ToolResult,
ToolRunContext,
@@ -108,6 +109,7 @@ export interface TestHarness {
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
lifecycleEvents?: ResourceLifecycleEvent[];
projects?: Project[];
issues?: Issue[];
issueComments?: IssueComment[];
@@ -495,6 +497,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const entities = new Map<string, PluginEntityRecord>();
const entityExternalIndex = new Map<string, string>();
const companies = new Map<string, Company>();
const lifecycleEvents = new Map<string, ResourceLifecycleEvent>();
const lifecycleAcknowledgments = new Set<string>();
const projects = new Map<string, Project>();
const routines = new Map<string, Routine>();
const routineRuns = new Map<string, RoutineRun>();
@@ -862,6 +866,32 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
},
events: {
async listLifecycle(companyId, limit = 50, afterId) {
requireCapability(manifest, capabilitySet, "events.subscribe");
requireCompanyId(companyId);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("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 new Error("Invalid lifecycle page id");
const pending = [...lifecycleEvents.values()].filter(event => event.companyId === companyId && !lifecycleAcknowledgments.has(event.id))
.sort((a, b) => Number(a.id) - Number(b.id));
const resources = new Set<string>();
return pending.filter(event => {
const key = `${event.resourceType}:${event.resourceId}`;
if (resources.has(key)) return false;
resources.add(key);
return true;
}).filter(event => afterId === undefined || Number(event.id) > Number(afterId)).slice(0, limit);
},
async acknowledgeLifecycle(companyId, eventId) {
requireCapability(manifest, capabilitySet, "events.subscribe");
requireCompanyId(companyId);
const event = lifecycleEvents.get(eventId);
if (!event || event.companyId !== companyId) throw new Error("Lifecycle event not found");
const earlier = [...lifecycleEvents.values()].some(other => other.companyId === companyId
&& other.resourceType === event.resourceType && other.resourceId === event.resourceId
&& Number(other.id) < Number(event.id) && !lifecycleAcknowledgments.has(other.id));
if (earlier) throw new Error("Acknowledge earlier lifecycle events for this resource first");
lifecycleAcknowledgments.add(eventId);
},
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
requireCapability(manifest, capabilitySet, "events.subscribe");
let registration: EventRegistration;
@@ -2539,6 +2569,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const harness: TestHarness = {
ctx,
seed(input) {
for (const row of input.lifecycleEvents ?? []) lifecycleEvents.set(row.id, row);
for (const row of input.companies ?? []) companies.set(row.id, row);
for (const row of input.projects ?? []) projects.set(row.id, row);
for (const row of input.issues ?? []) {
+18
View File
@@ -546,6 +546,14 @@ export interface PluginLocalFoldersClient {
* @see PLUGIN_SPEC.md §16 — Event System
*/
export interface PluginEventsClient {
/** Read durable resource hooks. Requires events.subscribe and a company scope.
* Only the earliest unacknowledged event for each resource is returned.
* Page using afterId; reset it each polling sweep to retry failures and late commits.
* Events repeat until acknowledged; use a company-scoped provider idempotency key.
*/
listLifecycle(companyId: string, limit?: number, afterId?: string): Promise<ResourceLifecycleEvent[]>;
/** Acknowledge only after the provider operation succeeds. */
acknowledgeLifecycle(companyId: string, eventId: string): Promise<void>;
/**
* Subscribe to a core Paperclip domain event or a plugin-namespaced event.
*
@@ -583,6 +591,16 @@ export interface PluginEventsClient {
emit(name: string, companyId: string, payload: unknown): Promise<void>;
}
export type ResourceLifecycleEvent = {
id: string;
companyId: string;
resourceId: string;
createdAt: string;
} & (
| { resourceType: "agent"; action: "create" | "pause" | "resume" | "terminate" }
| { resourceType: "project"; action: "create" | "update" }
);
/**
* `ctx.jobs` — register handlers for scheduled jobs declared in the manifest.
*
@@ -525,6 +525,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
events: {
async listLifecycle(companyId: string, limit?: number, afterId?: string) {
return callHost("events.listLifecycle", { companyId, limit, afterId });
},
async acknowledgeLifecycle(companyId: string, eventId: string) {
await callHost("events.acknowledgeLifecycle", { companyId, eventId });
},
on(
name: string,
filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>),
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "../src/testing.js";
import type { PaperclipPluginManifestV1, ResourceLifecycleEvent } from "../src/types.js";
const manifest: PaperclipPluginManifestV1 = {
id: "fixture.lifecycle", apiVersion: 1, version: "1.0.0", displayName: "Lifecycle", description: "Fixture",
author: "Tests", categories: ["automation"], capabilities: ["events.subscribe"], entrypoints: {},
};
describe("Lifecycle inbox test harness", () => {
it("lets a plugin exercise retries, acknowledgment order, and company scope", async () => {
const harness = createTestHarness({ manifest });
const event: ResourceLifecycleEvent = { id: "1", companyId: "a", resourceType: "agent", resourceId: "agent", action: "create", createdAt: new Date(0).toISOString() };
harness.seed({ lifecycleEvents: [event, { ...event, id: "2", action: "pause" }, { ...event, id: "3", companyId: "b" }] });
expect(await harness.ctx.events.listLifecycle("a")).toEqual([event]);
expect(await harness.ctx.events.listLifecycle("a", 100, "1")).toEqual([]);
expect(await harness.ctx.events.listLifecycle("a")).toEqual([event]);
await expect(harness.ctx.events.acknowledgeLifecycle("a", "2")).rejects.toThrow("earlier lifecycle events");
await expect(harness.ctx.events.acknowledgeLifecycle("a", "3")).rejects.toThrow("not found");
await harness.ctx.events.acknowledgeLifecycle("a", "1");
await harness.ctx.events.acknowledgeLifecycle("a", "1");
expect(await harness.ctx.events.listLifecycle("a")).toEqual([expect.objectContaining({ action: "pause" })]);
expect(await harness.ctx.events.listLifecycle("b")).toEqual([expect.objectContaining({ id: "3" })]);
const denied = createTestHarness({ manifest, capabilities: [] });
await expect(denied.ctx.events.listLifecycle("a")).rejects.toThrow("events.subscribe");
await expect(denied.ctx.events.acknowledgeLifecycle("a", "1")).rejects.toThrow("events.subscribe");
});
});
@@ -1137,3 +1137,45 @@ describe("AI connection router RPC", () => {
} finally { worker.stop(); reader.close(); input.destroy(); output.destroy(); }
});
});
describe("Durable lifecycle inbox RPC", () => {
it("sends company-scoped reads and acknowledgments through the worker SDK", async () => {
const input = new PassThrough(), output = new PassThrough();
const reader = createInterface({ input: output });
const calls: Array<{ method: string; params: unknown }> = [];
const event = { id: "7", companyId: "company-a", resourceType: "agent", resourceId: "agent-a", action: "pause", createdAt: new Date(0).toISOString() };
const plugin = definePlugin({ async setup(ctx) {
ctx.data.register("drain", async ({ companyId }) => {
const events = await ctx.events.listLifecycle(companyId!, 10, "6");
await ctx.events.acknowledgeLifecycle(companyId!, events[0].id);
return events;
});
} });
const worker = startWorkerRpcHost({ plugin, stdin: input, stdout: output });
let sequence = 0;
const pending = new Map<string, (value: JsonRpcResponse) => void>();
reader.on("line", line => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
} else if (isJsonRpcRequest(message)) {
calls.push({ method: message.method, params: message.params });
input.write(serializeMessage(createSuccessResponse(message.id, message.method === "events.listLifecycle" ? [event] : null)));
}
});
const call = (method: string, params: unknown) => new Promise<JsonRpcResponse>(resolve => {
const id = String(++sequence);
pending.set(id, resolve);
input.write(serializeMessage(createRequest(method, params, id)));
});
try {
await call("initialize", { manifest: { id: "fixture.lifecycle", apiVersion: 1, version: "1.0.0", displayName: "Lifecycle", description: "Fixture", author: "Tests", categories: ["automation"], capabilities: ["events.subscribe"], entrypoints: {} }, config: {}, databaseNamespace: null });
expect((await call("getData", { key: "drain", companyId: "company-a", params: {} }) as { result: unknown }).result).toEqual([event]);
expect(calls).toEqual([
{ method: "events.listLifecycle", params: { companyId: "company-a", limit: 10, afterId: "6" } },
{ method: "events.acknowledgeLifecycle", params: { companyId: "company-a", eventId: "7" } },
]);
} finally { worker.stop(); reader.close(); input.destroy(); output.destroy(); }
});
});
@@ -605,12 +605,14 @@ describe("plugin proactive company scope (LOOA-629)", () => {
}) {
const companiesGet = overrides?.companiesGet ?? vi.fn(async () => ({ id: "company-1", name: "Co" }));
const stateGet = overrides?.stateGet ?? vi.fn(async () => ({ value: "ok" }));
const listLifecycle = vi.fn(async () => [{ id: "1", companyId: "company-1" }]);
const hostHandlers = createHostClientHandlers({
pluginId: "test.plugin",
capabilities: ["companies.read", "plugin.state.read"],
capabilities: ["companies.read", "plugin.state.read", "events.subscribe"],
services: {
companies: { get: companiesGet },
state: { get: stateGet },
events: { listLifecycle },
} as unknown as HostServices,
});
const handle = createPluginWorkerHandle("test.plugin", {
@@ -621,7 +623,7 @@ describe("plugin proactive company scope (LOOA-629)", () => {
apiVersion: 1,
hostHandlers,
});
return { handle, companiesGet, stateGet };
return { handle, companiesGet, stateGet, listLifecycle };
}
it("denies a proactive company-scoped call when no company is authorized", async () => {
@@ -640,6 +642,25 @@ describe("plugin proactive company scope (LOOA-629)", () => {
}
});
it("admits lifecycle polling only for configured proactive companies", async () => {
const { handle, listLifecycle } = makeHandle();
const poll = (companyId: string) => handle.call("getData", {
params: { mode: "omit", hostMethod: "events.listLifecycle", requestedCompanyId: companyId },
} as unknown as HostToWorkerMethods["getData"][0]);
try {
await handle.start();
await expect(poll("company-1")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
handle.setProactiveCompanyScopes(["company-1"]);
expect(await poll("company-1")).toEqual([{ id: "1", companyId: "company-1" }]);
await expect(poll("company-2")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
handle.setProactiveCompanyScopes([]);
await expect(poll("company-1")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
expect(listLifecycle).toHaveBeenCalledTimes(1);
} finally {
await handle.stop().catch(() => undefined);
}
});
it("admits a proactive company-scoped call for an authorized company", async () => {
const { handle, companiesGet } = makeHandle();
try {
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { agentApiKeys, agents, budgetPolicies, companies, costEvents, createDb, projects, resourceLifecycleEvents, type Db } from "@paperclipai/db";
import { agentApiKeys, agents, budgetPolicies, companies, costEvents, createDb, pluginCompanySettings, pluginLifecycleAcknowledgments, plugins, projects, resourceLifecycleEvents, type Db } from "@paperclipai/db";
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
@@ -8,6 +9,9 @@ import { approvalService } from "../services/approvals.js";
import { budgetService } from "../services/budgets.js";
import { projectService } from "../services/projects.js";
import { recordResourceCreationEvent } from "../services/resource-lifecycle-events.js";
import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { createPluginEventBus } from "../services/plugin-event-bus.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
@@ -34,6 +38,10 @@ describePostgres("Resource lifecycle events", () => {
const events = () => db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId));
const createAgent = (status: "idle" | "pending_approval" | "terminated" = "idle", database: Db = db) =>
agentService(database).create(companyId, { name: "Lifecycle agent", adapterType: "process", adapterConfig: {}, status });
const createPlugin = async () => {
const [plugin] = await db.insert(plugins).values({ pluginKey: randomUUID(), packageName: "lifecycle-fixture", version: "1.0.0", status: "ready", manifestJson: {} as never }).returning();
return plugin;
};
it("records direct and approval-free hires once, including concurrent duplicate submissions", async () => {
const agent = await createAgent();
@@ -261,4 +269,88 @@ describePostgres("Resource lifecycle events", () => {
}
});
it("retries until acknowledgment, preserves resource order, and isolates progress per plugin", async () => {
const first = await createPlugin();
const second = await createPlugin();
const agent = await createAgent();
await agentService(db).pause(agent.id);
await agentService(db).resume(agent.id);
const journal = (await events()).sort((a, b) => a.id - b.id);
const inbox = pluginLifecycleInbox(db, first.id);
const [event] = await inbox.list(companyId);
expect(event).toMatchObject({ id: String(journal[0].id), resourceId: agent.id, action: "create" });
expect(await pluginLifecycleInbox(db, first.id).list(companyId)).toEqual([event]);
await expect(inbox.acknowledge(companyId, String(journal[1].id))).rejects.toMatchObject({ status: 409 });
await Promise.all([inbox.acknowledge(companyId, event.id), inbox.acknowledge(companyId, event.id)]);
expect(await inbox.list(companyId)).toEqual([expect.objectContaining({ action: "pause" })]);
expect(await pluginLifecycleInbox(db, second.id).list(companyId)).toEqual([event]);
await inbox.acknowledge(companyId, String(journal[1].id));
await inbox.acknowledge(companyId, String(journal[2].id));
expect(await inbox.list(companyId)).toEqual([]);
await db.delete(plugins).where(eq(plugins.id, first.id));
expect(await db.select().from(pluginLifecycleAcknowledgments).where(eq(pluginLifecycleAcknowledgments.pluginId, first.id))).toEqual([]);
});
it("pages past 100 failing resources without skipping their later events", async () => {
const plugin = await createPlugin();
const inbox = pluginLifecycleInbox(db, plugin.id);
await db.insert(resourceLifecycleEvents).values(Array.from({ length: 101 }, () => ({
companyId, resourceType: "agent" as const, resourceId: randomUUID(), action: "create" as const,
})));
const first = await inbox.list(companyId, 100);
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: first[0].resourceId, action: "pause" });
const next = await inbox.list(companyId, 100, first.at(-1)!.id);
expect(next).toHaveLength(1);
expect(next[0].resourceId).not.toBe(first[0].resourceId);
await inbox.acknowledge(companyId, next[0].id);
expect(await inbox.list(companyId, 100, first.at(-1)!.id)).toEqual([]);
expect(await inbox.list(companyId, 100)).toEqual(first);
await expect(inbox.list(companyId, 100, "invalid")).rejects.toMatchObject({ status: 400 });
});
it("never skips a lower event id that commits after a higher id is acknowledged", async () => {
const plugin = await createPlugin();
const inbox = pluginLifecycleInbox(db, plugin.id);
await db.transaction(async tx => {
const low = await agentService(tx as unknown as Db).create(companyId, { name: "Delayed commit" });
const high = await createAgent();
const [visible] = await inbox.list(companyId);
expect(visible.resourceId).toBe(high.id);
await inbox.acknowledge(companyId, visible.id);
expect(await inbox.list(companyId)).toEqual([]);
expect(low.id).not.toBe(high.id);
});
expect(await inbox.list(companyId)).toEqual([expect.objectContaining({ action: "create" })]);
});
it("enforces capability, invocation company, plugin availability, and company boundaries through host RPC", async () => {
const plugin = await createPlugin();
await createAgent();
const services = buildHostServices(db, plugin.id, plugin.pluginKey, createPluginEventBus());
const handlers = createHostClientHandlers({ pluginId: plugin.id, capabilities: ["events.subscribe"], services });
const scope = { invocationScope: { companyId } };
try {
await expect(handlers["events.listLifecycle"]({ companyId })).rejects.toThrow("company context");
const other = randomUUID();
await expect(handlers["events.listLifecycle"]({ companyId: other }, scope)).rejects.toThrow("requested company");
const denied = createHostClientHandlers({ pluginId: plugin.id, capabilities: [], services });
await expect(denied["events.listLifecycle"]({ companyId }, scope)).rejects.toThrow("events.subscribe");
await expect(denied["events.acknowledgeLifecycle"]({ companyId, eventId: "1" }, scope)).rejects.toThrow("events.subscribe");
const [event] = await handlers["events.listLifecycle"]({ companyId }, scope);
await expect(handlers["events.acknowledgeLifecycle"]({ companyId: other, eventId: event.id }, scope)).rejects.toThrow("requested company");
await expect(pluginLifecycleInbox(db, plugin.id).acknowledge(other, event.id)).rejects.toMatchObject({ status: 404 });
await db.insert(pluginCompanySettings).values({ pluginId: plugin.id, companyId, enabled: false });
await expect(handlers["events.listLifecycle"]({ companyId }, scope)).rejects.toMatchObject({ status: 403 });
await expect(handlers["events.acknowledgeLifecycle"]({ companyId, eventId: event.id }, scope)).rejects.toMatchObject({ status: 403 });
await db.update(pluginCompanySettings).set({ enabled: true }).where(eq(pluginCompanySettings.pluginId, plugin.id));
await expect(handlers["events.listLifecycle"]({ companyId, limit: 0 }, scope)).rejects.toMatchObject({ status: 400 });
await expect(handlers["events.acknowledgeLifecycle"]({ companyId, eventId: "1e2" }, scope)).rejects.toMatchObject({ status: 400 });
await handlers["events.acknowledgeLifecycle"]({ companyId, eventId: event.id }, scope);
expect(await handlers["events.listLifecycle"]({ companyId }, scope)).toEqual([]);
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, plugin.id));
await expect(handlers["events.listLifecycle"]({ companyId }, scope)).rejects.toMatchObject({ status: 403 });
} finally {
services.dispose();
}
});
});
@@ -47,6 +47,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import path from "node:path";
import { pluginRegistryService } from "./plugin-registry.js";
import { pluginStateStore } from "./plugin-state-store.js";
import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js";
import { pluginDatabaseService } from "./plugin-database.js";
import { pluginManagedAgentService } from "./plugin-managed-agents.js";
import { pluginManagedRoutineService } from "./plugin-managed-routines.js";
@@ -757,6 +758,7 @@ export function buildHostServices(
const approvalSvc = approvalService(db);
const interactions = issueThreadInteractionService(db);
const scopedBus = eventBus.forPlugin(pluginKey);
const lifecycleInbox = pluginLifecycleInbox(db, pluginId);
// Track active session event subscriptions for cleanup
const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType<typeof setTimeout> }>();
@@ -1557,6 +1559,12 @@ export function buildHostServices(
},
events: {
async listLifecycle(params) {
return lifecycleInbox.list(ensureCompanyId(params.companyId), params.limit, params.afterId);
},
async acknowledgeLifecycle(params) {
await lifecycleInbox.acknowledge(ensureCompanyId(params.companyId), params.eventId);
},
async emit(params) {
if (params.companyId) {
await ensurePluginAvailableForCompany(params.companyId);
@@ -0,0 +1,56 @@
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 { badRequest, conflict, forbidden, notFound } from "../errors.js";
/** Durable pull delivery. 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
WHERE ack.plugin_id = ${pluginId} AND ack.event_id = ${resourceLifecycleEvents.id}
)`;
const firstForResource = sql`NOT EXISTS (
SELECT 1 FROM resource_lifecycle_events older
WHERE older.company_id = ${resourceLifecycleEvents.companyId}
AND older.resource_type = ${resourceLifecycleEvents.resourceType}
AND older.resource_id = ${resourceLifecycleEvents.resourceId}
AND older.id < ${resourceLifecycleEvents.id}
AND NOT EXISTS (SELECT 1 FROM plugin_lifecycle_acknowledgments ack
WHERE ack.plugin_id = ${pluginId} AND ack.event_id = older.id)
)`;
async function assertAvailable(database: Db, companyId: string) {
if (!z.string().uuid().safeParse(companyId).success) throw badRequest("companyId must be a UUID");
const [plugin] = await database.select({ status: plugins.status }).from(plugins).where(eq(plugins.id, pluginId));
const [settings] = await database.select({ enabled: pluginCompanySettings.enabled }).from(pluginCompanySettings)
.where(and(eq(pluginCompanySettings.pluginId, pluginId), eq(pluginCompanySettings.companyId, companyId)));
if (plugin?.status !== "ready" || settings?.enabled === false) throw forbidden("Plugin is not enabled for this company");
}
return {
async list(companyId: string, limit = 50, afterId?: string): 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))))
.orderBy(asc(resourceLifecycleEvents.id)).limit(limit);
return rows.map(row => ({ ...row, id: String(row.id), createdAt: row.createdAt.toISOString() })) as ResourceLifecycleEvent[];
},
async acknowledge(companyId: string, eventId: string): Promise<void> {
if (typeof eventId !== "string" || !/^[1-9]\d*$/.test(eventId) || !Number.isSafeInteger(Number(eventId))) throw badRequest("Invalid lifecycle event id");
await db.transaction(async tx => {
const txDb = tx as unknown as Db;
await assertAvailable(txDb, companyId);
const [event] = await tx.select().from(resourceLifecycleEvents)
.where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.id, Number(eventId)))).for("update");
if (!event) throw notFound("Lifecycle event not found");
const [eligible] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents)
.where(and(eq(resourceLifecycleEvents.id, event.id), firstForResource));
if (!eligible) throw conflict("Acknowledge earlier lifecycle events for this resource first");
await tx.insert(pluginLifecycleAcknowledgments).values({ pluginId, eventId: event.id }).onConflictDoNothing();
});
},
};
}