Files
PaperClipAI/server/src/services/plugin-lifecycle-inbox.ts
T

72 lines
5.3 KiB
TypeScript

import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { projectInstanceSecretService, type ProjectSecretSnapshot } from "./project-instance-secrets.js";
import { publishActivity, type ActivityPublication } from "./activity-log.js";
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, inArray, sql } from "drizzle-orm";
import { badRequest, conflict, forbidden, notFound } from "../errors.js";
/** 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
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}
-- Backfilled creation must precede transitions captured before this baseline.
AND ${resourceLifecycleEvents.action} <> 'create'
AND (older.action = 'create' OR 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, 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, inArray(resourceLifecycleEvents.resourceType, resourceTypes ?? ["agent", "project"]), 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, credentials?: { snapshot: ProjectSecretSnapshot; changes: unknown }): Promise<void> {
if (typeof eventId !== "string" || !/^[1-9]\d*$/.test(eventId) || !Number.isSafeInteger(Number(eventId))) throw badRequest("Invalid lifecycle event id");
const commit = () => db.transaction(async tx => {
const publications: ActivityPublication[] = [];
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 [acked] = await tx.select().from(pluginLifecycleAcknowledgments).where(and(eq(pluginLifecycleAcknowledgments.pluginId, pluginId), eq(pluginLifecycleAcknowledgments.eventId, event.id)));
if (acked) return publications;
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");
if (credentials) {
if (event.resourceType !== "project") throw forbidden("Project instance secret event required");
publications.push(...await projectInstanceSecretService(txDb, pluginId).apply({ ...event, id: String(event.id), createdAt: event.createdAt.toISOString() } as ResourceLifecycleEvent, credentials.snapshot, credentials.changes));
}
await tx.insert(pluginLifecycleAcknowledgments).values({ pluginId, eventId: event.id }).onConflictDoNothing();
return publications;
});
const publications = credentials ? await withAccountHomeSecretMutationLock(undefined, companyId, commit) : await commit();
for (const publication of publications) publishActivity(publication);
},
};
}