mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
Add sandbox work-folder persistence and shared lifecycle
Introduce company-scoped task, agent, user and project files, recoverable trash, object-backed repository checkpoints, and a shared 180-second lifecycle for sandbox execution. Wire file browsing and save status into the owner surfaces. This is an implementation checkpoint; staging acceptance and the complete merge gate remain outstanding.
This commit is contained in:
1 parent
392ab26b1e
commit
a13bb6b395
51 files changed
+176079
-37
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { workFolderObjects, workFiles, taskRepositoryBindings, type Db } from "@paperclipai/db";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
|
||||
/** Record an upload before contacting storage, so abandoned bytes remain collectible. */
|
||||
export async function registerWorkFolderObject(db: Db, storage: StorageProvider, input: {
|
||||
objectKey: string; companyId: string; folderId?: string; repositoryBindingId?: string;
|
||||
}) {
|
||||
await db.insert(workFolderObjects).values({ ...input, provider: storage.id,
|
||||
deleteAfter: new Date(Date.now() + 24 * 60 * 60 * 1000) }).onConflictDoUpdate({
|
||||
target: workFolderObjects.objectKey,
|
||||
set: { deleteAfter: sql`case when ${workFolderObjects.deleteAfter} is null then null else ${new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()}::timestamptz end` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounded, retryable cleanup. Committed trash stays referenced until explicit purge. */
|
||||
export async function collectWorkFolderGarbage(db: Db, storage: StorageProvider, now = new Date(), limit = 100) {
|
||||
// Polymorphic owners cannot use a single foreign key. Permanent deletion is
|
||||
// detected against the authoritative owner tables, including auth users.
|
||||
await db.execute(sql`delete from work_folders f where
|
||||
(f.scope = 'task' and not exists (select 1 from issues i where i.id::text = f.owner_id and i.company_id = f.company_id)) or
|
||||
(f.scope = 'agent' and not exists (select 1 from agents a where a.id::text = f.owner_id and a.company_id = f.company_id)) or
|
||||
(f.scope = 'project' and not exists (select 1 from projects p where p.id::text = f.owner_id and p.company_id = f.company_id)) or
|
||||
(f.scope = 'user' and not exists (select 1 from "user" u where u.id = f.owner_id))`);
|
||||
let deleted = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const candidates = await tx.select().from(workFolderObjects).where(and(eq(workFolderObjects.provider, storage.id),
|
||||
sql`(${workFolderObjects.deleteAfter} <= ${now.toISOString()}::timestamptz or (${workFolderObjects.deleteAfter} is null and (
|
||||
(${workFolderObjects.folderId} is not null and not exists (select 1 from ${workFiles} where ${workFiles.objectKey} = ${workFolderObjects.objectKey})) or
|
||||
(${workFolderObjects.repositoryBindingId} is not null and not exists (select 1 from ${taskRepositoryBindings} where ${taskRepositoryBindings.id} = ${workFolderObjects.repositoryBindingId}))
|
||||
)))`,
|
||||
sql`not exists (select 1 from ${workFiles} where ${workFiles.objectKey} = ${workFolderObjects.objectKey})`,
|
||||
)).limit(Math.max(1, Math.min(limit, 1000))).for("update", { skipLocked: true });
|
||||
for (const object of candidates) {
|
||||
const prefix = object.folderId ? `${object.companyId}/work-folders/${object.folderId}/`
|
||||
: `${object.companyId}/task-repositories/${object.repositoryBindingId}/`;
|
||||
if (!object.objectKey.startsWith(prefix)) throw new Error("Work folder garbage ownership mismatch");
|
||||
await storage.deleteObject({ objectKey: object.objectKey });
|
||||
await tx.delete(workFolderObjects).where(eq(workFolderObjects.objectKey, object.objectKey));
|
||||
deleted++;
|
||||
}
|
||||
});
|
||||
return { deleted };
|
||||
}
|
||||
Reference in new issue
Block a user