mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
72 lines
5.3 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|