mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
perf(skills): store repository packages outside Postgres
Convert legacy packages automatically, restore exact commits on cache misses, and expire unused recoverable packages while keeping warm turn checks metadata-only. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
65fa1cd617
commit
0bb36b2c6b
53 files changed
+13120
-203
No files matched your search
@@ -53,7 +53,9 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
const previousPackageStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-");
|
||||
oldPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
oldPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
@@ -80,6 +82,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPackageStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousPackageStorage;
|
||||
if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = oldPaperclipHome;
|
||||
if (oldPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
@@ -233,7 +237,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await db.insert(companies).values({ id: companyId, name: "Rollback retry", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const input = { name: "Retryable", slug: "retryable", markdown: "# durable bytes\n" };
|
||||
await expect(db.transaction(async (tx) => {
|
||||
await companySkillService(tx as any).createLocalSkill(companyId, input);
|
||||
await companySkillService(tx as any, { packageDb: db }).createLocalSkill(companyId, input);
|
||||
throw new Error("simulate outer rollback");
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
@@ -383,7 +387,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await db.insert(companies).values({ id: companyId, name: "Rollback conflict", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const original = { name: "Original", slug: "rollback-conflict", markdown: "# original bytes\n" };
|
||||
await expect(db.transaction(async (tx) => {
|
||||
await companySkillService(tx as any).createLocalSkill(companyId, original);
|
||||
await companySkillService(tx as any, { packageDb: db }).createLocalSkill(companyId, original);
|
||||
throw new Error("simulate outer rollback");
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { skillPackageWork } from "../services/skill-package-work.js";
|
||||
import * as recovery from "../services/skill-package-recovery.js";
|
||||
import type { GitHubRead } from "../services/github-skill-source.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
||||
skillPackageDeletions, createDb } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import * as storageModule from "../storage/index.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { skillPackageStore, skillPackagePaths, withSkillPackageReadScope } from "../services/skill-package-store.js";
|
||||
import { skillPackageMaintenance } from "../services/skill-package-maintenance.js";
|
||||
import { skillPackageManifest, validateSkillPackageManifest, writeSkillPackageArchive, extractSkillPackageArchive,
|
||||
verifySkillPackageDirectory } from "../services/skill-package-files.js";
|
||||
import { skillFileBytes, skillSnapshotHash } from "../services/skill-snapshot.js";
|
||||
import { hasRestorableSnapshot } from "../services/skill-version-cache.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let home: string;
|
||||
let storage: storageModule.StorageService;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const files: CompanySkillVersionFileInventoryEntry[] = [
|
||||
{ path: "skills/one/SKILL.md", kind: "skill", content: "---\nname: one\ndescription: Test one\n---\nUse ../../shared/run.sh\n" },
|
||||
{ path: "skills/two/SKILL.md", kind: "skill", content: "---\nname: two\ndescription: Test two\n---\nUse ../../shared/run.sh\n" },
|
||||
{ path: "shared/run.sh", kind: "script", content: "#!/bin/sh\necho original\n", executable: true },
|
||||
{ path: "assets/binary", kind: "reference", content: Buffer.from([0, 255, 7]).toString("base64"), encoding: "base64" },
|
||||
{ path: "assets/base64-text", kind: "reference", content: Buffer.from("text encoded explicitly").toString("base64"), encoding: "base64" },
|
||||
];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("skill-packages-");
|
||||
db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-packages-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
storage = createStorageService(createLocalDiskStorageProvider(path.join(home, "objects")));
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
async function company() {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: "Package test", issuePrefix: `P${id.slice(0, 6)}` });
|
||||
return id;
|
||||
}
|
||||
async function fixture() {
|
||||
const companyId = await company();
|
||||
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
|
||||
const store = skillPackageStore(db, storage);
|
||||
const id = await store.prepare(companyId, files);
|
||||
const skills = [];
|
||||
for (const name of ["one", "two"]) {
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: `company/${companyId}/${name}`, slug: name,
|
||||
name, markdown: files.find(file => file.path === `skills/${name}/SKILL.md`)!.content, sourceType: "github",
|
||||
metadata: { skillSourceId: randomUUID(), snapshotHash: "saved" } }).returning();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 1,
|
||||
repositorySnapshotId: id, repositorySkillPath: `skills/${name}/SKILL.md`, packageSnapshotId: id,
|
||||
packageFileMap: [{ path: "SKILL.md", kind: "skill", sourcePath: `skills/${name}/SKILL.md` }] }).returning();
|
||||
await db.update(companySkills).set({ currentVersionId: version!.id }).where(eq(companySkills.id, skill!.id));
|
||||
skills.push({ skill: skill!, version: version! });
|
||||
}
|
||||
const service = companySkillService(db);
|
||||
const resolve = () => service.listRuntimeSkillEntries(companyId, { onlyKeys: new Set(skills.map(({ skill }) => skill.key)), snapshotLatest: true });
|
||||
return { companyId, id, store, skills, service, resolve };
|
||||
}
|
||||
|
||||
it("automatically converts legacy rows, clears verified payloads and preserves version identities", async () => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: "legacy", slug: "legacy", name: "Legacy", markdown: files[0]!.content }).returning();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 7,
|
||||
repositorySnapshotId: snapshot!.id, repositorySkillPath: files[0]!.path,
|
||||
fileInventory: [{ ...files[0]!, path: "SKILL.md" }] }).returning();
|
||||
const work = skillPackageWork(db, storage);
|
||||
await work.tick(new AbortController().signal);
|
||||
const [saved] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
const [converted] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id));
|
||||
expect(saved!.fileInventory).toEqual([]);
|
||||
expect(saved!.migrationAt).toBeNull();
|
||||
expect(saved!.storage).not.toBeNull();
|
||||
expect(converted!.fileInventory).toEqual([]);
|
||||
expect(converted!.migrationAt).toBeNull();
|
||||
expect(converted!.packageSnapshotId).toBe(snapshot!.id);
|
||||
expect(converted!.revisionNumber).toBe(7);
|
||||
expect(await skillPackageStore(db, storage).versionFiles(converted!)).toEqual([{ ...files[0]!, path: "SKILL.md" }]);
|
||||
});
|
||||
|
||||
it("expires an unused Git package and reacquires its exact bytes without a migration command", async () => {
|
||||
const f = await fixture();
|
||||
const recipe = { version: 1 as const, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: null, manifest: skillPackageManifest(files) };
|
||||
await f.store.prepare(f.companyId, files, true, undefined, recipe);
|
||||
await f.store.files(f.companyId, f.id);
|
||||
await db.update(snapshots).set({ createdAt: new Date(Date.now() - 40 * 86_400_000), maintenanceAt: new Date(0), migrationAt: null }).where(eq(snapshots.id, f.id));
|
||||
// Current versions stay pinned even after their cache age expires.
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).not.toBeNull();
|
||||
for (const { skill } of f.skills) await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, skill.id));
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date(0) }).where(eq(snapshots.id, f.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).toBeNull();
|
||||
expect(await fs.stat(skillPackagePaths(f.companyId, f.id).directory).catch(() => null)).toBeNull();
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: file.path, mode: file.executable ? "100755" : "100644", type: "blob", size: file.size, sha: String(i).repeat(40) }));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), { openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries,
|
||||
readBlob: async (entry: typeof entries[number]) => skillFileBytes(files[entries.indexOf(entry)]!), release: async () => {} })) }) as unknown as GitHubRead;
|
||||
const authorization = vi.spyOn(recovery, "skillPackageRecoveryReader").mockResolvedValue(read);
|
||||
expect(await f.store.files(f.companyId, f.id)).toEqual(files);
|
||||
expect(read.openSnapshot).toHaveBeenCalledOnce();
|
||||
await f.store.files(f.companyId, f.id);
|
||||
expect(authorization).toHaveBeenCalledOnce();
|
||||
await fs.rm(skillPackagePaths(f.companyId, f.id).directory, { recursive: true });
|
||||
authorization.mockRejectedValue(new Error("grant revoked"));
|
||||
await expect(f.store.files(f.companyId, f.id)).rejects.toThrow("grant revoked");
|
||||
await expect(f.store.files(f.companyId, f.id)).rejects.toThrow("backoff");
|
||||
expect(authorization).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers missing archive and manifest objects while a published receipt still exists", async () => {
|
||||
const companyId = await company();
|
||||
const store = skillPackageStore(db, storage);
|
||||
const recipe = { version: 1 as const, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: null, manifest: skillPackageManifest(files) };
|
||||
const id = await store.prepare(companyId, files, true, undefined, recipe);
|
||||
const receipt = (await store.descriptor(companyId, id)).storage!;
|
||||
await storage.deleteObject(companyId, receipt.archiveKey);
|
||||
await storage.deleteObject(companyId, receipt.manifestKey);
|
||||
await expect(recovery.skillPackageRecoveryReader(db, companyId, recipe)).rejects.toThrow("Reconnect its original repository");
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: file.path, mode: file.executable ? "100755" : "100644", type: "blob", size: file.size, sha: String(i).repeat(40) }));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), { openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries,
|
||||
readBlob: async (entry: typeof entries[number]) => skillFileBytes(files[entries.indexOf(entry)]!), release: async () => {} })) }) as unknown as GitHubRead;
|
||||
vi.spyOn(recovery, "skillPackageRecoveryReader").mockResolvedValue(read);
|
||||
// PostgreSQL JSONB has reordered recipe object keys; its canonical bytes must
|
||||
// still agree with the original externally written manifest checksum.
|
||||
expect(await store.files(companyId, id)).toEqual(files);
|
||||
expect(read.openSnapshot).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains referenced historical bytes without an exact Git recipe", async () => {
|
||||
const f = await fixture();
|
||||
for (const { skill } of f.skills) await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, skill.id));
|
||||
await db.update(snapshots).set({ createdAt: new Date(0), migrationAt: null, maintenanceAt: new Date(0) }).where(eq(snapshots.id, f.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).not.toBeNull();
|
||||
expect(await f.store.files(f.companyId, f.id)).toEqual(files);
|
||||
});
|
||||
|
||||
it("restores one shared archive, then prepares a warm turn without object reads or SQL file bodies", async () => {
|
||||
const f = await fixture();
|
||||
const read = vi.spyOn(storage, "getObject");
|
||||
const cold = await f.resolve();
|
||||
expect(cold.map(entry => entry.sourceStatus)).toEqual(["available", "available"]);
|
||||
expect(read.mock.calls.filter(call => call[1].endsWith("files.tar.gz"))).toHaveLength(1);
|
||||
expect(read.mock.calls.filter(call => call[1].endsWith("manifest.json"))).toHaveLength(1);
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.id, f.id));
|
||||
expect(row!.fileInventory).toEqual([]);
|
||||
const sql: string[] = [];
|
||||
const oldDebug = db.$client.options.debug;
|
||||
db.$client.options.debug = (_connection, query) => { sql.push(query); };
|
||||
read.mockClear();
|
||||
try { expect(await f.resolve()).toEqual(cold); }
|
||||
finally { db.$client.options.debug = oldDebug; }
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(sql.length).toBeLessThanOrEqual(6);
|
||||
for (const query of sql.filter(query => /from "company_skill_(repository_snapshots|versions)"/.test(query))) {
|
||||
// Bundled release validation returns only a DB-side digest.
|
||||
if (!query.includes("encode(sha256(")) expect(query).not.toMatch(/"file_inventory"/);
|
||||
}
|
||||
expect((await f.service.getVersion(f.companyId, f.skills[0]!.skill.id, f.skills[0]!.version.id))!.fileInventory)
|
||||
.toEqual([{ path: "SKILL.md", kind: "skill", content: files[0]!.content }]);
|
||||
const restored = await f.store.files(f.companyId, f.id);
|
||||
expect(restored.map(skillFileBytes)).toEqual(files.map(skillFileBytes));
|
||||
await expect(f.store.files(await company(), f.id)).rejects.toThrow("unavailable");
|
||||
});
|
||||
|
||||
it("repairs a same-size cached edit and restores old versions without upstream access", async () => {
|
||||
const f = await fixture();
|
||||
const entries = await f.resolve();
|
||||
const target = path.join(entries[0]!.source, ".paperclip-repository/shared/run.sh");
|
||||
const stat = await fs.stat(target);
|
||||
await fs.chmod(target, 0o755);
|
||||
await fs.writeFile(target, "#!/bin/sh\necho modified\n");
|
||||
await fs.utimes(target, stat.atime, stat.mtime);
|
||||
const read = vi.spyOn(storage, "getObject");
|
||||
await f.resolve();
|
||||
expect(await fs.readFile(target, "utf8")).toBe(files[2]!.content);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
const upstream = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
||||
for (const entry of entries) await fs.rm(entry.source, { recursive: true });
|
||||
await fs.rm(skillPackagePaths(f.companyId, f.id).directory, { recursive: true });
|
||||
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses cache eviction when durable objects are missing, even after an earlier verification", async () => {
|
||||
const f = await fixture();
|
||||
await f.resolve();
|
||||
expect(await hasRestorableSnapshot(db, f.companyId, f.skills[0]!.version.id)).toBe(true);
|
||||
const receipt = (await f.store.descriptor(f.companyId, f.id)).storage!;
|
||||
await storage.deleteObject(f.companyId, receipt.archiveKey);
|
||||
await expect(hasRestorableSnapshot(db, f.companyId, f.skills[0]!.version.id)).rejects.toThrow();
|
||||
// Existing local verified files remain usable while storage is unavailable.
|
||||
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||
});
|
||||
|
||||
it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => {
|
||||
const companyId = await company();
|
||||
const store = skillPackageStore(db, storage);
|
||||
const put = vi.spyOn(storage, "putFile").mockRejectedValueOnce(new Error("connection lost"));
|
||||
await expect(store.prepare(companyId, files)).rejects.toThrow("connection lost");
|
||||
put.mockRestore();
|
||||
const [pending] = await db.select().from(snapshots).where(eq(snapshots.companyId, companyId));
|
||||
expect(pending!.storage).toBeNull();
|
||||
await expect(store.prepare(companyId, files)).rejects.toThrow("being saved");
|
||||
await db.update(snapshots).set({ upload: { ...pending!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, pending!.id));
|
||||
const id = await store.prepare(companyId, files);
|
||||
expect(id).toBe(pending!.id);
|
||||
const receipt = (await store.descriptor(companyId, id)).storage!;
|
||||
expect(receipt.archiveKey).not.toBe(pending!.upload!.archiveKey);
|
||||
const [deletion] = await db.select().from(skillPackageDeletions).where(eq(skillPackageDeletions.companyId, companyId));
|
||||
expect(deletion!.archiveKey).toBe(pending!.upload!.archiveKey);
|
||||
await db.update(skillPackageDeletions).set({ deleteAfter: new Date(0) }).where(eq(skillPackageDeletions.id, deletion!.id));
|
||||
expect((await skillPackageMaintenance(db, storage).deleteObjects({ companyId, apply: true })).removed).toBe(1);
|
||||
expect(await store.verifyDurable(companyId, id)).toBe(true);
|
||||
// Company cascades retain a receipt after removing the snapshot's DB row.
|
||||
await db.delete(companies).where(eq(companies.id, companyId));
|
||||
expect(await db.select().from(skillPackageDeletions).where(eq(skillPackageDeletions.companyId, companyId)))
|
||||
.toEqual([expect.objectContaining({ archiveKey: receipt.archiveKey })]);
|
||||
});
|
||||
|
||||
it("cancels queued and active uploads without publishing or leaking I/O capacity", async () => {
|
||||
const ids = await Promise.all([company(), company(), company()]);
|
||||
const active = new AbortController();
|
||||
const queued = new AbortController();
|
||||
const put = vi.spyOn(storage, "putFile").mockImplementation(async input => {
|
||||
expect(input.signal).toBeDefined();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
input.signal!.addEventListener("abort", () => reject(input.signal!.reason), { once: true });
|
||||
});
|
||||
throw new Error("Unreachable cancelled upload");
|
||||
});
|
||||
const store = skillPackageStore(db, storage);
|
||||
const running = ids.slice(0, 2).map(id => store.prepare(id!, files, false, active.signal).catch(error => error));
|
||||
try {
|
||||
await vi.waitFor(() => expect(put).toHaveBeenCalledTimes(2));
|
||||
const waiting = store.prepare(ids[2]!, files, false, queued.signal).catch(error => error);
|
||||
await vi.waitFor(async () => expect(await db.select({ id: snapshots.id }).from(snapshots)
|
||||
.where(eq(snapshots.companyId, ids[2]!))).toHaveLength(1));
|
||||
queued.abort(new Error("queued cancellation"));
|
||||
expect((await waiting).message).toBe("queued cancellation");
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
active.abort(new Error("active cancellation"));
|
||||
await Promise.all(running);
|
||||
put.mockRestore();
|
||||
}
|
||||
for (const id of ids) {
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.companyId, id!));
|
||||
expect(row!.storage).toBeNull();
|
||||
expect(row!.upload).not.toBeNull();
|
||||
}
|
||||
expect(await store.prepare(await company(), files)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("migrates both legacy payload locations in bounded resumable pages without changing version IDs", async () => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: "legacy", slug: "legacy", name: "Legacy", markdown: files[0]!.content }).returning();
|
||||
const original = { path: "SKILL.md", kind: "skill" as const, content: files[0]!.content };
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 7,
|
||||
repositorySnapshotId: snapshot!.id, repositorySkillPath: files[0]!.path, fileInventory: [original] }).returning();
|
||||
const maintenance = skillPackageMaintenance(db, storage);
|
||||
expect((await maintenance.migrateSnapshots({ companyId })).migrated).toBe(0);
|
||||
const fail = vi.spyOn(storage, "getObject").mockRejectedValueOnce(new Error("verification interrupted"));
|
||||
await expect(maintenance.migrateSnapshots({ companyId, apply: true })).rejects.toThrow("verification interrupted");
|
||||
fail.mockRestore();
|
||||
const [pending] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(pending!.fileInventory).toEqual(files);
|
||||
await db.update(snapshots).set({ upload: { ...pending!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, snapshot!.id));
|
||||
expect((await maintenance.migrateSnapshots({ companyId, limit: 1, apply: true })).migrated).toBe(1);
|
||||
const [soaked] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(soaked!.storage).not.toBeNull();
|
||||
expect(soaked!.fileInventory).toEqual(files);
|
||||
expect((await maintenance.migrateSnapshots({ companyId, limit: 1, apply: true, clearLegacy: true })).migrated).toBe(1);
|
||||
expect((await maintenance.migrateSnapshots({ companyId, apply: true })).inspected).toBe(0);
|
||||
expect((await maintenance.migrateVersions({ companyId, apply: true, clearLegacy: true })).migrated).toBe(1);
|
||||
const [saved] = await db.select().from(companySkillVersions).where(and(eq(companySkillVersions.id, version!.id), eq(companySkillVersions.companyId, companyId)));
|
||||
expect(saved).toMatchObject({ id: version!.id, revisionNumber: 7, fileInventory: [], repositorySnapshotId: snapshot!.id, packageSnapshotId: snapshot!.id });
|
||||
expect(await skillPackageStore(db, storage).versionFiles(saved!)).toEqual([original]);
|
||||
});
|
||||
|
||||
it.each([false, true])("restores across legacy payload clearing with a cached manifest: %s", async cachedManifest => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const store = skillPackageStore(db, storage);
|
||||
await withSkillPackageReadScope(db, async () => {
|
||||
expect((await store.descriptor(companyId, snapshot!.id)).storage).toBeNull();
|
||||
if (cachedManifest) await store.manifest(companyId, snapshot!.id);
|
||||
await store.prepare(companyId, files, true);
|
||||
await db.update(snapshots).set({ fileInventory: [] }).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(skillSnapshotHash(await store.files(companyId, snapshot!.id))).toBe(skillSnapshotHash(files));
|
||||
expect(await store.verifyDurable(companyId, snapshot!.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds and validates archives, binaries, paths, executable modes, and corruption", async () => {
|
||||
const root = await fs.mkdtemp(path.join(home, "codec-"));
|
||||
const archive = path.join(root, "files.tar.gz");
|
||||
const manifest = skillPackageManifest(files);
|
||||
await writeSkillPackageArchive(archive, files);
|
||||
const output = path.join(root, "out");
|
||||
await fs.mkdir(output);
|
||||
await extractSkillPackageArchive(archive, output, manifest);
|
||||
expect(await verifySkillPackageDirectory(output, manifest.files)).toBe(true);
|
||||
expect((await fs.stat(path.join(output, "shared/run.sh"))).mode & 0o111).toBe(0o111);
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, files: [{ ...manifest.files[0], path: "../escape" }] })).toThrow();
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, files: [manifest.files[0], manifest.files[0]] })).toThrow();
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, totalBytes: 1 })).toThrow();
|
||||
const bad = path.join(root, "bad");
|
||||
await fs.mkdir(bad);
|
||||
await fs.truncate(archive, 20);
|
||||
await expect(extractSkillPackageArchive(archive, bad, manifest)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,9 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
let files: Record<string, string | Buffer> = initial;
|
||||
let commit = sha;
|
||||
const context: SkillSourceContext = { actor: { type: 'user', userId: 'board' }, read: () => githubFixture(files, { 'deep/one/scripts/run.sh': '100755', 'shared/check.sh': '100755' }, commit), authorize: async () => {}, audit: async () => {} };
|
||||
const previousPackageStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
||||
if (externalUrl) await applyPendingMigrations(externalUrl);
|
||||
else testDb = await startEmbeddedPostgresTestDatabase('paperclip-skill-sources-');
|
||||
db = createDb(externalUrl ?? testDb.connectionString);
|
||||
@@ -39,10 +41,24 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
await db.insert(companies).values({ id: companyId, name: 'Skills', issuePrefix: 'SKL' });
|
||||
}, 30000);
|
||||
afterAll(async () => {
|
||||
if (previousPackageStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousPackageStorage;
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await testDb?.cleanup(); if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
it('tracks repositories with no selected valid skills without saving their contents', async () => {
|
||||
for (const repository of [{ 'README.md': 'No skills here' }, { 'one/SKILL.md': md('one'), 'shared.txt': 'Unused package content' }]) {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: 'Discovery only', issuePrefix: `E${id.slice(0, 6)}` });
|
||||
const result = await skillSourceService(db).create(id, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main',
|
||||
packageMode: 'repository', commitSha: sha, selectedPaths: [] }, { ...context, read: () => githubFixture(repository) });
|
||||
expect(result.imported).toEqual([]);
|
||||
expect(result.source.lastScanCommit).toBe(sha);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.companyId, id))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes two assigned skills while a concurrent version save holds the second skill', async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: 'Concurrent skill publication', issuePrefix: 'CSP' });
|
||||
@@ -566,7 +582,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
const v1 = (await skills.getVersion(companyId, main.id, main.currentVersionId!))!;
|
||||
const helper = installed.imported.find(skill => skill.name === 'helper')!;
|
||||
expect((await skills.getVersion(companyId, helper.id, helper.currentVersionId!))?.repositorySnapshotId).toBe(v1.repositorySnapshotId);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.companyId, companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.id, v1.repositorySnapshotId!))).toEqual([expect.objectContaining({ fileInventory: [], storage: expect.objectContaining({ format: "tar-gzip-v1" }) })]);
|
||||
await db.update(companySkills).set({ trustLevel: 'markdown_only' }).where(eq(companySkills.id, main.id));
|
||||
expect((await sources.refresh(companyId, installed.source.id, context)).unchanged).toBe(2);
|
||||
expect((await skills.getById(companyId, main.id))?.trustLevel).toBe('scripts_executables');
|
||||
|
||||
@@ -21,6 +21,8 @@ import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-w
|
||||
import { loginCleanupSchedule } from "./services/login-cleanup-state.js";
|
||||
import { registerLoginCleanupWork } from "./services/login-cleanup-work.js";
|
||||
import { timeStartupRecovery } from "./startup-recovery-timing.js";
|
||||
import { runtimeAssetRetention } from "./services/native-runtime/runtime-asset-retention.js";
|
||||
import { skillPackageWork } from "./services/skill-package-work.js";
|
||||
import { skillSourceSyncService } from "./services/skill-source-sync.js";
|
||||
import {
|
||||
SKILL_VERSION_RETENTION_INTERVAL_MS,
|
||||
@@ -1396,6 +1398,9 @@ async function startServerWithDatabaseTeardown(
|
||||
const accountingWorker = registerCostAccountingWork(db, deliveryWork, accountingHeartbeat.reconcileCostAccounting);
|
||||
server.on("close", accountingWorker.stop);
|
||||
skillSourceSync.start(deliveryWork);
|
||||
skillPackageWork(db).start(deliveryWork);
|
||||
const runtimeAssetCleanup = runtimeAssetRetention(db).start(deliveryWork);
|
||||
server.on("close", () => { void runtimeAssetCleanup.stop(); });
|
||||
const caseHandoffDeliveries = caseHandoffDeliveryService(db as any, { heartbeat: environmentLeaseCleanupHeartbeat });
|
||||
deliveryWork.register(DELIVERY_QUEUES.caseHandoffs, {
|
||||
retryMs: 5_000,
|
||||
|
||||
@@ -1221,7 +1221,7 @@ export function companySkillRoutes(db: Db) {
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_created"),
|
||||
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
||||
));
|
||||
const service = companySkillService(tx as unknown as Db);
|
||||
const service = companySkillService(tx as unknown as Db, { packageDb: db });
|
||||
if (prior) {
|
||||
if (prior.details?.fingerprint !== fingerprint) throw conflict("Skill idempotency key was used with different inputs");
|
||||
const skill = await service.getById(companyId, prior.entityId);
|
||||
@@ -1298,7 +1298,7 @@ export function companySkillRoutes(db: Db) {
|
||||
}
|
||||
let publication: ActivityPublication | null = null;
|
||||
let receipt: { skillId: string; path: string; versionId: string | null; studioPath: string } | null = null;
|
||||
await companySkillService(tx as unknown as Db).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
await companySkillService(tx as unknown as Db, { packageDb: db }).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
encoding: input.encoding, executable: input.executable, expectedVersionId,
|
||||
onRollback: (restore, versionId) => { rollbackState.current = { restore, versionId }; },
|
||||
afterUpdate: async (versionId) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await notifyChatCompanyConfiguration(tx, id);
|
||||
await chatCompanyDeletion.deleteCompanyData(tx, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, id);
|
||||
await notifyToolReviewChange(tx, id);
|
||||
await agentObservabilityService(tx as unknown as Db).removeForCompanyDeletion(id);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { verifyFileManifest } from "./file-manifest-cache.js";
|
||||
import { skillPackageStorageEnabled, skillPackageStore, skillVersionMetadataColumns, withSkillPackageReadScope, type StoredSkillVersion } from "./skill-package-store.js";
|
||||
import { skillPackageRuntime } from "./skill-package-runtime.js";
|
||||
import type { SkillPackageFileMapping } from "@paperclipai/db";
|
||||
import { ensureSkillCacheDirectory, touchSkillVersionCache, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { verifyExistingBundledSkillRelease } from "./bundled-skill-release-validation.js";
|
||||
@@ -24,7 +29,7 @@ import {
|
||||
companySkillTestInputs,
|
||||
companySkillTestRunTemplates,
|
||||
companySkillTestRuns,
|
||||
companySkillVersions, companySkillRepositorySnapshots,
|
||||
companySkillVersions, withDedicatedDbConnection,
|
||||
companySkills,
|
||||
costEvents,
|
||||
documents,
|
||||
@@ -406,6 +411,8 @@ type BundledSkillReleaseManifestEntry = {
|
||||
type CreateVersionOptions = {
|
||||
repositorySnapshotId?: string;
|
||||
repositorySkillPath?: string;
|
||||
packageSnapshotId?: string;
|
||||
packageFileMap?: SkillPackageFileMapping[] | null;
|
||||
fileInventory?: CompanySkillVersionFileInventoryEntry[];
|
||||
release?: { id: string; name: string; releasedAt: Date };
|
||||
updateCurrentVersion?: boolean;
|
||||
@@ -1929,8 +1936,9 @@ function serializeVersionFileInventory(
|
||||
}
|
||||
|
||||
function toCompanySkillVersion(row: CompanySkillVersionRow): CompanySkillVersion {
|
||||
const { packageSnapshotId: _package, packageFileMap: _map, migrationAt: _migration, ...publicRow } = row;
|
||||
return {
|
||||
...row,
|
||||
...publicRow,
|
||||
label: row.label ?? null,
|
||||
releaseId: row.releaseId ?? null,
|
||||
releaseName: row.releaseName ?? null,
|
||||
@@ -2964,7 +2972,10 @@ async function listLastEditorsBySkillId(
|
||||
return editors;
|
||||
}
|
||||
|
||||
export function companySkillService(db: Db, options: { actor?: AuthorizationActor } = {}) {
|
||||
export function companySkillService(db: Db, options: { actor?: AuthorizationActor; packageDb?: Db } = {}) {
|
||||
const packageDb = options.packageDb ?? db;
|
||||
const packages = skillPackageStore(packageDb);
|
||||
const packageRuntime = skillPackageRuntime(packageDb);
|
||||
const visibility = (database: DbOrTransaction = db) => skillAccessService(database as Db, options.actor);
|
||||
const folderSvc = folderService(db);
|
||||
const agents = agentService(db);
|
||||
@@ -3228,7 +3239,10 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
|
||||
await removeRuntimeSkillCache(resolveManagedSkillsRoot(companyId), skill.id, async () => {
|
||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||
await db.delete(companySkills).where(eq(companySkills.id, skill.id));
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx.delete(companySkills).where(eq(companySkills.id, skill.id));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3382,12 +3396,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
return readFullSkillRows(companyId);
|
||||
}
|
||||
|
||||
async function readFullSkillRows(companyId: string): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId)) return [];
|
||||
async function readFullSkillRows(companyId: string, keys?: Set<string>, unrestrictedOnly = false): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId) || keys?.size === 0) return [];
|
||||
const rows = await db
|
||||
.select(selectCompanySkillColumns())
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), await visibility().condition(companyId)))
|
||||
.where(and(eq(companySkills.companyId, companyId), keys ? inArray(companySkills.key, [...keys]) : undefined, unrestrictedOnly ? isNull(companySkills.accessSourceId) : await visibility().condition(companyId)))
|
||||
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
||||
return rows.map((row) => toCompanySkill(row));
|
||||
}
|
||||
@@ -3462,18 +3476,17 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
?? await getByKey(companyId, ref);
|
||||
}
|
||||
|
||||
async function readVersionMetadata(companyId: string, skillId: string, versionId: string): Promise<StoredSkillVersion | null> {
|
||||
if (db === packageDb) return packages.version(companyId, skillId, versionId);
|
||||
const [row] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId), eq(companySkillVersions.id, versionId)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function getVersion(companyId: string, skillId: string, versionId: string): Promise<CompanySkillVersion | null> {
|
||||
if (!await getById(companyId, skillId)) return null;
|
||||
const row = await db
|
||||
.select()
|
||||
.from(companySkillVersions)
|
||||
.where(and(
|
||||
eq(companySkillVersions.companyId, companyId),
|
||||
eq(companySkillVersions.companySkillId, skillId),
|
||||
eq(companySkillVersions.id, versionId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toCompanySkillVersion(row) : null;
|
||||
const row = await readVersionMetadata(companyId, skillId, versionId);
|
||||
return row ? toCompanySkillVersion({ ...row, fileInventory: await packages.versionFiles(row) }) : null;
|
||||
}
|
||||
|
||||
async function getCurrentVersion(skill: CompanySkill): Promise<CompanySkillVersion | null> {
|
||||
@@ -3543,14 +3556,15 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
if (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash && skill.currentVersionId) {
|
||||
const versionId = skill.currentVersionId;
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const version = await getVersion(companyId, skill.id, versionId);
|
||||
const version = await readVersionMetadata(companyId, skill.id, versionId);
|
||||
if (!version) throw unprocessable("Installed skill version is unavailable.");
|
||||
const sourceLocator = await materializeVersionSnapshot(companyId, skill, version);
|
||||
const audit = await auditInstalledSkillBytes({
|
||||
...skill,
|
||||
sourceType: "local_path",
|
||||
sourceLocator,
|
||||
fileInventory: await runtimeVersionInventory(companyId, version),
|
||||
fileInventory: version.repositorySnapshotId || version.packageSnapshotId
|
||||
? await packageRuntime.inventory(version) : await packages.versionFiles(version),
|
||||
});
|
||||
await persistAuditMetadata(skill, audit);
|
||||
return audit;
|
||||
@@ -3619,7 +3633,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const existingForks = await existingForkSummaries(companyId, skill.id, actor);
|
||||
const currentVersion = await getCurrentVersion(skill);
|
||||
const repositoryInventory = currentVersion?.repositorySnapshotId
|
||||
? await runtimeVersionInventory(companyId, currentVersion) : null;
|
||||
? await packageRuntime.inventory((await readVersionMetadata(companyId, skill.id, currentVersion.id))!) : null;
|
||||
return enrichSkill(
|
||||
repositoryInventory ? { ...skill, fileInventory: repositoryInventory.map(({ path, kind }) => ({ path, kind })) } : skill,
|
||||
usedByAgents.length,
|
||||
@@ -3671,11 +3685,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const skill = await getById(companyId, skillId);
|
||||
if (!skill) throw notFound("Skill not found");
|
||||
const rows = await db
|
||||
.select()
|
||||
.select(skillVersionMetadataColumns)
|
||||
.from(companySkillVersions)
|
||||
.where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId)))
|
||||
.orderBy(desc(companySkillVersions.revisionNumber));
|
||||
return rows.map((row) => toCompanySkillVersion(row));
|
||||
const result: CompanySkillVersion[] = [];
|
||||
for (const row of rows) result.push(toCompanySkillVersion({ ...row, fileInventory: await packages.versionFiles(row) }));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createVersion(
|
||||
@@ -3692,7 +3708,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
options.fileInventory ?? await collectVersionFileInventory(companyId, skill),
|
||||
);
|
||||
const previousVersion = options.fileInventory === undefined ? await getCurrentVersion(skill) : null;
|
||||
// Shipped release rows retain their existing validation contract. All user
|
||||
// snapshots use the package store; source imports prepare before their transaction.
|
||||
const packageSnapshotId = options.packageSnapshotId ?? (options.release || !skillPackageStorageEnabled() ? null : options.database
|
||||
? await withDedicatedDbConnection(packageDb, connection => skillPackageStore(connection).prepare(companyId, fileInventory))
|
||||
: await packages.prepare(companyId, fileInventory));
|
||||
const persist = async (tx: DbOrTransaction) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx.execute(sql`
|
||||
select ${companySkills.id}
|
||||
from ${companySkills}
|
||||
@@ -3713,12 +3735,15 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
companySkillId: skillId,
|
||||
repositorySnapshotId: options.repositorySnapshotId ?? previousVersion?.repositorySnapshotId ?? null,
|
||||
repositorySkillPath: options.repositorySkillPath ?? previousVersion?.repositorySkillPath ?? null,
|
||||
packageSnapshotId,
|
||||
migrationAt: packageSnapshotId || options.release ? null : new Date(),
|
||||
packageFileMap: options.packageFileMap ?? null,
|
||||
revisionNumber: Number(nextRevision ?? 1),
|
||||
label: input.label?.trim() || null,
|
||||
releaseId: options.release?.id ?? null,
|
||||
releaseName: options.release?.name ?? null,
|
||||
releasedAt: options.release?.releasedAt ?? null,
|
||||
fileInventory,
|
||||
fileInventory: packageSnapshotId ? [] : fileInventory,
|
||||
authorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
|
||||
authorUserId: actor?.type === "user" ? actor.userId ?? null : null,
|
||||
})
|
||||
@@ -3735,7 +3760,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
};
|
||||
const versionRow = options.database ? await persist(options.database) : await db.transaction(persist);
|
||||
if (!versionRow) throw notFound("Failed to persist skill version");
|
||||
return toCompanySkillVersion(versionRow);
|
||||
return toCompanySkillVersion({ ...versionRow, fileInventory });
|
||||
}
|
||||
|
||||
async function refreshStarCount(companyId: string, skillId: string) {
|
||||
@@ -4002,6 +4027,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
|
||||
async function cleanupFailedFork(companyId: string, sourceSkillId: string, forkSkillId: string, forkDir: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ currentVersionId: null, updatedAt: new Date() })
|
||||
@@ -4407,11 +4433,19 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
async function readLoadedSkillFile(skill: CompanySkill, relativePath: string): Promise<CompanySkillFileDetail> {
|
||||
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
|
||||
if (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash && skill.currentVersionId) {
|
||||
const version = await getCurrentVersion(skill);
|
||||
// Keep original package-relative reads working, and expose the repository
|
||||
// files from the same saved snapshot used by runtime delivery.
|
||||
const file = version?.fileInventory.find(entry => entry.path === normalizedPath)
|
||||
?? (version?.repositorySnapshotId ? (await runtimeVersionInventory(skill.companyId, version)).find(entry => entry.path === normalizedPath) : undefined);
|
||||
const version = await readVersionMetadata(skill.companyId, skill.id, skill.currentVersionId);
|
||||
let file: CompanySkillVersionFileInventoryEntry | null | undefined;
|
||||
if (version?.packageSnapshotId) {
|
||||
const mapping = version.packageFileMap?.find(file => file.path === normalizedPath);
|
||||
if (mapping) file = await packages.withPackage(skill.companyId, version.packageSnapshotId, async (root, manifest) => {
|
||||
const entry = manifest.files.find(file => file.path === mapping.sourcePath);
|
||||
if (!entry) return null;
|
||||
return { path: mapping.path, kind: mapping.kind, content: (await fs.readFile(path.join(root, entry.path))).toString(entry.encoding),
|
||||
encoding: entry.encoding, executable: entry.executable };
|
||||
});
|
||||
else if (!version.repositorySnapshotId) file = await packageRuntime.readFile(version, normalizedPath);
|
||||
} else if (version) file = (await packages.versionFiles(version)).find(file => file.path === normalizedPath);
|
||||
if (!file && version?.repositorySnapshotId) file = await packageRuntime.readFile(version, normalizedPath);
|
||||
if (!file) throw notFound("Installed skill snapshot is missing a file.");
|
||||
return { ...file, skillId: skill.id, language: file.encoding === "base64" ? null : inferLanguageFromPath(normalizedPath),
|
||||
markdown: file.encoding !== "base64" && isMarkdownPath(normalizedPath), editable: false };
|
||||
@@ -6018,15 +6052,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
}
|
||||
|
||||
async function runtimeVersionInventory(companyId: string, version: CompanySkillVersion) {
|
||||
if (!version.repositorySnapshotId) return version.fileInventory;
|
||||
const snapshotId = version.repositorySnapshotId;
|
||||
const [snapshot] = await measureRunPhase("skills.repository.read", () => db.select().from(companySkillRepositorySnapshots).where(and(
|
||||
eq(companySkillRepositorySnapshots.companyId, companyId), eq(companySkillRepositorySnapshots.id, snapshotId))));
|
||||
if (!snapshot || !version.repositorySkillPath) throw unprocessable("Installed repository snapshot is unavailable.");
|
||||
return repositorySkillProjection(snapshot.fileInventory, version.repositorySkillPath);
|
||||
if (!version.repositorySnapshotId) return version.fileInventory.length ? version.fileInventory
|
||||
: packages.versionFiles((await readVersionMetadata(companyId, version.companySkillId, version.id))!);
|
||||
if (!version.repositorySkillPath) throw unprocessable("Installed repository snapshot is unavailable.");
|
||||
return repositorySkillProjection(await packages.files(companyId, version.repositorySnapshotId), version.repositorySkillPath);
|
||||
}
|
||||
|
||||
async function materializeVersionSnapshot(companyId: string, skill: CompanySkill, version: CompanySkillVersion): Promise<string> {
|
||||
async function materializeVersionSnapshot(companyId: string, skill: CompanySkill, version: StoredSkillVersion): Promise<string> {
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const directory = await materializeVersionSnapshotLocked(companyId, skill, version);
|
||||
await touchSkillVersionCache(companyId, skill.id, version.id);
|
||||
@@ -6034,11 +6066,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeVersionSnapshotLocked(companyId: string, skill: CompanySkill, version: CompanySkillVersion): Promise<string> {
|
||||
version = { ...version, fileInventory: await runtimeVersionInventory(companyId, version) };
|
||||
async function materializeVersionSnapshotLocked(companyId: string, skill: CompanySkill, version: StoredSkillVersion): Promise<string> {
|
||||
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__versions__", skill.id);
|
||||
const skillDir = path.join(runtimeRoot, version.id);
|
||||
return runVersionSnapshotFlight(skillDir, async () => {
|
||||
if (version.repositorySnapshotId || version.packageSnapshotId) return packageRuntime.materialize(version, skillDir);
|
||||
version = { ...version, fileInventory: await packages.versionFiles(version) };
|
||||
await ensureSkillCacheDirectory(runtimeRoot, true);
|
||||
if (await measureRunPhase("skills.version.verify", () => materializedVersionSnapshotMatches(skillDir, version), { fileCount: version.fileInventory.length })) return skillDir;
|
||||
const staging = await fs.mkdtemp(path.join(runtimeRoot, `.${version.id}-`));
|
||||
@@ -6081,7 +6114,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
?? (options.snapshotLatest || (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash) || skill.accessSourceId ? skill.currentVersionId : null);
|
||||
if (selectedVersionId) {
|
||||
const versionPath = path.resolve(resolveManagedSkillsRoot(companyId), "__versions__", skill.id, selectedVersionId);
|
||||
const version = await measureRunPhase("skills.version.read", () => getVersion(companyId, skill.id, selectedVersionId));
|
||||
const version = await measureRunPhase("skills.version.read", () => readVersionMetadata(companyId, skill.id, selectedVersionId));
|
||||
if (!version) {
|
||||
return {
|
||||
status: "missing",
|
||||
@@ -6185,22 +6218,43 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
): Promise<T> {
|
||||
if (options.onlyKeys?.size === 0 || (!options.onlyKeys && options.selectionConfig && !options.includeLegacy
|
||||
&& readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills.length === 0)) return consume([]);
|
||||
await measureRunPhase("skills.inventory", () => ensureSkillInventoryCurrent(companyId));
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const entries = await listRuntimeSkillEntriesLocked(companyId, options);
|
||||
// Installed repository versions are immutable and already discovered. Do
|
||||
// not reconcile the entire library (bundled releases/folders/local paths)
|
||||
// before every turn. This probe caches no authority or mutable selection.
|
||||
const requested = options.onlyKeys ?? (options.selectionConfig && !options.includeLegacy
|
||||
? new Set(readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills) : null);
|
||||
const installed = requested?.size && visibility().canReadCompany(companyId) ? await db.select({
|
||||
key: companySkills.key, sourceType: companySkills.sourceType, versionId: companySkills.currentVersionId, accessSourceId: companySkills.accessSourceId,
|
||||
}).from(companySkills).where(and(eq(companySkills.companyId, companyId), inArray(companySkills.key, [...requested]))) : [];
|
||||
const preparedRepositorySelection = requested && installed.length === requested.size
|
||||
&& installed.every(skill => skill.sourceType === "github" && skill.versionId);
|
||||
if (!preparedRepositorySelection) await measureRunPhase("skills.inventory", () => ensureSkillInventoryCurrent(companyId));
|
||||
return withSkillPackageReadScope(db, () => withSkillVersionCacheLock(db, companyId, async () => {
|
||||
// Canonical keys already resolved by the fresh probe avoid listing all
|
||||
// unrelated library references. Scope is still enforced by the row query;
|
||||
// a public-to-project change between the two reads fails closed.
|
||||
const runtimeOptions = preparedRepositorySelection ? { ...options, onlyKeys: requested } : options;
|
||||
const entries = await listRuntimeSkillEntriesLocked(companyId, runtimeOptions,
|
||||
Boolean(preparedRepositorySelection && installed.every(skill => skill.accessSourceId === null)));
|
||||
return measureRunPhase("skills.consume", () => consume(entries));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function listRuntimeSkillEntriesLocked(
|
||||
companyId: string,
|
||||
options: RuntimeSkillEntryOptions,
|
||||
unrestrictedOnly = false,
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
const skills = await measureRunPhase("skills.rows", () => readFullSkillRows(companyId));
|
||||
const references = !options.onlyKeys && options.selectionConfig ? await listReferenceTargets(companyId) : [];
|
||||
const selectedKeys = options.onlyKeys ?? (options.selectionConfig ? new Set(
|
||||
(options.includeLegacy ? resolveLegacyPaperclipDesiredSkillNames : resolvePaperclipDesiredSkillNames)(
|
||||
options.selectionConfig, skills.map(skill => ({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug) }))),
|
||||
) : null);
|
||||
options.selectionConfig, references.map(skill => ({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug) }))),
|
||||
) : undefined);
|
||||
const skills = await measureRunPhase("skills.rows", () => readFullSkillRows(companyId, selectedKeys, unrestrictedOnly));
|
||||
await packages.prefetch(companyId, skills.flatMap(skill => {
|
||||
const id = options.versionSelections?.get(skill.key) ?? skill.currentVersionId;
|
||||
return id ? [id] : [];
|
||||
}));
|
||||
|
||||
const out: PaperclipSkillEntry[] = [];
|
||||
for (let skill of skills) {
|
||||
@@ -6209,7 +6263,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const unpinned = !options.versionSelections?.get(skill.key);
|
||||
const installedDirectory = options.snapshotLatest && unpinned && (skill.sourceType === "local_path" || skill.sourceType === "catalog")
|
||||
? await resolveExistingSkillDirectory(normalizeSkillDirectory(skill)) : null;
|
||||
if (options.snapshotLatest && unpinned && (!skill.currentVersionId || installedDirectory)) {
|
||||
const installedVersion = installedDirectory && skill.currentVersionId
|
||||
? await readVersionMetadata(companyId, skill.id, skill.currentVersionId) : null;
|
||||
const installedUnchanged = installedDirectory && installedVersion?.packageSnapshotId && !installedVersion.packageFileMap
|
||||
? await verifyFileManifest(installedDirectory, (await packages.manifest(companyId, installedVersion.packageSnapshotId)).files)
|
||||
: false;
|
||||
if (!installedUnchanged && options.snapshotLatest && unpinned && (!skill.currentVersionId || installedDirectory)) {
|
||||
// Capture installed bytes only. Older remote imports can have no
|
||||
// version yet; execution must not fetch their missing files upstream.
|
||||
const installed: RuntimeSkillSourceResolution | null = installedDirectory ? { status: "available", source: installedDirectory }
|
||||
@@ -6219,8 +6278,11 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
: skill.fileInventory.length === 1 && skill.fileInventory[0]?.path === "SKILL.md" && skill.markdown
|
||||
? [{ path: "SKILL.md", kind: "skill" as const, content: skill.markdown }] : null;
|
||||
if (inventory) {
|
||||
const previous = skill.currentVersionId ? await getVersion(companyId, skill.id, skill.currentVersionId) : null;
|
||||
if (!previous || skillSnapshotHash(previous.fileInventory) !== skillSnapshotHash(inventory)) {
|
||||
const previous = installedVersion ?? (skill.currentVersionId ? await readVersionMetadata(companyId, skill.id, skill.currentVersionId) : null);
|
||||
const previousHash = previous?.packageSnapshotId && !previous.packageFileMap
|
||||
? (await packages.descriptor(companyId, previous.packageSnapshotId)).contentHash
|
||||
: previous ? skillSnapshotHash(await packages.versionFiles(previous)) : null;
|
||||
if (previousHash !== skillSnapshotHash(inventory)) {
|
||||
const version = await createVersion(companyId, skill.id, { label: "Execution snapshot" }, { type: "system" },
|
||||
{ skipInventoryRefresh: true, skill, fileInventory: inventory });
|
||||
skill = { ...skill, currentVersionId: version.id };
|
||||
@@ -7366,6 +7428,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
}
|
||||
try {
|
||||
return await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await beforeDelete?.(tx);
|
||||
const deleted: CompanySkill[] = [];
|
||||
for (const before of initial) {
|
||||
|
||||
@@ -74,6 +74,8 @@ export const DELIVERY_QUEUES = {
|
||||
dotEvents: "dot-events",
|
||||
grokDispatch: "grok-dispatch",
|
||||
skillSourceSync: "skill-source-sync",
|
||||
skillPackages: "skill-packages",
|
||||
runtimeAssetRetention: "runtime-asset-retention",
|
||||
fastResponse: "fast-responses",
|
||||
feedback: "feedback-exports",
|
||||
chatCompletion: "chat-completions",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type FileManifestEntry = { path: string; size: number; sha256: string; executable: boolean; mode?: number };
|
||||
|
||||
// Cache only identities/digests, not file contents. ctime prevents an ordinary
|
||||
// writer from hiding a modification by restoring mtime. Every use still checks
|
||||
// paths, file types, count and mode; changed files are hashed again.
|
||||
const verifiedFiles = new Map<string, { signature: string; digest: string }>();
|
||||
const MAX_VERIFIED_FILES = 128_000;
|
||||
let activeFileIo = 0;
|
||||
const waitingFileIo: Array<() => void> = [];
|
||||
async function withFileIo<T>(action: () => Promise<T>): Promise<T> {
|
||||
if (activeFileIo >= 24) await new Promise<void>(resolve => waitingFileIo.push(resolve));
|
||||
else activeFileIo++;
|
||||
try { return await action(); }
|
||||
finally { const next = waitingFileIo.shift(); if (next) next(); else activeFileIo--; }
|
||||
}
|
||||
/** Rename preserves file identities; carry verification forward to avoid
|
||||
* re-reading every byte on the first warm turn after a cold materialization. */
|
||||
export function relocateFileManifestVerification(from: string, to: string, files: readonly FileManifestEntry[]) {
|
||||
for (const file of files) {
|
||||
const source = path.join(from, file.path);
|
||||
const verified = verifiedFiles.get(source);
|
||||
verifiedFiles.delete(source);
|
||||
if (verified) verifiedFiles.set(path.join(to, file.path), verified);
|
||||
}
|
||||
}
|
||||
export async function verifyFileManifest(directory: string, files: readonly FileManifestEntry[]): Promise<boolean> {
|
||||
const expected = new Map(files.map(file => [file.path, file]));
|
||||
const seen = new Set<string>();
|
||||
async function walk(relative: string): Promise<boolean> {
|
||||
const root = path.join(directory, relative);
|
||||
const rootStat = await fs.lstat(root);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return false;
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (let offset = 0; offset < entries.length; offset += 24) {
|
||||
const outcomes = await Promise.all(entries.slice(offset, offset + 24).map(async entry => {
|
||||
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) return walk(name);
|
||||
const file = expected.get(name);
|
||||
if (!file || !entry.isFile()) return false;
|
||||
seen.add(name);
|
||||
return withFileIo(async () => {
|
||||
const target = path.join(directory, name);
|
||||
const stat = await fs.lstat(target, { bigint: true });
|
||||
if (!stat.isFile() || stat.size !== BigInt(file.size) || Boolean(stat.mode & 0o111n) !== file.executable
|
||||
|| (file.mode !== undefined && Number(stat.mode & 0o555n) !== file.mode)) return false;
|
||||
const signature = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.mode}`;
|
||||
const previous = verifiedFiles.get(target);
|
||||
if (previous?.signature === signature && previous.digest === file.sha256) return true;
|
||||
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (before.ino !== stat.ino || before.ctimeNs !== stat.ctimeNs) return false;
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
|
||||
if (hash.digest("hex") !== file.sha256) return false;
|
||||
const after = await handle.stat({ bigint: true });
|
||||
if (after.ctimeNs !== before.ctimeNs || after.mtimeNs !== before.mtimeNs) return false;
|
||||
if (verifiedFiles.size >= MAX_VERIFIED_FILES) verifiedFiles.delete(verifiedFiles.keys().next().value!);
|
||||
verifiedFiles.set(target, { signature, digest: file.sha256 });
|
||||
return true;
|
||||
} finally { await handle.close(); }
|
||||
});
|
||||
}));
|
||||
if (outcomes.some(result => !result)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
try { return await walk("") && seen.size === expected.size; }
|
||||
catch (error) { if (["ENOENT", "ENOTDIR", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; throw error; }
|
||||
}
|
||||
@@ -105,6 +105,12 @@ async function scanRepository(input: SkillScanInput, providerRead: GitHubRead, o
|
||||
}
|
||||
const repositoryPaths = indexSkillPackagePaths(entries.map(entry => entry.path));
|
||||
const roots = entries.filter(e => e.type === 'blob' && /(^|\/)skill\.md$/i.test(e.path) && ['100644', '100755'].includes(e.mode));
|
||||
if (!roots.length) {
|
||||
return { treeSha: snapshot.treeSha, downloadBytes: snapshot.downloadBytes, scanBytes: 0,
|
||||
packageMode: input.packageMode, connectionId: providerRead.connectionId ?? null,
|
||||
repositoryId: String(repo.id), repositoryUrl: canonical.repositoryUrl, fullName: repo.full_name,
|
||||
trackingRef, commitSha: commit.sha, defaultBranch: repo.default_branch, candidates: [], warnings: [], skills: [] };
|
||||
}
|
||||
const rootsByDirectory = new Map<string, TreeEntry[]>();
|
||||
for (const root of roots) {
|
||||
const dir = path.posix.dirname(root.path);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "../skill-package-store.js";
|
||||
import { skillAccessService } from "../skill-access.js";
|
||||
import { buildReviewPathRecoveryDisposition } from "../recovery/review-path-recovery.js";
|
||||
import type { AuthorizationActor } from "../authorization.js";
|
||||
@@ -2959,7 +2960,7 @@ export function createHeartbeatRunPreparation(db: Db) {
|
||||
inputId: companySkillTestRuns.inputId,
|
||||
skillVersionId: companySkillTestRuns.skillVersionId,
|
||||
outputDocumentKey: companySkillTestRuns.outputDocumentKey,
|
||||
fileInventory: companySkillVersions.fileInventory,
|
||||
version: skillVersionMetadataColumns,
|
||||
revisionNumber: companySkillVersions.revisionNumber,
|
||||
label: companySkillVersions.label,
|
||||
})
|
||||
@@ -2979,8 +2980,9 @@ export function createHeartbeatRunPreparation(db: Db) {
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const fileInventory = Array.isArray(row.fileInventory)
|
||||
? row.fileInventory.flatMap((entry) => {
|
||||
const savedFiles = await skillPackageStore(db).versionFiles(row.version);
|
||||
const fileInventory = Array.isArray(savedFiles)
|
||||
? savedFiles.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
||||
return [];
|
||||
const record = entry as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -213,6 +213,11 @@ const WORK_CHECKS = [
|
||||
// label requires its completion stamp and a cleared failure reason.
|
||||
`SELECT 1 FROM feedback_exports WHERE (status = 'local_only'
|
||||
OR (status = 'sent' AND exported_at IS NOT NULL AND failure_reason IS NULL)) IS NOT TRUE`,
|
||||
// Conversion owes a verified durable copy before dropping legacy payloads.
|
||||
// Cache/deletion retention times are earliest eligibility, allowed to wait for
|
||||
// the next ingress wake; they are not a promised external alarm deadline.
|
||||
`SELECT 1 FROM company_skill_repository_snapshots WHERE migration_at IS NOT NULL`,
|
||||
`SELECT 1 FROM company_skill_versions WHERE migration_at IS NOT NULL`,
|
||||
`SELECT 1 FROM project_skill_sources b JOIN companies c ON c.id = b.company_id
|
||||
WHERE c.status = 'active' AND b.active AND b.source_id IS NULL`,
|
||||
`SELECT 1 FROM company_skill_sources s JOIN companies c ON c.id = s.company_id
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { materializeAsset } from "./runtime-context.js";
|
||||
import { removeRuntimeAssetTree, runtimeAssetRetention, RUNTIME_ASSET_RETENTION_MS } from "./runtime-asset-retention.js";
|
||||
|
||||
let home: string;
|
||||
const originalHome = process.env.PAPERCLIP_HOME;
|
||||
beforeEach(async () => { home = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-asset-gc-")); process.env.PAPERCLIP_HOME = home; });
|
||||
afterEach(async () => {
|
||||
await removeRuntimeAssetTree(home);
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
function database(rows: unknown[] = []) {
|
||||
const select = vi.fn(() => ({ from: () => ({ where: () => ({ limit: async () => rows }) }) }));
|
||||
return { db: { select } as unknown as Db, select };
|
||||
}
|
||||
async function age(digest: string) {
|
||||
const manifest = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${digest}.json`);
|
||||
const old = new Date(Date.now() - RUNTIME_ASSET_RETENTION_MS - 1_000);
|
||||
await fs.utimes(manifest, old, old);
|
||||
}
|
||||
describe("runtime asset retention", () => {
|
||||
it("has no database work when empty/fresh, removes expired copies and rebuilds the same digest", async () => {
|
||||
const { db, select } = database();
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
const files = [{ path: "SKILL.md", content: Buffer.from("immutable skill"), mode: 0o444 }];
|
||||
const asset = await materializeAsset(files);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeGreaterThan(Date.now());
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
await age(asset.digest);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await fs.stat(asset.rootPath).catch(() => null)).toBeNull();
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await materializeAsset(files)).toEqual(asset);
|
||||
});
|
||||
it("keeps files when an unfinished run has no trustworthy input", async () => {
|
||||
const asset = await materializeAsset([{ path: "SKILL.md", content: Buffer.from("pinned skill"), mode: 0o444 }]);
|
||||
await age(asset.digest);
|
||||
const { db } = database([{ id: "run", companyId: "company", agentId: "agent", input: null }]);
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect((await fs.stat(asset.rootPath)).isDirectory()).toBe(true);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
import { ensureSkillCacheDirectory, skillRunNeedsFilesSql } from "../skill-version-cache.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
|
||||
export const RUNTIME_ASSET_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const TOUCH_MS = 60 * 60_000;
|
||||
const touched = new Map<string, number>();
|
||||
const observers = new Set<() => void>();
|
||||
const root = () => path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
function paths(digest: string) {
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error("Invalid runtime asset digest");
|
||||
return { bundle: path.join(root(), "bundles", digest), manifest: path.join(root(), "manifests", `${digest}.json`) };
|
||||
}
|
||||
|
||||
/** Persist an hourly access lease, under the same filesystem lock as deletion.
|
||||
* Warm turns inside that lease add no I/O or database calls. The long grace also
|
||||
* protects the short interval between preparation and durable run pinning. */
|
||||
export async function withRuntimeAssetUse<T>(digest: string, consume: (locked: boolean) => Promise<T>, mutation = false): Promise<T> {
|
||||
const target = paths(digest);
|
||||
const last = touched.get(target.manifest);
|
||||
const fresh = last !== undefined && Date.now() - last < TOUCH_MS;
|
||||
if (fresh && !mutation) return consume(false);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
return withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const result = await consume(true);
|
||||
if (!fresh && (await fs.lstat(target.manifest).catch(() => null))?.isFile()) {
|
||||
const now = Date.now();
|
||||
await fs.utimes(target.manifest, new Date(now), new Date(now));
|
||||
if (touched.size >= 4096) touched.delete(touched.keys().next().value!);
|
||||
touched.set(target.manifest, now);
|
||||
for (const wake of observers) wake();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function protectedAssets(db: Db): Promise<Set<string> | null> {
|
||||
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
input: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'` }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.runtimeMode, "native"), skillRunNeedsFilesSql())).limit(1001);
|
||||
if (rows.length > 1000) return null;
|
||||
const pins = new Set<string>();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const input = parseNativeExecutionInput(row.input);
|
||||
if (input.binding.runId !== row.id || input.binding.companyId !== row.companyId || input.binding.agentId !== row.agentId) return null;
|
||||
if (!("runtimeContext" in input)) return null;
|
||||
for (const asset of [input.runtimeContext.instructions.bundle, ...input.runtimeContext.skills.map(skill => skill.bundle)]) pins.add(asset.digest);
|
||||
} catch { return null; } // An unfinished/legacy input cannot prove absence.
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
export async function removeRuntimeAssetTree(directory: string) {
|
||||
const stat = await fs.lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat) return;
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe runtime asset directory");
|
||||
await fs.chmod(directory, 0o700);
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await removeRuntimeAssetTree(path.join(directory, entry.name));
|
||||
else if (!entry.isFile()) throw new Error("Unsafe runtime asset entry");
|
||||
}
|
||||
await fs.rm(directory, { recursive: true });
|
||||
}
|
||||
|
||||
export function runtimeAssetRetention(db: Db) {
|
||||
let next: number | null = null;
|
||||
async function tick(signal: AbortSignal) {
|
||||
next = null;
|
||||
const directory = path.join(root(), "manifests");
|
||||
try { await ensureSkillCacheDirectory(directory); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const candidates: Array<{ digest: string; at: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
const manifests = await fs.readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return []; throw error;
|
||||
});
|
||||
for (const entry of manifests) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(directory, entry.name)).catch(() => null);
|
||||
if (!stat?.isFile()) continue;
|
||||
seen.add(entry.name.slice(0, -5));
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name.slice(0, -5), at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
// A crash between bundle rename and manifest publication can leave an
|
||||
// unreferenced bundle. The same age, lock and run-pin checks apply.
|
||||
const bundles = path.join(root(), "bundles");
|
||||
try {
|
||||
await ensureSkillCacheDirectory(bundles);
|
||||
for (const entry of await fs.readdir(bundles, { withFileTypes: true })) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name) || seen.has(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(bundles, entry.name)).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name, at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (!candidates.length) return; // No SQL merely to discover idle history.
|
||||
const pins = await protectedAssets(db);
|
||||
if (!pins) return; // Reconsider on the next asset use or startup, without polling.
|
||||
const eligible = candidates.filter(entry => !pins.has(entry.digest));
|
||||
for (const { digest } of eligible.slice(0, 32)) {
|
||||
signal.throwIfAborted();
|
||||
const target = paths(digest);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
await withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const stat = await fs.lstat(target.manifest).catch(() => null) ?? await fs.lstat(target.bundle).catch(() => null);
|
||||
if (!stat || (!stat.isFile() && !stat.isDirectory()) || stat.isSymbolicLink()) return;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at > Date.now()) { next = Math.min(next ?? Infinity, at); return; }
|
||||
await removeRuntimeAssetTree(target.bundle);
|
||||
await fs.rm(target.manifest, { force: true });
|
||||
touched.delete(target.manifest);
|
||||
});
|
||||
}
|
||||
if (eligible.length > 32) next = Math.min(next ?? Infinity, Date.now() + 1_000);
|
||||
}
|
||||
function start(coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">) {
|
||||
let requested = 0, completed = -1;
|
||||
const worker = coordinator.register(DELIVERY_QUEUES.runtimeAssetRetention, {
|
||||
retryMs: 5_000, pausePassiveMaintenance: true,
|
||||
async run(signal) { const generation = requested; await tick(signal); completed = generation; },
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => requested !== completed ? Date.now() + 5_000 : next,
|
||||
});
|
||||
const wake = () => { requested++; worker.wake(); };
|
||||
observers.add(wake);
|
||||
return { ...worker, async stop() { observers.delete(wake); await worker.stop(); } };
|
||||
}
|
||||
return { tick, start, nextRunAt: () => next };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -290,7 +291,11 @@ describe("buildNativeRuntimeContext", () => {
|
||||
};
|
||||
|
||||
const context = await buildNativeRuntimeContext(input);
|
||||
const reads = vi.spyOn(fs, "readFile");
|
||||
const repeated = await buildNativeRuntimeContext(input);
|
||||
expect(reads.mock.calls.some(([file]) => String(file).startsWith(selectedRoot)
|
||||
|| String(file).startsWith(context.skills[0]!.bundle.rootPath))).toBe(false);
|
||||
reads.mockRestore();
|
||||
|
||||
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
|
||||
expect(serviceMocks.getEffectiveToolAssignmentsForAgent).toHaveBeenCalledWith("company-1", "agent-1");
|
||||
@@ -322,6 +327,19 @@ describe("buildNativeRuntimeContext", () => {
|
||||
expect(repeated.aggregateDigest).toBe(context.aggregateDigest);
|
||||
expect(repeated.instructions.bundle.rootPath).toBe(context.instructions.bundle.rootPath);
|
||||
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
|
||||
await writeFile(path.join(selectedRoot, "references/checklist.md"), "- Verify edits\n");
|
||||
const edited = await buildNativeRuntimeContext(input);
|
||||
expect(edited.skills[0]!.bundle.digest).not.toBe(context.skills[0]!.bundle.digest);
|
||||
const corrupted = path.join(edited.skills[0]!.bundle.rootPath, "SKILL.md");
|
||||
await chmod(corrupted, 0o644);
|
||||
await writeFile(corrupted, "Corrupted saved asset");
|
||||
const repaired = await buildNativeRuntimeContext(input);
|
||||
expect(repaired.skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
await chmod(path.dirname(corrupted), 0o755);
|
||||
await fs.rm(corrupted);
|
||||
expect((await buildNativeRuntimeContext(input)).skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
});
|
||||
|
||||
it("does not materialize assigned skills or external MCP in preconfigured mode", async () => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { withRuntimeAssetUse, removeRuntimeAssetTree } from "./runtime-asset-retention.js";
|
||||
import { cachedSkillAsset, rememberSkillAsset } from "./skill-asset-cache.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { measureRunPhase } from "../execution-preparation-timing.js";
|
||||
import { spekoToolsForSession } from "../voice/speko-agent-tools.js";
|
||||
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
|
||||
@@ -83,14 +86,9 @@ async function verifyMaterializedAsset(
|
||||
rootPath: string,
|
||||
manifestFiles: Array<{ path: string; sha256: string; mode: number; size: number }>,
|
||||
): Promise<void> {
|
||||
const actual = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(rootPath));
|
||||
const actualByPath = new Map(actual.map((file) => [file.path, file]));
|
||||
if (actualByPath.size !== manifestFiles.length) throw new Error("runtime context asset file count mismatch");
|
||||
for (const expected of manifestFiles) {
|
||||
const file = actualByPath.get(expected.path);
|
||||
if (!file || file.content.byteLength !== expected.size || sha256(file.content) !== expected.sha256 || (file.mode & 0o555) !== expected.mode) {
|
||||
throw new Error(`runtime context asset digest mismatch: ${expected.path}`);
|
||||
}
|
||||
const expected = manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) }));
|
||||
if (!await measureRunPhase("runtime_context.asset_read", () => verifyFileManifest(rootPath, expected))) {
|
||||
throw new Error("runtime context asset digest mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,55 +103,62 @@ export async function materializeAsset(files: AssetFile[]): Promise<NativeRuntim
|
||||
const manifestDigest = sha256(manifestText);
|
||||
return { sorted, manifestFiles, totalBytes, assetDigest, manifestText, manifestDigest };
|
||||
});
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
if (existing !== null) {
|
||||
if (sha256(existing) !== manifestDigest || !(await fs.stat(rootPath).catch(() => null))?.isDirectory()) throw new Error(`runtime context asset verification failed: ${assetDigest}`);
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
return withRuntimeAssetUse(assetDigest, async function publishOrReuse(locked): Promise<NativeRuntimeAssetReference> {
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
const rootExists = (await fs.stat(rootPath).catch(() => null))?.isDirectory();
|
||||
if (existing !== null && rootExists && sha256(existing) === manifestDigest
|
||||
&& await verifyFileManifest(rootPath, manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) })))) {
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
if (!locked) return withRuntimeAssetUse(assetDigest, publishOrReuse, true);
|
||||
// The caller supplied the exact authoritative bytes for this digest. A missing
|
||||
// or damaged disposable copy can be rebuilt under the publication/GC lock.
|
||||
if (rootExists) await removeRuntimeAssetTree(rootPath);
|
||||
await fs.rm(manifestPath, { force: true });
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFiles = false) {
|
||||
@@ -180,7 +185,7 @@ async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFi
|
||||
return { entryPath, bundle: await materializeAsset(files) };
|
||||
}
|
||||
|
||||
async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
export async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter(
|
||||
(key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
);
|
||||
@@ -189,7 +194,17 @@ async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>,
|
||||
const entry = byKey.get(key);
|
||||
if (!entry) throw new Error(`assigned runtime skill is missing from the company library: ${key}`);
|
||||
if (entry.sourceStatus === "missing") throw new Error(entry.missingDetail ?? `assigned runtime skill source is missing: ${key}`);
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle: await materializeAsset(await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source))) };
|
||||
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(entry.source));
|
||||
if (!bundle) {
|
||||
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source));
|
||||
bundle = await materializeAsset(files);
|
||||
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
|
||||
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
|
||||
// Populate the stat/digest cache against these exact source bytes before
|
||||
// remembering the reference. A concurrent source edit cannot seed it.
|
||||
if (await verifyFileManifest(entry.source, manifest)) rememberSkillAsset(entry.source, manifest, bundle);
|
||||
}
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle };
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { withRuntimeAssetUse } from "./runtime-asset-retention.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { NativeRuntimeAssetReference } from "../../vendor/paperclip-runner/index.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
|
||||
type Cached = { manifest: FileManifestEntry[]; bundle: NativeRuntimeAssetReference };
|
||||
const entries = new Map<string, Cached>();
|
||||
let fileCount = 0;
|
||||
const MAX_FILES = 32_000;
|
||||
const MAX_ENTRIES = 128;
|
||||
const cacheKey = (source: string) => `${resolvePaperclipInstanceRoot()}:${source}`;
|
||||
|
||||
/** The caller supplies freshly authorized runtime entries on every turn. This
|
||||
* cache retains no authority or file bodies, and verifies source and destination
|
||||
* identities before reusing the content-addressed native asset reference. */
|
||||
export async function cachedSkillAsset(source: string): Promise<NativeRuntimeAssetReference | null> {
|
||||
const cached = entries.get(cacheKey(source));
|
||||
if (!cached || !await verifyFileManifest(source, cached.manifest)) return null;
|
||||
return withRuntimeAssetUse(cached.bundle.digest, async () => {
|
||||
const manifestPath = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${cached.bundle.digest}.json`);
|
||||
const manifest = await fs.readFile(manifestPath).catch(() => null);
|
||||
if (!manifest || !(await fs.lstat(cached.bundle.rootPath).catch(() => null))?.isDirectory()) return null;
|
||||
if (createHash("sha256").update(manifest).digest("hex") !== cached.bundle.manifestDigest
|
||||
|| !await verifyFileManifest(cached.bundle.rootPath, cached.manifest)) {
|
||||
return null;
|
||||
}
|
||||
return cached.bundle;
|
||||
});
|
||||
}
|
||||
|
||||
export function rememberSkillAsset(source: string, manifest: FileManifestEntry[], bundle: NativeRuntimeAssetReference) {
|
||||
if (manifest.length > MAX_FILES) return;
|
||||
const key = cacheKey(source);
|
||||
const previous = entries.get(key);
|
||||
if (previous) { fileCount -= previous.manifest.length; entries.delete(key); }
|
||||
while (entries.size && (fileCount + manifest.length > MAX_FILES || entries.size >= MAX_ENTRIES)) {
|
||||
const first = entries.keys().next().value!;
|
||||
fileCount -= entries.get(first)!.manifest.length;
|
||||
entries.delete(first);
|
||||
}
|
||||
entries.set(key, { manifest, bundle });
|
||||
fileCount += manifest.length;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, type Db } from "@paperclipai/db";
|
||||
import { ensureSkillCacheDirectory } from "./skill-version-cache.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_PACKAGES = 64;
|
||||
const used = new Map<string, number>();
|
||||
export function touchSkillPackage(directory: string) {
|
||||
if (used.size >= 4096) used.delete(used.keys().next().value!);
|
||||
used.set(directory, Date.now());
|
||||
}
|
||||
|
||||
/** Called only after a cold fill, under the company cache lock. Never on a warm
|
||||
* check. Version projections and run pins have their own existing retention. */
|
||||
export async function trimSkillPackageCache(db: Db, companyId: string, directory: string,
|
||||
verifyDurable: (id: string, signal: AbortSignal) => Promise<boolean>) {
|
||||
const parent = path.dirname(directory);
|
||||
await ensureSkillCacheDirectory(parent);
|
||||
const contents = await fs.readdir(parent, { withFileTypes: true });
|
||||
for (const entry of contents) {
|
||||
if (!/^\.(package|upload|verify)-[a-zA-Z0-9]{6}(\.tar\.gz)?$/.test(entry.name)) continue;
|
||||
const target = path.join(parent, entry.name);
|
||||
// Every I/O attempt has a three-minute deadline; leave a much longer crash
|
||||
// grace so housekeeping cannot race a live publisher in another process.
|
||||
if (Date.now() - (await fs.lstat(target)).mtimeMs > 60 * 60_000) await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
const names = contents.filter(entry => entry.isDirectory() && /^[a-f0-9-]{36}$/i.test(entry.name)).map(entry => entry.name);
|
||||
if (!names.length) return;
|
||||
const rows = await db.select({ id: snapshots.id, storage: snapshots.storage }).from(snapshots)
|
||||
.where(and(eq(snapshots.companyId, companyId), inArray(snapshots.id, names)));
|
||||
const byId = new Map(rows.map(row => [row.id, row.storage]));
|
||||
const entries = await Promise.all(names.map(async id => {
|
||||
const location = path.join(parent, id);
|
||||
const receipt = byId.get(id);
|
||||
return { id, location, receipt, lastUsed: used.get(location) ?? (await fs.lstat(location)).mtimeMs,
|
||||
// Include an upper bound on block rounding and directory/manifest overhead.
|
||||
bytes: receipt ? receipt.totalBytes + receipt.fileCount * 8192 : 64 * 1024 * 1024 };
|
||||
}));
|
||||
let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
|
||||
let count = entries.length;
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
let attempted = 0;
|
||||
for (const entry of entries.sort((a, b) => a.lastUsed - b.lastUsed)) {
|
||||
if (bytes <= MAX_BYTES && count <= MAX_PACKAGES) break;
|
||||
if (signal.aborted || attempted >= 2) break;
|
||||
if (entry.location === directory) continue;
|
||||
attempted++;
|
||||
// A storage outage must not discard the last recoverable copy. Keep the
|
||||
// excess until the next cold fill can confirm a durable copy independently.
|
||||
if (byId.has(entry.id) && !await verifyDurable(entry.id, signal)) break;
|
||||
await fs.rm(entry.location, { recursive: true, force: true });
|
||||
await fs.rm(path.join(parent, `${entry.id}.json`), { force: true });
|
||||
used.delete(entry.location);
|
||||
bytes -= entry.bytes;
|
||||
count--;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip, createGunzip } from "node:zlib";
|
||||
import { extract, pack } from "tar-stream";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { assertSkillSnapshotPath, skillFileBytes, skillSnapshotHash } from "./skill-snapshot.js";
|
||||
|
||||
export const MAX_SKILL_PACKAGE_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_SKILL_PACKAGE_FILES = 10_000;
|
||||
export const MAX_SKILL_MANIFEST_BYTES = 48 * 1024 * 1024;
|
||||
export type SkillPackageFile = {
|
||||
path: string;
|
||||
kind: CompanySkillVersionFileInventoryEntry["kind"];
|
||||
encoding: "utf8" | "base64";
|
||||
executable: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
};
|
||||
export type SkillPackageManifest = {
|
||||
format: "paperclip-skill-package/v1";
|
||||
contentHash: string;
|
||||
totalBytes: number;
|
||||
files: SkillPackageFile[];
|
||||
};
|
||||
const kinds = new Set(["skill", "markdown", "reference", "script", "asset", "other"]);
|
||||
export const packageSha256 = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
export function validateSkillPackageManifest(value: unknown): SkillPackageManifest {
|
||||
const manifest = value as SkillPackageManifest;
|
||||
if (!manifest || manifest.format !== "paperclip-skill-package/v1" || !/^[a-f0-9]{64}$/.test(manifest.contentHash)
|
||||
|| !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_SKILL_PACKAGE_FILES) {
|
||||
throw new Error("Invalid skill package manifest");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
let total = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSkillSnapshotPath(file.path);
|
||||
if (file.path.length > 4096 || file.path.split("/").length > 64 || paths.has(file.path)
|
||||
|| !kinds.has(file.kind) || !["utf8", "base64"].includes(file.encoding) || typeof file.executable !== "boolean"
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error("Invalid skill package file");
|
||||
paths.add(file.path);
|
||||
total += file.size;
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
let parent = path.posix.dirname(file.path);
|
||||
while (parent !== ".") {
|
||||
if (paths.has(parent)) throw new Error("Conflicting skill package paths");
|
||||
parent = path.posix.dirname(parent);
|
||||
}
|
||||
}
|
||||
if (total !== manifest.totalBytes || total > MAX_SKILL_PACKAGE_BYTES) throw new Error("Skill package exceeds its byte bound");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function skillPackageManifest(files: CompanySkillVersionFileInventoryEntry[]): SkillPackageManifest {
|
||||
return validateSkillPackageManifest({
|
||||
format: "paperclip-skill-package/v1", contentHash: skillSnapshotHash(files),
|
||||
totalBytes: files.reduce((sum, file) => sum + skillFileBytes(file).length, 0),
|
||||
files: files.map(file => {
|
||||
const bytes = skillFileBytes(file);
|
||||
return { path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable), size: bytes.length, sha256: packageSha256(bytes) };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** JSONB reorders object keys. Preserve the archive manifest's v1 wire encoding
|
||||
* when a recovery recipe is read back from PostgreSQL. Array order is retained. */
|
||||
export function skillPackageManifestBytes(manifest: SkillPackageManifest): Buffer {
|
||||
return Buffer.from(JSON.stringify({ format: manifest.format, contentHash: manifest.contentHash, totalBytes: manifest.totalBytes,
|
||||
files: manifest.files.map(file => ({ path: file.path, kind: file.kind, encoding: file.encoding,
|
||||
executable: file.executable, size: file.size, sha256: file.sha256 })) }));
|
||||
}
|
||||
|
||||
export function boundedHashStream(maxBytes: number) {
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
const stream = new Transform({ transform(chunk: Buffer, _encoding, done) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > maxBytes) return done(new Error("Skill package stream exceeds its byte bound"));
|
||||
hash.update(chunk);
|
||||
done(null, chunk);
|
||||
} });
|
||||
return { stream, result: () => ({ bytes, sha256: hash.digest("hex") }) };
|
||||
}
|
||||
|
||||
export async function writeSkillPackageArchive(filename: string, files: CompanySkillVersionFileInventoryEntry[], signal?: AbortSignal) {
|
||||
const archive = pack();
|
||||
const digest = boundedHashStream(MAX_SKILL_PACKAGE_BYTES + MAX_SKILL_MANIFEST_BYTES);
|
||||
const writing = pipeline(archive, createGzip({ level: 3 }), digest.stream, createWriteStream(filename, { flags: "wx", mode: 0o600 }), { signal });
|
||||
// Attach a handler before producing entries so an early filesystem failure is observed.
|
||||
void writing.catch(() => {});
|
||||
try {
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => archive.entry({
|
||||
name: `files/${assertSkillSnapshotPath(file.path)}`, type: "file", mode: file.executable ? 0o555 : 0o444,
|
||||
mtime: new Date(0), uid: 0, gid: 0,
|
||||
}, skillFileBytes(file), error => error ? reject(error) : resolve()));
|
||||
}
|
||||
archive.finalize();
|
||||
await writing;
|
||||
return digest.result();
|
||||
} catch (error) {
|
||||
archive.destroy(error as Error);
|
||||
await writing.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractSkillPackageArchive(filename: string, directory: string, manifest: SkillPackageManifest, signal?: AbortSignal) {
|
||||
const archive = extract();
|
||||
const expected = new Map(manifest.files.map(file => [`files/${file.path}`, file]));
|
||||
const expanded = boundedHashStream(MAX_SKILL_PACKAGE_BYTES + MAX_SKILL_PACKAGE_FILES * 8192);
|
||||
const reading = pipeline(createReadStream(filename), createGunzip(), expanded.stream, archive, { signal });
|
||||
void reading.catch(() => {});
|
||||
try {
|
||||
for await (const entry of archive) {
|
||||
signal?.throwIfAborted();
|
||||
const file = expected.get(entry.header.name);
|
||||
if (!file || entry.header.type !== "file" || entry.header.size !== file.size
|
||||
|| entry.header.mode !== (file.executable ? 0o555 : 0o444)) throw new Error("Unexpected skill package archive entry");
|
||||
expected.delete(entry.header.name);
|
||||
const target = path.join(directory, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const digest = boundedHashStream(file.size);
|
||||
const output = await fs.open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, file.executable ? 0o555 : 0o444);
|
||||
await pipeline(entry, digest.stream, output.createWriteStream(), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== file.size || actual.sha256 !== file.sha256) throw new Error("Skill package file checksum mismatch");
|
||||
}
|
||||
await reading;
|
||||
if (expected.size) throw new Error("Skill package archive is incomplete");
|
||||
} catch (error) {
|
||||
archive.destroy(error as Error);
|
||||
await reading.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { verifyFileManifest as verifySkillPackageDirectory, relocateFileManifestVerification as relocateSkillPackageVerification } from "./file-manifest-cache.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
import path from "node:path";
|
||||
import { and, asc, eq, gt, isNull, lte, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions as versions,
|
||||
skillPackageDeletions, type Db } from "@paperclipai/db";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
|
||||
import { packageSha256 } from "./skill-package-files.js";
|
||||
import { skillFileBytes, skillSnapshotHash } from "./skill-snapshot.js";
|
||||
|
||||
type PageOptions = { id?: string; signal?: AbortSignal; companyId?: string; after?: string; limit?: number; apply?: boolean; clearLegacy?: boolean };
|
||||
const pageSize = (options: PageOptions) => Math.min(50, Math.max(1, options.limit ?? 10));
|
||||
|
||||
/** Bounded maintenance shared by automatic delivery and optional operator diagnostics. */
|
||||
export function skillPackageMaintenance(db: Db, storage?: StorageService) {
|
||||
const store = skillPackageStore(db, storage);
|
||||
async function migrateSnapshots(options: PageOptions = {}) {
|
||||
const rows = await db.select({ id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash })
|
||||
.from(snapshots).where(and(options.companyId ? eq(snapshots.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(snapshots.id, options.id) : undefined,
|
||||
options.after ? gt(snapshots.id, options.after) : undefined, options.clearLegacy ? undefined : isNull(snapshots.storage)))
|
||||
.orderBy(asc(snapshots.id)).limit(pageSize(options));
|
||||
let migrated = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
// Transfer only one legacy body at a time. Never trust a mutable upstream.
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(eq(snapshots.id, row.id));
|
||||
options.signal?.throwIfAborted();
|
||||
if (!legacy?.files.length) {
|
||||
await db.update(snapshots).set({ migrationAt: null }).where(eq(snapshots.id, row.id));
|
||||
continue;
|
||||
}
|
||||
if (skillSnapshotHash(legacy.files) !== row.contentHash) throw new Error(`Legacy package hash mismatch: ${row.id}`);
|
||||
const id = await store.prepare(row.companyId, legacy.files, true, options.signal);
|
||||
if (id !== row.id || !await store.verifyDurable(row.companyId, id, options.signal)) throw new Error(`Package verification failed: ${row.id}`);
|
||||
// An ambiguous result is safe to retry: storage is already published, IDs
|
||||
// are unchanged, and the old bytes survive until this final update.
|
||||
if (options.clearLegacy) await db.update(snapshots).set({ fileInventory: [], migrationAt: null }).where(and(eq(snapshots.id, id), eq(snapshots.contentHash, row.contentHash), sql`${snapshots.storage} is not null`));
|
||||
migrated++;
|
||||
}
|
||||
return { inspected: rows.length, migrated, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function migrateVersions(options: PageOptions = {}) {
|
||||
const rows = await db.select(skillVersionMetadataColumns).from(versions).where(and(
|
||||
options.companyId ? eq(versions.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(versions.id, options.id) : undefined,
|
||||
options.after ? gt(versions.id, options.after) : undefined, options.clearLegacy ? undefined : isNull(versions.packageSnapshotId), isNull(versions.releaseId)))
|
||||
.orderBy(asc(versions.id)).limit(pageSize(options));
|
||||
let migrated = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
const [legacy] = await db.select({ files: versions.fileInventory }).from(versions).where(eq(versions.id, row.id));
|
||||
const files = legacy?.files;
|
||||
options.signal?.throwIfAborted();
|
||||
if (!files?.length) {
|
||||
await db.update(versions).set({ migrationAt: null }).where(eq(versions.id, row.id));
|
||||
continue;
|
||||
}
|
||||
if (row.repositorySnapshotId) await migrateSnapshots({ id: row.repositorySnapshotId, apply: true, clearLegacy: true, signal: options.signal });
|
||||
let packageId = row.packageSnapshotId;
|
||||
let fileMap = row.packageFileMap;
|
||||
if (packageId && skillSnapshotHash(await store.versionFiles(row)) !== skillSnapshotHash(files)) throw new Error(`Legacy version differs from its package: ${row.id}`);
|
||||
if (!packageId && row.repositorySnapshotId && row.repositorySkillPath) {
|
||||
const manifest = await store.manifest(row.companyId, row.repositorySnapshotId);
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
const mappings = files.map(file => ({ path: file.path, kind: file.kind,
|
||||
sourcePath: file.path === "SKILL.md" ? row.repositorySkillPath! : path.posix.join(path.posix.dirname(row.repositorySkillPath!), file.path) }));
|
||||
if (files.every((file, index) => {
|
||||
const source = byPath.get(mappings[index]!.sourcePath);
|
||||
return source?.sha256 === packageSha256(skillFileBytes(file)) && source.executable === Boolean(file.executable);
|
||||
})) { packageId = row.repositorySnapshotId; fileMap = mappings; }
|
||||
}
|
||||
packageId ??= await store.prepare(row.companyId, files, true, options.signal);
|
||||
const receipt = await store.descriptor(row.companyId, packageId);
|
||||
if (!receipt.storage || !await store.verifyDurable(row.companyId, packageId, options.signal)) throw new Error(`Package must be migrated and verified first: ${packageId}`);
|
||||
await db.update(versions).set({ packageSnapshotId: packageId, packageFileMap: fileMap, ...(options.clearLegacy ? { fileInventory: [], migrationAt: null } : {}) })
|
||||
.where(and(eq(versions.companyId, row.companyId), eq(versions.id, row.id), row.packageSnapshotId ? eq(versions.packageSnapshotId, row.packageSnapshotId) : isNull(versions.packageSnapshotId)));
|
||||
migrated++;
|
||||
}
|
||||
return { inspected: rows.length, migrated, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function collectOrphans(options: PageOptions = {}) {
|
||||
const unused = sql`not exists (select 1 from ${versions} where ${versions.repositorySnapshotId} = ${snapshots.id} or ${versions.packageSnapshotId} = ${snapshots.id})`;
|
||||
const rows = await db.select({ id: snapshots.id }).from(snapshots).where(and(
|
||||
options.companyId ? eq(snapshots.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(snapshots.id, options.id) : undefined,
|
||||
options.after ? gt(snapshots.id, options.after) : undefined,
|
||||
sql`${snapshots.createdAt} < now() - interval '1 day'`,
|
||||
sql`(${snapshots.upload} is null or (${snapshots.upload}->>'expiresAt')::timestamptz < now())`, unused,
|
||||
)).orderBy(asc(snapshots.id)).limit(pageSize(options));
|
||||
let removed = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
// FK locking wins against a concurrent publication. The trigger persists
|
||||
// deletion receipts in the same transaction, including company cascades.
|
||||
const deleted = await db.delete(snapshots).where(and(eq(snapshots.id, row.id), unused)).returning({ id: snapshots.id });
|
||||
removed += deleted.length;
|
||||
}
|
||||
return { inspected: rows.length, removed, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function deleteObjects(options: PageOptions = {}) {
|
||||
const backend = storage ?? getStorageService();
|
||||
const rows = await db.select().from(skillPackageDeletions).where(and(
|
||||
options.companyId ? eq(skillPackageDeletions.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(skillPackageDeletions.id, options.id) : undefined,
|
||||
options.after ? gt(skillPackageDeletions.id, options.after) : undefined,
|
||||
lte(skillPackageDeletions.deleteAfter, new Date()), eq(skillPackageDeletions.provider, backend.provider),
|
||||
)).orderBy(asc(skillPackageDeletions.id)).limit(pageSize(options));
|
||||
let removed = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
options.signal?.throwIfAborted();
|
||||
const [referenced] = await db.select({ id: snapshots.id }).from(snapshots).where(and(eq(snapshots.companyId, row.companyId),
|
||||
sql`(${snapshots.storage}->>'archiveKey' = ${row.archiveKey} or ${snapshots.upload}->>'archiveKey' = ${row.archiveKey})`)).limit(1);
|
||||
if (referenced) {
|
||||
// Live ownership supersedes this receipt. A future replacement/deletion
|
||||
// enqueues fresh work in the same transaction as the ownership change.
|
||||
await db.delete(skillPackageDeletions).where(eq(skillPackageDeletions.id, row.id));
|
||||
continue;
|
||||
}
|
||||
for (const key of [row.archiveKey, row.manifestKey]) {
|
||||
await backend.deleteObject(row.companyId, key);
|
||||
if ((await backend.headObject(row.companyId, key)).exists) throw new Error(`Storage did not confirm package deletion: ${row.id}`);
|
||||
}
|
||||
await db.delete(skillPackageDeletions).where(eq(skillPackageDeletions.id, row.id));
|
||||
removed++;
|
||||
}
|
||||
return { inspected: rows.length, removed, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
return { migrateSnapshots, migrateVersions, collectOrphans, deleteObjects };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { recoverSkillPackage } from "./skill-package-recovery.js";
|
||||
import { skillPackageManifest } from "./skill-package-files.js";
|
||||
import type { GitHubRead } from "./github-skill-source.js";
|
||||
import type { SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
|
||||
function fixture() {
|
||||
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "---\nname: original\n---\nold commit" },
|
||||
{ path: "run.sh", kind: "script" as const, content: "echo original", executable: true }];
|
||||
const recipe: SkillPackageRestoreRecipe = { version: 1, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: "skills/one/skill.md", manifest: skillPackageManifest(files) };
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: `skills/one/${i ? "run.sh" : "skill.md"}`, type: "blob", mode: file.executable ? "100755" : "100644", sha: String(i).repeat(40), size: file.size }));
|
||||
const release = vi.fn(async () => {});
|
||||
const readBlob = vi.fn(async (entry: typeof entries[number]) => Buffer.from(files[entries.indexOf(entry)]!.content));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), {
|
||||
openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries, readBlob, release })),
|
||||
}) as unknown as GitHubRead;
|
||||
return { files, recipe, read, entries, readBlob, release };
|
||||
}
|
||||
describe("immutable skill package recovery", () => {
|
||||
it("reads the original manifest at the exact commit, including normalized entrypoint and modes", async () => {
|
||||
const f = fixture();
|
||||
expect(await recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).toEqual(f.files);
|
||||
expect(f.read.openSnapshot).toHaveBeenCalledWith({ repositoryUrl: f.recipe.repositoryUrl, ref: f.recipe.commitSha, commitSha: f.recipe.commitSha }, expect.anything());
|
||||
expect(f.read).toHaveBeenCalledExactlyOnceWith("/repos/acme/skills", expect.any(AbortSignal));
|
||||
expect(f.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects reused repository names, wrong commits, changed contents and missing files", async () => {
|
||||
const f = fixture();
|
||||
vi.mocked(f.read).mockResolvedValueOnce({ id: 999 });
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("not the original");
|
||||
expect(f.read.openSnapshot).not.toHaveBeenCalled();
|
||||
f.readBlob.mockResolvedValueOnce(Buffer.from("wrong"));
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("checksum mismatch");
|
||||
f.entries.pop();
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("unavailable");
|
||||
expect(f.release).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { companySkillSources, type Db, type SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertSkillSnapshotPath } from "./skill-snapshot.js";
|
||||
import { packageSha256, skillPackageManifest, validateSkillPackageManifest } from "./skill-package-files.js";
|
||||
import type { GitHubRead } from "./github-skill-source.js";
|
||||
|
||||
/** Recheck the source's saved identity and current grant. Never search another
|
||||
* company's credentials or turn a mutable branch name into a historical commit. */
|
||||
export async function skillPackageRecoveryReader(db: Db, companyId: string, recipe: SkillPackageRestoreRecipe) {
|
||||
const [source] = await db.select().from(companySkillSources).where(and(
|
||||
eq(companySkillSources.companyId, companyId), eq(companySkillSources.id, recipe.sourceId)));
|
||||
if (!source || source.removedAt || !source.enabled || source.repositoryId !== recipe.repositoryId
|
||||
|| source.repositoryUrl.toLowerCase() !== recipe.repositoryUrl.toLowerCase()) {
|
||||
throw forbidden("This skill package is no longer cached. Reconnect its original repository to restore it.");
|
||||
}
|
||||
// Deferred import avoids making the package store own source synchronization.
|
||||
const { skillSourceSetupContext } = await import("./skill-source-sync.js");
|
||||
const context = await skillSourceSetupContext(db, source);
|
||||
await context.authorize("skills.update", { sourceType: "git", sourceLocator: recipe.repositoryUrl });
|
||||
return context.read(source.connectionId);
|
||||
}
|
||||
|
||||
export async function recoverSkillPackage(recipe: SkillPackageRestoreRecipe, read: GitHubRead, signal: AbortSignal): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
if (recipe.version !== 1 || !/^[a-f0-9]{40}$/i.test(recipe.commitSha)) throw new Error("Invalid immutable skill package recipe");
|
||||
const manifest = validateSkillPackageManifest(recipe.manifest);
|
||||
const url = new URL(recipe.repositoryUrl);
|
||||
if (url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password
|
||||
|| !/^\/[^/]+\/[^/]+$/.test(url.pathname) || url.search || url.hash) throw new Error("Invalid skill repository identity");
|
||||
const repository = await read(`/repos${url.pathname}`, signal) as { id?: number | string };
|
||||
if (String(repository.id) !== recipe.repositoryId) throw new Error("The repository at this URL is not the original skill repository");
|
||||
const snapshot = await read.openSnapshot({ repositoryUrl: recipe.repositoryUrl, ref: recipe.commitSha, commitSha: recipe.commitSha }, { signal });
|
||||
try {
|
||||
if (snapshot.commitSha !== recipe.commitSha) throw new Error("Git returned a different skill commit");
|
||||
const byPath = new Map(snapshot.entries.map(entry => [entry.path, entry]));
|
||||
if (recipe.skillPath) assertSkillSnapshotPath(recipe.skillPath);
|
||||
const entries = manifest.files.map(file => {
|
||||
const sourcePath = recipe.skillPath
|
||||
? file.path === "SKILL.md" ? recipe.skillPath : path.posix.join(path.posix.dirname(recipe.skillPath), file.path)
|
||||
: file.path;
|
||||
const entry = byPath.get(assertSkillSnapshotPath(sourcePath));
|
||||
if (!entry || entry.type !== "blob" || entry.mode !== (file.executable ? "100755" : "100644") || entry.size !== file.size) {
|
||||
throw new Error(`The original skill file is unavailable: ${sourcePath}`);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
const blobs = snapshot.readBlobs ? await snapshot.readBlobs(entries, signal) : null;
|
||||
const files: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
signal.throwIfAborted();
|
||||
const expected = manifest.files[index]!;
|
||||
const bytes = blobs?.get(entry.sha) ?? await snapshot.readBlob(entry, signal);
|
||||
if (bytes.length !== expected.size || packageSha256(bytes) !== expected.sha256) throw new Error(`Restored skill file checksum mismatch: ${expected.path}`);
|
||||
files.push({ path: expected.path, kind: expected.kind, content: bytes.toString(expected.encoding),
|
||||
...(expected.encoding === "base64" ? { encoding: expected.encoding } : {}), ...(expected.executable ? { executable: true } : {}) });
|
||||
}
|
||||
if (skillPackageManifest(files).contentHash !== manifest.contentHash) throw new Error("Restored skill package checksum mismatch");
|
||||
return files;
|
||||
} finally { await snapshot.release(); }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { skillPackageStore, type StoredSkillVersion } from "./skill-package-store.js";
|
||||
import { packageSha256, verifySkillPackageDirectory, relocateSkillPackageVerification, type SkillPackageFile, type SkillPackageManifest } from "./skill-package-files.js";
|
||||
import { REPOSITORY_CONTENT_DIR, repositorySkillProjection } from "./skill-repository-package.js";
|
||||
import { assertSkillSnapshotPath, skillFileBytes } from "./skill-snapshot.js";
|
||||
import { ensureSkillCacheDirectory } from "./skill-version-cache.js";
|
||||
|
||||
type ProjectedFile = SkillPackageFile & { sourcePath?: string; generated?: Buffer };
|
||||
// Only the wrapper/policy bytes and file metadata are retained, never a decoded repository.
|
||||
const projections = new Map<string, { files: ProjectedFile[]; bytes: number }>();
|
||||
let projectionBytes = 0;
|
||||
const MAX_PROJECTION_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_PROJECTIONS = 256;
|
||||
const repositoryFiles = new WeakMap<SkillPackageManifest, ProjectedFile[]>();
|
||||
function originalFiles(version: StoredSkillVersion, manifest: SkillPackageManifest): ProjectedFile[] {
|
||||
if (version.repositorySnapshotId) {
|
||||
let shared = repositoryFiles.get(manifest);
|
||||
if (!shared) {
|
||||
shared = manifest.files.map(file => ({ ...file, sourcePath: file.path, path: `${REPOSITORY_CONTENT_DIR}/${file.path}` }));
|
||||
repositoryFiles.set(manifest, shared);
|
||||
}
|
||||
return [...shared];
|
||||
}
|
||||
if (!version.packageFileMap) return manifest.files.map(file => ({ ...file, sourcePath: file.path }));
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
return version.packageFileMap.map(mapping => {
|
||||
assertSkillSnapshotPath(mapping.path);
|
||||
assertSkillSnapshotPath(mapping.sourcePath);
|
||||
const file = byPath.get(mapping.sourcePath);
|
||||
if (!file) throw new Error("Skill version references a missing package file");
|
||||
return { ...file, ...mapping };
|
||||
});
|
||||
}
|
||||
export function skillPackageRuntime(db: Db) {
|
||||
const store = skillPackageStore(db);
|
||||
async function inventory(version: StoredSkillVersion) {
|
||||
const id = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
if (!id) throw new Error("Skill version has no package");
|
||||
const manifest = await store.manifest(version.companyId, id);
|
||||
const files = originalFiles(version, manifest).map(({ path, kind }) => ({ path, kind }));
|
||||
if (version.repositorySnapshotId) {
|
||||
files.unshift({ path: "SKILL.md", kind: "skill" });
|
||||
const policy = path.posix.join(path.posix.dirname(version.repositorySkillPath!), "agents/openai.yaml");
|
||||
const file = manifest.files.find(file => file.path === policy);
|
||||
if (file) files.push({ path: "agents/openai.yaml", kind: file.kind });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
async function projection(version: StoredSkillVersion): Promise<ProjectedFile[]> {
|
||||
const id = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
if (!id) throw new Error("Skill version has no package");
|
||||
const manifest = await store.manifest(version.companyId, id);
|
||||
const key = `${version.companyId}:${version.id}:${manifest.contentHash}`;
|
||||
const files = originalFiles(version, manifest);
|
||||
const cached = projections.get(key);
|
||||
if (cached) return [...files, ...cached.files];
|
||||
const generatedFiles: ProjectedFile[] = [];
|
||||
if (version.repositorySnapshotId) {
|
||||
if (!version.repositorySkillPath) throw new Error("Repository skill entrypoint is missing");
|
||||
const generated = await store.withPackage(version.companyId, id, async (root, manifest) => {
|
||||
const wanted = new Set([version.repositorySkillPath!, path.posix.join(path.posix.dirname(version.repositorySkillPath!), "agents/openai.yaml")]);
|
||||
const selected: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const file of manifest.files.filter(file => wanted.has(file.path))) selected.push({
|
||||
...file, content: (await fs.readFile(path.join(root, file.path))).toString(file.encoding),
|
||||
});
|
||||
return repositorySkillProjection(selected, version.repositorySkillPath!).filter(file => !file.path.startsWith(`${REPOSITORY_CONTENT_DIR}/`));
|
||||
});
|
||||
for (const file of generated) {
|
||||
const bytes = skillFileBytes(file);
|
||||
generatedFiles.push({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable),
|
||||
size: bytes.length, sha256: packageSha256(bytes), generated: bytes });
|
||||
}
|
||||
}
|
||||
// Retain only tiny generated wrappers/policies per version. Shared package
|
||||
// file metadata follows the bounded manifest cache through the WeakMap.
|
||||
const bytes = generatedFiles.reduce((sum, file) => sum + (file.generated?.length ?? 0) + 512, 0);
|
||||
while (projections.size && (projections.size >= MAX_PROJECTIONS || projectionBytes + bytes > MAX_PROJECTION_BYTES)) {
|
||||
const first = projections.keys().next().value!;
|
||||
projectionBytes -= projections.get(first)!.bytes;
|
||||
projections.delete(first);
|
||||
}
|
||||
if (generatedFiles.length && bytes <= MAX_PROJECTION_BYTES) { projections.set(key, { files: generatedFiles, bytes }); projectionBytes += bytes; }
|
||||
return [...files, ...generatedFiles];
|
||||
}
|
||||
async function materialize(version: StoredSkillVersion, directory: string) {
|
||||
const files = await projection(version);
|
||||
const parent = path.dirname(directory);
|
||||
await ensureSkillCacheDirectory(parent, true);
|
||||
if (await measureRunPhase("skills.version.verify", () => verifySkillPackageDirectory(directory, files), { fileCount: files.length })) return directory;
|
||||
return store.withPackage(version.companyId, (version.repositorySnapshotId ?? version.packageSnapshotId)!, async root => {
|
||||
const staging = await fs.mkdtemp(path.join(parent, `.${version.id}-`));
|
||||
try {
|
||||
for (const file of files) {
|
||||
const target = path.join(staging, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
if (file.generated) await fs.writeFile(target, file.generated);
|
||||
else await fs.copyFile(path.join(root, file.sourcePath!), target);
|
||||
await fs.chmod(target, file.executable ? 0o555 : 0o444);
|
||||
}
|
||||
if (!await verifySkillPackageDirectory(staging, files)) throw new Error("Prepared skill version failed integrity verification");
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
await fs.rename(staging, directory);
|
||||
relocateSkillPackageVerification(staging, directory, files);
|
||||
return directory;
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
async function readFile(version: StoredSkillVersion, relative: string) {
|
||||
const file = (await projection(version)).find(file => file.path === relative);
|
||||
if (!file) return null;
|
||||
const bytes = file.generated ?? await store.withPackage(version.companyId,
|
||||
(version.repositorySnapshotId ?? version.packageSnapshotId)!, root => fs.readFile(path.join(root, file.sourcePath!)));
|
||||
return { path: file.path, kind: file.kind, content: bytes.toString(file.encoding), encoding: file.encoding, executable: file.executable };
|
||||
}
|
||||
return { inventory, materialize, readFile };
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { recoverSkillPackage, skillPackageRecoveryReader } from "./skill-package-recovery.js";
|
||||
import { touchSkillPackage, trimSkillPackageCache } from "./skill-package-cache.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions, type Db, type SkillPackageStorageReceipt, type SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { assertSkillSnapshotPath, skillFileBytes } from "./skill-snapshot.js";
|
||||
import { ensureSkillCacheDirectory, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
import { boundedHashStream, extractSkillPackageArchive, MAX_SKILL_MANIFEST_BYTES, packageSha256,
|
||||
skillPackageManifest, skillPackageManifestBytes, validateSkillPackageManifest, verifySkillPackageDirectory, relocateSkillPackageVerification, writeSkillPackageArchive,
|
||||
type SkillPackageManifest } from "./skill-package-files.js";
|
||||
|
||||
/** External storage is the default; false is an explicit rolling-upgrade escape hatch. */
|
||||
export function skillPackageStorageEnabled() {
|
||||
return process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED !== "false";
|
||||
}
|
||||
|
||||
const descriptorColumns = { id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash,
|
||||
storage: snapshots.storage, upload: snapshots.upload, createdAt: snapshots.createdAt,
|
||||
restorable: sql<boolean>`${snapshots.restore} is not null`, restoreRetryAt: snapshots.restoreRetryAt };
|
||||
type Descriptor = Pick<typeof snapshots.$inferSelect, "id" | "companyId" | "contentHash" | "storage" | "upload" | "createdAt" | "restoreRetryAt"> & { restorable: boolean };
|
||||
export const skillVersionMetadataColumns = { ...getTableColumns(companySkillVersions),
|
||||
fileInventory: sql<CompanySkillVersionFileInventoryEntry[]>`'[]'::jsonb` };
|
||||
export type StoredSkillVersion = typeof companySkillVersions.$inferSelect;
|
||||
type ReadScope = { db: Db; descriptors: Map<string, Promise<Descriptor>>; durable: Map<string, Promise<boolean>>; versions: Map<string, StoredSkillVersion> };
|
||||
const readScope = new AsyncLocalStorage<ReadScope>();
|
||||
export function withSkillPackageReadScope<T>(db: Db, action: () => Promise<T>): Promise<T> {
|
||||
return readScope.getStore()?.db === db ? action() : readScope.run({ db, descriptors: new Map(), durable: new Map(), versions: new Map() }, action);
|
||||
}
|
||||
const flights = new WeakMap<Db, Map<string, Promise<unknown>>>();
|
||||
function flight<T>(db: Db, key: string, action: () => Promise<T>): Promise<T> {
|
||||
let pending = flights.get(db);
|
||||
if (!pending) { pending = new Map(); flights.set(db, pending); }
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const work = action().finally(() => pending!.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
let activeIo = 0;
|
||||
const waitingIo: Array<() => void> = [];
|
||||
async function withPackageIo<T>(action: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
if (activeIo >= 2) {
|
||||
if (waitingIo.length >= 32) throw conflict("Skill package storage is busy; retry shortly.");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ready = () => { signal.removeEventListener("abort", aborted); resolve(); };
|
||||
const aborted = () => {
|
||||
const index = waitingIo.indexOf(ready);
|
||||
if (index >= 0) waitingIo.splice(index, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
waitingIo.push(ready);
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
} else activeIo++;
|
||||
try { signal.throwIfAborted(); return await action(); }
|
||||
finally { const next = waitingIo.shift(); if (next) next(); else activeIo--; }
|
||||
}
|
||||
const manifests = new Map<string, { manifest: SkillPackageManifest; bytes: number }>();
|
||||
let manifestBytes = 0;
|
||||
const MAX_MANIFEST_CACHE = 16 * 1024 * 1024;
|
||||
function rememberManifest(key: string, manifest: SkillPackageManifest, bytes: number) {
|
||||
if (bytes > MAX_MANIFEST_CACHE) return;
|
||||
const previous = manifests.get(key);
|
||||
if (previous) manifestBytes -= previous.bytes;
|
||||
manifests.delete(key);
|
||||
while (manifests.size && manifestBytes + bytes > MAX_MANIFEST_CACHE) {
|
||||
const first = manifests.keys().next().value!;
|
||||
manifestBytes -= manifests.get(first)!.bytes;
|
||||
manifests.delete(first);
|
||||
}
|
||||
manifests.set(key, { manifest, bytes });
|
||||
manifestBytes += bytes;
|
||||
}
|
||||
export function skillPackagePaths(companyId: string, snapshotId: string) {
|
||||
if (![companyId, snapshotId].every(id => /^[a-f0-9-]{36}$/i.test(id))) throw new Error("Invalid skill package identity");
|
||||
const parent = path.join(resolvePaperclipInstanceRoot(), "skills", companyId, "__packages__");
|
||||
return { parent, directory: path.join(parent, snapshotId), manifest: path.join(parent, `${snapshotId}.json`) };
|
||||
}
|
||||
async function readBoundedObject(storage: StorageService, companyId: string, key: string, length: number, hash: string, signal: AbortSignal) {
|
||||
if (!Number.isSafeInteger(length) || length < 0 || length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Invalid skill manifest size");
|
||||
const object = await storage.getObject(companyId, key, { signal });
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of object.stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.length;
|
||||
if (bytes > length) throw new Error("Skill manifest exceeds its recorded size");
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} finally { object.stream.destroy(); }
|
||||
const result = Buffer.concat(chunks);
|
||||
if (bytes !== length || packageSha256(result) !== hash) throw new Error("Skill manifest checksum mismatch");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Owns immutable package storage. Authorization belongs to the referencing
|
||||
* skill/source, and must be checked even when package bytes are already cached. */
|
||||
export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
||||
const storage = () => suppliedStorage ?? getStorageService();
|
||||
const scope = (companyId: string, id: string) => and(eq(snapshots.companyId, companyId), eq(snapshots.id, id));
|
||||
async function descriptor(companyId: string, id: string): Promise<Descriptor> {
|
||||
const load = async () => {
|
||||
const [row] = await db.select(descriptorColumns).from(snapshots).where(scope(companyId, id));
|
||||
if (!row || (!row.storage && !row.restorable && row.upload && !row.upload.legacy)) throw notFound("Installed skill package is unavailable");
|
||||
return row;
|
||||
};
|
||||
const context = readScope.getStore();
|
||||
if (context?.db !== db) return load();
|
||||
const key = `${companyId}:${id}`;
|
||||
if (!context.descriptors.has(key)) context.descriptors.set(key, load());
|
||||
return context.descriptors.get(key)!;
|
||||
}
|
||||
async function version(companyId: string, skillId: string, id: string): Promise<StoredSkillVersion | null> {
|
||||
const context = readScope.getStore();
|
||||
const cached = context?.db === db ? context.versions.get(`${companyId}:${id}`) : null;
|
||||
if (cached) return cached.companySkillId === skillId ? cached : null;
|
||||
const [row] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId), eq(companySkillVersions.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
async function prefetch(companyId: string, versionIds: string[]) {
|
||||
const context = readScope.getStore();
|
||||
if (context?.db !== db || versionIds.length < 2) return;
|
||||
const rows = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), inArray(companySkillVersions.id, [...new Set(versionIds)])));
|
||||
for (const row of rows) context.versions.set(`${companyId}:${row.id}`, row);
|
||||
const ids = [...new Set(rows.flatMap(row => [row.repositorySnapshotId, row.packageSnapshotId]).filter((id): id is string => Boolean(id)))];
|
||||
if (!ids.length) return;
|
||||
const packages = await db.select(descriptorColumns).from(snapshots).where(and(eq(snapshots.companyId, companyId), inArray(snapshots.id, ids)));
|
||||
for (const row of packages) if (row.storage || row.restorable || !row.upload || row.upload.legacy) {
|
||||
context.descriptors.set(`${companyId}:${row.id}`, Promise.resolve(row));
|
||||
}
|
||||
}
|
||||
async function restoreRecipe(row: Descriptor) {
|
||||
const [saved] = await db.select({ restore: snapshots.restore }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
const recipe = saved?.restore;
|
||||
if (!recipe || recipe.version !== 1) throw notFound("This skill package has no exact-commit restore recipe");
|
||||
const manifest = validateSkillPackageManifest(recipe.manifest);
|
||||
if (manifest.contentHash !== row.contentHash || (row.storage && packageSha256(skillPackageManifestBytes(manifest)) !== row.storage.manifestSha256)) {
|
||||
throw new Error("Skill package restore recipe does not match its receipt");
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
async function acquireFiles(row: Descriptor, signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
const [current] = await db.select({ retryAt: snapshots.restoreRetryAt }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (current?.retryAt && current.retryAt.getTime() > Date.now()) throw conflict("The original skill package could not be acquired. Reconnect its repository and retry after the recovery backoff.");
|
||||
try {
|
||||
const recipe = await restoreRecipe(row);
|
||||
const read = await skillPackageRecoveryReader(db, row.companyId, recipe);
|
||||
const files = await recoverSkillPackage(recipe, read, signal);
|
||||
await db.update(snapshots).set({ restoreRetryAt: null }).where(scope(row.companyId, row.id));
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) await db.update(snapshots).set({ restoreRetryAt: new Date(Date.now() + 5 * 60_000) }).where(scope(row.companyId, row.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function legacyFiles(row: Descriptor) {
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory, storage: snapshots.storage, upload: snapshots.upload })
|
||||
.from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (!legacy || (!legacy.files.length && !legacy.storage)) throw notFound("Installed skill package has no saved contents");
|
||||
// A reader may have loaded its descriptor just before a migration clears
|
||||
// the legacy payload. Upgrade this request's descriptor to the published
|
||||
// receipt instead of reporting a missing historical version.
|
||||
if (legacy.storage) { row.storage = legacy.storage; row.upload = legacy.upload; }
|
||||
return legacy.files;
|
||||
}
|
||||
async function manifestFor(row: Descriptor): Promise<SkillPackageManifest> {
|
||||
const paths = skillPackagePaths(row.companyId, row.id);
|
||||
const key = `${paths.manifest}:${row.storage?.manifestSha256 ?? row.contentHash}`;
|
||||
const cached = manifests.get(key);
|
||||
if (cached) return cached.manifest;
|
||||
return flight(db, `manifest:${key}`, async () => {
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
// Legacy manifests have no independently stored checksum. Derive them once
|
||||
// per process from the authoritative row until the operator migrates it.
|
||||
let bytes: Buffer | null = null;
|
||||
if (row.storage) {
|
||||
try {
|
||||
const file = await fs.open(paths.manifest, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (stat.isFile() && stat.size <= MAX_SKILL_MANIFEST_BYTES) bytes = await file.readFile();
|
||||
} finally { await file.close(); }
|
||||
} catch (error) {
|
||||
if (!["ENOENT", "ELOOP", "EISDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
||||
}
|
||||
}
|
||||
if (row.storage) {
|
||||
if (!bytes || packageSha256(bytes) !== row.storage.manifestSha256) {
|
||||
const signal = AbortSignal.timeout(180_000);
|
||||
try {
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
|
||||
bytes = await withPackageIo(() => readBoundedObject(storage(), row.companyId, row.storage!.manifestKey,
|
||||
row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal);
|
||||
} catch (error) {
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
const saved = legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null;
|
||||
if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved;
|
||||
else if (row.restorable) bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
} else if (row.restorable) {
|
||||
bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||
} else if (!bytes) {
|
||||
const files = await legacyFiles(row);
|
||||
if (row.storage) return manifestFor(row);
|
||||
bytes = skillPackageManifestBytes(skillPackageManifest(files));
|
||||
}
|
||||
if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
||||
const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8")));
|
||||
if (row.storage && (manifest.contentHash !== row.contentHash || manifest.files.length !== row.storage.fileCount
|
||||
|| manifest.totalBytes !== row.storage.totalBytes)) throw new Error("Skill package receipt does not match its manifest");
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
const temp = `${paths.manifest}.${randomUUID()}`;
|
||||
try { await fs.writeFile(temp, bytes, { flag: "wx", mode: 0o600 }); await fs.rename(temp, paths.manifest); }
|
||||
finally { await fs.rm(temp, { force: true }); }
|
||||
rememberManifest(key, manifest, bytes.length);
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
async function materialize(row: Descriptor, manifest: SkillPackageManifest) {
|
||||
const paths = skillPackagePaths(row.companyId, row.id);
|
||||
return flight(db, `files:${paths.directory}`, async () => {
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
if (await verifySkillPackageDirectory(paths.directory, manifest.files)) return { directory: paths.directory, created: false };
|
||||
const signal = AbortSignal.timeout(180_000);
|
||||
return withPackageIo(async () => {
|
||||
const staging = await fs.mkdtemp(path.join(paths.parent, ".package-"));
|
||||
const archivePath = `${staging}.tar.gz`;
|
||||
try {
|
||||
let legacy: CompanySkillVersionFileInventoryEntry[] | null = null;
|
||||
if (!row.storage && !row.restorable) legacy = await legacyFiles(row);
|
||||
try {
|
||||
if (row.storage) {
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
|
||||
const object = await storage().getObject(row.companyId, row.storage.archiveKey, { signal });
|
||||
const digest = boundedHashStream(row.storage.archiveBytes);
|
||||
await pipeline(object.stream, digest.stream, createWriteStream(archivePath, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== row.storage.archiveBytes || actual.sha256 !== row.storage.archiveSha256) throw new Error("Skill package archive checksum mismatch");
|
||||
await extractSkillPackageArchive(archivePath, staging, manifest, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
// A missing/corrupt archive must not hide a still-authoritative legacy
|
||||
// body. Only then use the saved, authorized immutable Git recipe.
|
||||
const [saved] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (saved?.files.length && skillPackageManifest(saved.files).contentHash === row.contentHash) legacy = saved.files;
|
||||
else if (row.restorable) legacy = await acquireFiles(row, signal);
|
||||
else throw error;
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
await fs.mkdir(staging);
|
||||
}
|
||||
if (!row.storage && row.restorable && !legacy) legacy = await acquireFiles(row, signal);
|
||||
if (legacy) {
|
||||
for (const file of legacy) {
|
||||
signal.throwIfAborted();
|
||||
const target = path.join(staging, assertSkillSnapshotPath(file.path));
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, skillFileBytes(file), { flag: "wx", mode: file.executable ? 0o555 : 0o444 });
|
||||
}
|
||||
}
|
||||
if (!await verifySkillPackageDirectory(staging, manifest.files)) throw new Error("Restored skill package does not match its manifest");
|
||||
// All consumers hold the company cache lock, including processes sharing a volume.
|
||||
await fs.rm(paths.directory, { recursive: true, force: true });
|
||||
await fs.rename(staging, paths.directory);
|
||||
relocateSkillPackageVerification(staging, paths.directory, manifest.files);
|
||||
return { directory: paths.directory, created: true };
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); await fs.rm(archivePath, { force: true }); }
|
||||
}, signal);
|
||||
});
|
||||
}
|
||||
async function withPackage<T>(companyId: string, id: string, consume: (root: string, manifest: SkillPackageManifest) => Promise<T>) {
|
||||
const row = await descriptor(companyId, id);
|
||||
const manifest = await manifestFor(row);
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const restored = await materialize(row, manifest);
|
||||
touchSkillPackage(restored.directory);
|
||||
if (restored.created) await trimSkillPackageCache(db, companyId, restored.directory,
|
||||
(id, signal) => verifyDurable(companyId, id, signal).catch(() => false));
|
||||
return consume(restored.directory, manifest);
|
||||
});
|
||||
}
|
||||
async function files(companyId: string, id: string): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
return withPackage(companyId, id, async (root, manifest) => {
|
||||
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const entry of manifest.files) result.push({ path: entry.path, kind: entry.kind, content: (await fs.readFile(path.join(root, entry.path))).toString(entry.encoding),
|
||||
...(entry.encoding === "base64" ? { encoding: entry.encoding } : {}), ...(entry.executable ? { executable: true } : {}) });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async function prepare(companyId: string, files: CompanySkillVersionFileInventoryEntry[], migrateLegacy = false, cancellation?: AbortSignal, restore?: SkillPackageRestoreRecipe): Promise<string> {
|
||||
cancellation?.throwIfAborted();
|
||||
const manifest = skillPackageManifest(files);
|
||||
if (restore && !skillPackageManifestBytes(restore.manifest).equals(skillPackageManifestBytes(manifest))) throw new Error("Restore recipe does not describe these package bytes");
|
||||
return flight(db, `prepare:${companyId}:${manifest.contentHash}`, async () => {
|
||||
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||
const token = randomUUID();
|
||||
const prefix = `${companyId}/skill-packages/${token}`;
|
||||
const upload = { token, legacy: false, provider: storage().provider, archiveKey: `${prefix}/files.tar.gz`, manifestKey: `${prefix}/manifest.json`, expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||
const [created] = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
return tx.insert(snapshots).values({ companyId, contentHash: manifest.contentHash, upload, restore,
|
||||
migrationAt: null, maintenanceAt: new Date(Date.now() + 24 * 60 * 60_000) }).onConflictDoNothing().returning(descriptorColumns);
|
||||
});
|
||||
let existing = created ?? (await db.select(descriptorColumns).from(snapshots).where(and(eq(snapshots.companyId, companyId), eq(snapshots.contentHash, manifest.contentHash))))[0]!;
|
||||
if (!created) {
|
||||
if (existing.storage || (!existing.upload && !migrateLegacy && !existing.restorable)) {
|
||||
// Content deduplication must not attach a recipe with different kind or
|
||||
// encoding metadata to an already published manifest.
|
||||
if (restore && !existing.restorable && existing.storage?.manifestSha256 === packageSha256(skillPackageManifestBytes(manifest))) {
|
||||
await db.update(snapshots).set({ restore }).where(and(scope(companyId, existing.id), sql`${snapshots.restore} is null`));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
// A stale claim is replaced atomically; the DB trigger retains its exact
|
||||
// keys in the deletion outbox. Never reuse keys from another attempt.
|
||||
upload.legacy = !existing.upload || Boolean(existing.upload.legacy);
|
||||
const [claimed] = await db.update(snapshots).set({ upload }).where(and(scope(companyId, existing.id),
|
||||
sql`${snapshots.storage} is null`,
|
||||
sql`(${snapshots.upload} is null or (${snapshots.upload}->>'expiresAt')::timestamptz <= now())`)).returning(descriptorColumns);
|
||||
if (!claimed) throw conflict("This skill package is being saved. Retry after its upload settles.");
|
||||
existing = claimed;
|
||||
}
|
||||
return withPackageIo(async () => {
|
||||
const root = skillPackagePaths(companyId, existing.id).parent;
|
||||
await ensureSkillCacheDirectory(root, true);
|
||||
const staging = await fs.mkdtemp(path.join(root, ".upload-"));
|
||||
const archivePath = path.join(staging, "files.tar.gz");
|
||||
const manifestBuffer = skillPackageManifestBytes(manifest);
|
||||
if (manifestBuffer.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
||||
const backend = storage();
|
||||
try {
|
||||
const archive = await writeSkillPackageArchive(archivePath, files, signal);
|
||||
await backend.putFile({ companyId, namespace: "skill-packages", objectKey: upload.archiveKey, originalFilename: "files.tar.gz",
|
||||
contentType: "application/gzip", body: createReadStream(archivePath), byteSize: archive.bytes, sha256: archive.sha256, signal });
|
||||
await backend.putFile({ companyId, namespace: "skill-packages", objectKey: upload.manifestKey, originalFilename: "manifest.json",
|
||||
contentType: "application/json", body: manifestBuffer, signal });
|
||||
const receipt: SkillPackageStorageReceipt = { format: "tar-gzip-v1", provider: backend.provider,
|
||||
archiveKey: upload.archiveKey, archiveSha256: archive.sha256, archiveBytes: archive.bytes,
|
||||
manifestKey: upload.manifestKey, manifestSha256: packageSha256(manifestBuffer), manifestBytes: manifestBuffer.length,
|
||||
fileCount: manifest.files.length, totalBytes: manifest.totalBytes };
|
||||
// A complete read verifies storage independently before publishing any reference.
|
||||
await readBoundedObject(backend, companyId, receipt.manifestKey, receipt.manifestBytes, receipt.manifestSha256, signal);
|
||||
const verify = await backend.getObject(companyId, receipt.archiveKey, { signal });
|
||||
const digest = boundedHashStream(receipt.archiveBytes);
|
||||
const downloaded = path.join(staging, "verified.tar.gz");
|
||||
await pipeline(verify.stream, digest.stream, createWriteStream(downloaded, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== receipt.archiveBytes || actual.sha256 !== receipt.archiveSha256) throw new Error("Uploaded skill package checksum mismatch");
|
||||
const restored = path.join(staging, "restored");
|
||||
await fs.mkdir(restored);
|
||||
await extractSkillPackageArchive(downloaded, restored, manifest, signal);
|
||||
signal.throwIfAborted();
|
||||
const [saved] = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
return tx.update(snapshots).set({ storage: receipt, upload: null, ...(restore ? { restore } : {}) })
|
||||
.where(and(scope(companyId, existing.id), sql`${snapshots.upload}->>'token' = ${token}`)).returning({ id: snapshots.id });
|
||||
});
|
||||
if (!saved) throw conflict("Skill package upload ownership changed");
|
||||
return saved.id;
|
||||
} finally {
|
||||
// Failed/ambiguous uploads retain their durable claim. Maintenance reconciles
|
||||
// exact owned keys; never delete a successful object on an uncertain SQL result.
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}, signal);
|
||||
});
|
||||
}
|
||||
async function verifyDurable(companyId: string, id: string, cancellation?: AbortSignal): Promise<boolean> {
|
||||
const action = async () => {
|
||||
const row = await descriptor(companyId, id);
|
||||
if (!row.storage) { await manifestFor(row); if (!row.storage) return true; }
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed; migrate its objects before switching storage");
|
||||
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||
return withPackageIo(async () => {
|
||||
const receipt = row.storage!;
|
||||
const bytes = await readBoundedObject(storage(), companyId, receipt.manifestKey, receipt.manifestBytes, receipt.manifestSha256, signal);
|
||||
const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8")));
|
||||
if (manifest.contentHash !== row.contentHash) return false;
|
||||
const parent = skillPackagePaths(companyId, id).parent;
|
||||
await ensureSkillCacheDirectory(parent, true);
|
||||
const staging = await fs.mkdtemp(path.join(parent, ".verify-"));
|
||||
try {
|
||||
const object = await storage().getObject(companyId, receipt.archiveKey, { signal });
|
||||
const digest = boundedHashStream(receipt.archiveBytes);
|
||||
const archive = path.join(staging, "files.tar.gz");
|
||||
await pipeline(object.stream, digest.stream, createWriteStream(archive, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== receipt.archiveBytes || actual.sha256 !== receipt.archiveSha256) return false;
|
||||
const restored = path.join(staging, "files");
|
||||
await fs.mkdir(restored);
|
||||
await extractSkillPackageArchive(archive, restored, manifest, signal);
|
||||
return true;
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
||||
}, signal);
|
||||
};
|
||||
const context = readScope.getStore();
|
||||
const key = `${companyId}:${id}`;
|
||||
if (context?.db !== db) return action();
|
||||
if (!context.durable.has(key)) context.durable.set(key, action());
|
||||
return context.durable.get(key)!;
|
||||
}
|
||||
async function versionFiles(version: StoredSkillVersion): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
if (!version.packageSnapshotId) {
|
||||
const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id)));
|
||||
if (row?.packageSnapshotId) return versionFiles(row);
|
||||
return row?.files ?? [];
|
||||
}
|
||||
if (!version.packageFileMap) return files(version.companyId, version.packageSnapshotId);
|
||||
return withPackage(version.companyId, version.packageSnapshotId, async (root, manifest) => {
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const mapping of version.packageFileMap!) {
|
||||
assertSkillSnapshotPath(mapping.path);
|
||||
assertSkillSnapshotPath(mapping.sourcePath);
|
||||
const file = byPath.get(mapping.sourcePath);
|
||||
if (!file) throw new Error("Skill version references a missing package file");
|
||||
result.push({ path: mapping.path, kind: mapping.kind,
|
||||
content: (await fs.readFile(path.join(root, file.path))).toString(file.encoding),
|
||||
...(file.encoding === "base64" ? { encoding: file.encoding } : {}), ...(file.executable ? { executable: true } : {}) });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return { descriptor, prepare, files, version, prefetch, versionFiles, withPackage, verifyDurable,
|
||||
manifest: async (companyId: string, id: string) => manifestFor(await descriptor(companyId, id)) };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { companies, companySkillRepositorySnapshots as snapshots, createDb } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { skillPackageWork } from "./skill-package-work.js";
|
||||
import { skillPackageStore } from "./skill-package-store.js";
|
||||
import { skillSnapshotHash } from "./skill-snapshot.js";
|
||||
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { idleWorkSnapshot } from "./task-admission.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("automatic skill package work", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let storage: ReturnType<typeof createStorageService>;
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
let home: string;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "---\nname: legacy\n---\noriginal" }];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("skill-package-work-");
|
||||
db = createDb(database.connectionString, { maxConnections: 2 });
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-package-work-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
storage = createStorageService(createLocalDiskStorageProvider(path.join(home, "objects")));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await db.execute(sql`truncate companies cascade`);
|
||||
await db.execute(sql`truncate skill_package_deletions`);
|
||||
});
|
||||
afterEach(async () => { await coordinator?.stop(); coordinator = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
afterAll(async () => {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await db?.$client.end({ timeout: 1 }); await database?.cleanup();
|
||||
if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
async function company() {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: "Migration", issuePrefix: "MIG" });
|
||||
return id;
|
||||
}
|
||||
it("stops querying after empty startup and retains a first committed wake through drain", async () => {
|
||||
const companyId = await company();
|
||||
let draining = false;
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => { throw error; } });
|
||||
const work = skillPackageWork(db, storage).start(coordinator);
|
||||
await work.ready;
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
let queries = 0;
|
||||
const debug = db.$client.options.debug;
|
||||
db.$client.options.debug = () => { queries++; };
|
||||
try {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(queries).toBe(0);
|
||||
draining = true;
|
||||
let id: string;
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
[id] = (await tx.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning({ id: snapshots.id })).map(row => row.id);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
|
||||
const baseline = queries;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(queries).toBe(baseline);
|
||||
draining = false;
|
||||
work.wake();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.waitFor(async () => {
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.id, id!));
|
||||
expect(row!.fileInventory).toEqual([]);
|
||||
expect(row!.storage).not.toBeNull();
|
||||
});
|
||||
} finally { db.$client.options.debug = debug; }
|
||||
});
|
||||
it("keeps legacy contents on failure, persists a retry and resumes after restart", async () => {
|
||||
const companyId = await company();
|
||||
const [row] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const upload = vi.spyOn(storage, "putFile").mockRejectedValue(new Error("offline"));
|
||||
const service = skillPackageWork(db, storage);
|
||||
await service.tick(new AbortController().signal);
|
||||
const [failed] = await db.select().from(snapshots).where(eq(snapshots.id, row!.id));
|
||||
expect(failed!.fileInventory).toEqual(files);
|
||||
expect(failed!.migrationAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(await service.deadline()).toBe(failed!.migrationAt!.getTime());
|
||||
upload.mockRestore();
|
||||
await db.update(snapshots).set({ migrationAt: new Date(0), upload: { ...failed!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, row!.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
const [saved] = await db.select().from(snapshots).where(eq(snapshots.id, row!.id));
|
||||
expect(saved!.fileInventory).toEqual([]);
|
||||
expect(saved!.migrationAt).toBeNull();
|
||||
expect(await skillPackageStore(db, storage).files(companyId, row!.id)).toEqual(files);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { and, eq, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions as versions,
|
||||
skillPackageDeletions, agents, type Db } from "@paperclipai/db";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { validateSkillPackageManifest, skillPackageManifestBytes, packageSha256 } from "./skill-package-files.js";
|
||||
import { skillPackageMaintenance } from "./skill-package-maintenance.js";
|
||||
import { skillPackagePaths, skillPackageStorageEnabled } from "./skill-package-store.js";
|
||||
import { ensureSkillCacheDirectory, protectedVersions, SkillCacheBusy, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
|
||||
export const SKILL_PACKAGE_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const RETRY_MS = 60 * 60_000;
|
||||
const PAGE_SIZE = 4;
|
||||
|
||||
/** A finite upgrade plus persisted cleanup deadlines. No work is added to turn
|
||||
* preparation; completed history has no recurring SQL/network obligation. */
|
||||
export function skillPackageWork(db: Db, storage?: StorageService) {
|
||||
const maintenance = skillPackageMaintenance(db, storage);
|
||||
const backend = () => storage ?? getStorageService();
|
||||
async function retire(id: string, signal: AbortSignal) {
|
||||
const [row] = await db.select({ id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash, createdAt: snapshots.createdAt,
|
||||
storage: snapshots.storage, upload: snapshots.upload, restore: snapshots.restore, migrationAt: snapshots.migrationAt })
|
||||
.from(snapshots).where(eq(snapshots.id, id));
|
||||
if (!row) return;
|
||||
signal.throwIfAborted();
|
||||
// Orphan deletion is delayed so an upload can publish its first version.
|
||||
const removeLocal = async () => {
|
||||
const paths = skillPackagePaths(row.companyId, id);
|
||||
try {
|
||||
await ensureSkillCacheDirectory(paths.parent);
|
||||
await fs.rm(paths.directory, { recursive: true, force: true });
|
||||
await fs.rm(paths.manifest, { force: true });
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
};
|
||||
if (await withSkillVersionCacheLock(db, row.companyId, async () => {
|
||||
if (!(await maintenance.collectOrphans({ id, apply: true, signal })).removed) return false;
|
||||
await removeLocal();
|
||||
return true;
|
||||
}, true)) return;
|
||||
const orphanAt = row.createdAt.getTime() + 24 * 60 * 60_000;
|
||||
let next: Date | null = orphanAt > Date.now() ? new Date(orphanAt) : null;
|
||||
if (row.upload) next = new Date(Math.max(Date.now() + RETRY_MS, new Date(row.upload.expiresAt).getTime(), row.createdAt.getTime() + 24 * 60 * 60_000));
|
||||
else if (row.restore && row.storage) {
|
||||
const expires = row.createdAt.getTime() + SKILL_PACKAGE_RETENTION_MS;
|
||||
if (expires > Date.now()) next = new Date(expires);
|
||||
else if (!row.migrationAt) {
|
||||
const manifest = validateSkillPackageManifest(row.restore.manifest);
|
||||
if (row.restore.version !== 1 || manifest.contentHash !== row.contentHash
|
||||
|| packageSha256(skillPackageManifestBytes(manifest)) !== row.storage.manifestSha256) throw new Error("Invalid skill restore recipe; keep durable bytes");
|
||||
await withSkillVersionCacheLock(db, row.companyId, async () => {
|
||||
const referenced = await db.select({ id: versions.id }).from(versions).where(and(eq(versions.companyId, row.companyId),
|
||||
sql`(${versions.repositorySnapshotId} = ${id} or ${versions.packageSnapshotId} = ${id})`));
|
||||
const [current] = await db.execute(sql`select 1 from company_skills s where s.company_id = ${row.companyId}
|
||||
and s.current_version_id in (select id from company_skill_versions where repository_snapshot_id = ${id} or package_snapshot_id = ${id})
|
||||
union all select 1 from company_skill_test_runs t where t.company_id = ${row.companyId}
|
||||
and t.status in ('queued', 'running') and t.skill_version_id in
|
||||
(select id from company_skill_versions where repository_snapshot_id = ${id} or package_snapshot_id = ${id}) limit 1`);
|
||||
if (current) return;
|
||||
const pinned = new Set<string>();
|
||||
for (const agent of await db.select({ config: agents.adapterConfig }).from(agents).where(eq(agents.companyId, row.companyId))) {
|
||||
for (const entry of readPaperclipSkillSyncPreference(agent.config ?? {}).desiredSkillEntries) if (entry.versionId) pinned.add(entry.versionId);
|
||||
}
|
||||
const active = await protectedVersions(db, row.companyId);
|
||||
if (!active || referenced.some(version => active.has(version.id) || pinned.has(version.id))) return;
|
||||
signal.throwIfAborted();
|
||||
// Metadata/recipe remain. Triggered deletion receipts retain the old
|
||||
// object keys through the rollback grace; future reads reacquire the SHA.
|
||||
await db.update(snapshots).set({ storage: null }).where(and(eq(snapshots.id, id), isNull(snapshots.upload)));
|
||||
await removeLocal();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
await db.update(snapshots).set({ maintenanceAt: next }).where(and(eq(snapshots.id, id), lte(snapshots.maintenanceAt, new Date())));
|
||||
}
|
||||
async function tick(signal: AbortSignal) {
|
||||
if (!skillPackageStorageEnabled()) return;
|
||||
for (const table of [snapshots, versions]) {
|
||||
const rows = await db.select({ id: table.id }).from(table).where(lte(table.migrationAt, new Date())).orderBy(table.migrationAt).limit(PAGE_SIZE);
|
||||
for (const row of rows) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
if (table === snapshots) await maintenance.migrateSnapshots({ id: row.id, apply: true, clearLegacy: true, signal });
|
||||
else {
|
||||
// Bundled releases intentionally retain their small inline contract.
|
||||
const result = await maintenance.migrateVersions({ id: row.id, apply: true, clearLegacy: true, signal });
|
||||
if (!result.inspected) await db.update(versions).set({ migrationAt: null }).where(eq(versions.id, row.id));
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(table).set({ migrationAt: new Date(Date.now() + RETRY_MS) }).where(and(eq(table.id, row.id), isNotNull(table.migrationAt)));
|
||||
logger.warn({ packageId: row.id }, "Skill package conversion deferred; original contents retained");
|
||||
}
|
||||
}
|
||||
}
|
||||
const due = await db.select({ id: snapshots.id }).from(snapshots).where(lte(snapshots.maintenanceAt, new Date())).orderBy(snapshots.maintenanceAt).limit(PAGE_SIZE);
|
||||
for (const { id } of due) {
|
||||
try { await retire(id, signal); }
|
||||
catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date(Date.now() + (error instanceof SkillCacheBusy ? 60_000 : RETRY_MS)) }).where(and(eq(snapshots.id, id), lte(snapshots.maintenanceAt, new Date())));
|
||||
if (!(error instanceof SkillCacheBusy)) logger.warn({ packageId: id }, "Skill package cleanup deferred");
|
||||
}
|
||||
}
|
||||
const deletions = await db.select({ id: skillPackageDeletions.id }).from(skillPackageDeletions).where(and(
|
||||
eq(skillPackageDeletions.provider, backend().provider), lte(skillPackageDeletions.deleteAfter, new Date()))).limit(PAGE_SIZE);
|
||||
for (const { id } of deletions) {
|
||||
try { await maintenance.deleteObjects({ id, apply: true, signal }); }
|
||||
catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(skillPackageDeletions).set({ deleteAfter: new Date(Date.now() + RETRY_MS) }).where(eq(skillPackageDeletions.id, id));
|
||||
logger.warn({ deletionId: id }, "Skill package object deletion deferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
async function deadline() {
|
||||
if (!skillPackageStorageEnabled()) return null;
|
||||
const [row] = await db.execute<{ at: string | Date | null }>(sql`select min(at) as at from (
|
||||
select min(migration_at) as at from company_skill_repository_snapshots
|
||||
union all select min(migration_at) from company_skill_versions
|
||||
union all select min(maintenance_at) from company_skill_repository_snapshots
|
||||
union all select min(delete_after) from skill_package_deletions where provider = ${backend().provider}
|
||||
) deadlines`);
|
||||
return row?.at ? new Date(row.at).getTime() : null;
|
||||
}
|
||||
function start(coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">) {
|
||||
let requested = 0, completed = -1, next: number | null = null;
|
||||
let recovered = false;
|
||||
return coordinator.register(DELIVERY_QUEUES.skillPackages, {
|
||||
retryMs: 5_000, pausePassiveMaintenance: true,
|
||||
onSettled: () => { requested++; },
|
||||
async run(signal) {
|
||||
const generation = requested;
|
||||
if (!recovered && skillPackageStorageEnabled()) {
|
||||
// A package previously protected by a run/pin may now be unused.
|
||||
// Reconsider it once on restart, not on an idle polling interval.
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date() }).where(and(isNull(snapshots.maintenanceAt),
|
||||
sql`${snapshots.storage} is not null and ${snapshots.restore} is not null`,
|
||||
lte(snapshots.createdAt, new Date(Date.now() - SKILL_PACKAGE_RETENTION_MS))));
|
||||
recovered = true;
|
||||
}
|
||||
await tick(signal);
|
||||
next = await deadline();
|
||||
if (next !== null) next = Math.max(Date.now() + 1_000, next);
|
||||
completed = generation;
|
||||
},
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => requested !== completed ? Date.now() + 5_000 : next,
|
||||
});
|
||||
}
|
||||
return { tick, deadline, start };
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function setupActor(setup: Setup): AuthorizationActor {
|
||||
onBehalfOfUserId: setup.refreshUserId === 'local-board' ? null : setup.refreshUserId, source: 'agent_key' }
|
||||
: { type: 'board', userId: setup.refreshUserId, source: setup.refreshLocalTrusted ? 'local_implicit' : 'session', companyIds: [setup.companyId] };
|
||||
}
|
||||
async function setupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
export async function skillSourceSetupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
if (!setup.refreshUserId && !setup.refreshLocalTrusted) throw forbidden('Choose a GitHub connection to enable automatic updates.');
|
||||
if (!setup.refreshLocalTrusted) {
|
||||
const [member] = await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
@@ -152,7 +152,7 @@ async function setupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
}
|
||||
|
||||
export function skillSourceSyncService(db: Db, options: { context?: (setup: Setup) => Promise<SkillSourceContext> } = {}) {
|
||||
const contextFor = options.context ?? (setup => setupContext(db, setup));
|
||||
const contextFor = options.context ?? (setup => skillSourceSetupContext(db, setup));
|
||||
const sources = skillSourceService(db);
|
||||
let running: Promise<{ imported: number; checked: number }> | null = null;
|
||||
const shutdown = new AbortController();
|
||||
@@ -211,7 +211,7 @@ export function skillSourceSyncService(db: Db, options: { context?: (setup: Setu
|
||||
try {
|
||||
const result = await sources.create(binding.companyId, { repositoryUrl: scan.repositoryUrl, trackingRef: binding.trackingRef,
|
||||
commitSha: scan.commitSha, packageMode: scan.packageMode, selectedPaths: scan.candidates.map(candidate => candidate.path),
|
||||
connectionId: scan.connectionId ?? binding.connectionId }, { ...context, projectBindingId: binding.id, projectBindingLeaseToken: token }, scan);
|
||||
connectionId: scan.connectionId ?? binding.connectionId }, { ...context, signal: shutdown.signal, projectBindingId: binding.id, projectBindingLeaseToken: token }, scan);
|
||||
sourceId = result.source.id;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'status' in error && error.status === 409)) throw error;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { skillPackageManifest } from "./skill-package-files.js";
|
||||
import { skillPackageStorageEnabled, skillPackageStore } from "./skill-package-store.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from './delivery-work-notifications.js';
|
||||
import { reserveAutomaticSkillWork, settleAutomaticSkillWork } from './skill-source-refresh-budget.js';
|
||||
import { skillSourceRepairAction } from './skill-source-problems.js';
|
||||
@@ -20,6 +22,7 @@ import { skillSnapshotHash } from './skill-snapshot.js';
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
type SourceRow = typeof sources.$inferSelect;
|
||||
export type SkillSourceContext = {
|
||||
signal?: AbortSignal;
|
||||
actor: { type: 'user' | 'agent' | 'system'; userId?: string | null; agentId?: string | null };
|
||||
setup?: { userId: string | null; agentId?: string | null; localTrusted: boolean; grantId?: string | null };
|
||||
projectBindingId?: string;
|
||||
@@ -33,6 +36,27 @@ const scope = (companyId: string, id: string) => and(eq(sources.companyId, compa
|
||||
|
||||
export function skillSourceService(db: Db) {
|
||||
const skills = companySkillService(db);
|
||||
const packages = skillPackageStore(db);
|
||||
async function preparePackages(companyId: string, scan: ScannedSkillSource, selected: string[], sourceId: string, signal?: AbortSignal) {
|
||||
if (!skillPackageStorageEnabled()) return new Map<string, string>();
|
||||
const candidates = scan.skills.filter(candidate => selected.includes(candidate.path) && !candidate.error);
|
||||
const prepared = new Map<string, string>();
|
||||
if (!candidates.length) return prepared;
|
||||
if (scan.packageMode === 'repository') {
|
||||
if (scan.repositoryPackage?.error || !scan.repositoryFiles?.length) throw unprocessable(scan.repositoryPackage?.error ?? 'Repository package has no files.');
|
||||
const id = await packages.prepare(companyId, scan.repositoryFiles, true, signal, {
|
||||
version: 1, sourceId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl,
|
||||
commitSha: scan.commitSha, skillPath: null, manifest: skillPackageManifest(scan.repositoryFiles),
|
||||
});
|
||||
for (const candidate of candidates) prepared.set(candidate.path, id);
|
||||
} else {
|
||||
for (const candidate of candidates) prepared.set(candidate.path, await packages.prepare(companyId, candidate.files, true, signal, {
|
||||
version: 1, sourceId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl,
|
||||
commitSha: scan.commitSha, skillPath: candidate.path, manifest: skillPackageManifest(candidate.files),
|
||||
}));
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
async function row(companyId: string, id: string, database: Db | Tx = db) {
|
||||
const [source] = await database.select().from(sources).where(scope(companyId, id));
|
||||
if (!source || source.removedAt) throw notFound('Skill source not found.');
|
||||
@@ -98,20 +122,23 @@ export function skillSourceService(db: Db) {
|
||||
const identity = createHash('sha256').update(`${scan.repositoryId}:${sourceId}${revision ? `:${revision}` : ''}:${skillPath}`).digest('hex').slice(0, 16);
|
||||
return `github/${scan.repositoryId}/${identity}/${normalizeAgentUrlKey(name) || 'skill'}`;
|
||||
}
|
||||
async function publish(tx: Tx, source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], excludedFolders: string[], context: SkillSourceContext, selectionReviewed: boolean): Promise<Omit<SkillSourceRefreshResult, 'source'>> {
|
||||
async function publish(tx: Tx, source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], excludedFolders: string[], context: SkillSourceContext, selectionReviewed: boolean, prepared: Map<string, string>): Promise<Omit<SkillSourceRefreshResult, 'source'>> {
|
||||
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
|
||||
const mode = scan.packageMode ?? 'skills';
|
||||
if (mode === 'repository' && scan.skills.length > 0 && (scan.repositoryPackage?.error || !scan.repositoryFiles?.length)) {
|
||||
throw unprocessable(scan.repositoryPackage?.error ?? 'Repository package has no files. Scan it again before importing.');
|
||||
}
|
||||
let repositorySnapshotId: string | undefined;
|
||||
const repositoryHash = mode === 'repository' && scan.repositoryFiles?.length && !scan.repositoryPackage?.error ? skillSnapshotHash(scan.repositoryFiles) : null;
|
||||
if (repositoryHash) {
|
||||
await tx.insert(companySkillRepositorySnapshots).values({ companyId: source.companyId, contentHash: repositoryHash, fileInventory: scan.repositoryFiles! })
|
||||
.onConflictDoNothing();
|
||||
const [snapshot] = await tx.select({ id: companySkillRepositorySnapshots.id }).from(companySkillRepositorySnapshots).where(and(
|
||||
eq(companySkillRepositorySnapshots.companyId, source.companyId), eq(companySkillRepositorySnapshots.contentHash, repositoryHash)));
|
||||
repositorySnapshotId = snapshot!.id;
|
||||
let repositorySnapshotId = mode === 'repository' ? prepared.values().next().value : undefined;
|
||||
const hasSelected = scan.skills.some(candidate => selectedPaths.includes(candidate.path) && !candidate.error);
|
||||
const repositoryHash = mode === 'repository' && hasSelected ? skillSnapshotHash(scan.repositoryFiles!) : null;
|
||||
if (repositoryHash && !repositorySnapshotId) {
|
||||
// Compatibility writer for a rolling deployment. Never save an otherwise
|
||||
// unused repository when no valid skill is selected.
|
||||
const [saved] = await tx.insert(companySkillRepositorySnapshots).values({ companyId: source.companyId,
|
||||
contentHash: repositoryHash, fileInventory: scan.repositoryFiles! }).onConflictDoNothing().returning({ id: companySkillRepositorySnapshots.id });
|
||||
repositorySnapshotId = saved?.id ?? (await tx.select({ id: companySkillRepositorySnapshots.id })
|
||||
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, source.companyId),
|
||||
eq(companySkillRepositorySnapshots.contentHash, repositoryHash))))[0]!.id;
|
||||
}
|
||||
const previous = await tx.select().from(entries).where(and(eq(entries.companyId, source.companyId), eq(entries.sourceId, source.id)));
|
||||
const imported: CompanySkill[] = [], updated: CompanySkill[] = [];
|
||||
@@ -157,6 +184,9 @@ export function skillSourceService(db: Db) {
|
||||
if (changed) {
|
||||
const version = await skills.createVersion(source.companyId, skillId!, { label: `GitHub ${scan.commitSha.slice(0, 8)}` }, context.actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill: installed, fileInventory: candidate.files,
|
||||
packageSnapshotId: prepared.get(candidate.path),
|
||||
packageFileMap: repositorySnapshotId && prepared.has(candidate.path) ? candidate.files.map(file => ({ path: file.path, kind: file.kind,
|
||||
sourcePath: file.path === 'SKILL.md' ? candidate.path : path.posix.join(path.posix.dirname(candidate.path), file.path) })) : null,
|
||||
...(repositorySnapshotId ? { repositorySnapshotId, repositorySkillPath: candidate.path } : {}),
|
||||
});
|
||||
installed.currentVersionId = version.id;
|
||||
@@ -203,8 +233,11 @@ export function skillSourceService(db: Db) {
|
||||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: scan.repositoryUrl,
|
||||
skillKey: newSkillKey(scan, candidate.path, candidate.name, id) });
|
||||
}
|
||||
const prepared = await preparePackages(companyId, scan, input.selectedPaths, id, context.signal);
|
||||
context.signal?.throwIfAborted();
|
||||
const published = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
const [source] = await tx.insert(sources).values({ id, companyId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl, fullName: scan.fullName,
|
||||
packageMode: scan.packageMode ?? 'skills', trackingRef: context.projectBindingId && input.trackingRef === 'HEAD' ? 'HEAD' : scan.trackingRef, connectionId: scan.connectionId ?? input.connectionId ?? null, lastAttemptAt: new Date(),
|
||||
accessScope: context.projectBindingId ? 'projects' : 'company', importNewSkills: Boolean(context.projectBindingId),
|
||||
@@ -215,7 +248,7 @@ export function skillSourceService(db: Db) {
|
||||
if (!binding?.active || (context.projectBindingLeaseToken && binding.leaseToken !== context.projectBindingLeaseToken)) throw conflict('Project repository changed during import.');
|
||||
await tx.update(projectSkillSources).set({ sourceId: source.id, status: 'ready', lastError: null }).where(eq(projectSkillSources.id, binding.id));
|
||||
}
|
||||
const result = await publish(tx, source, scan, input.selectedPaths, input.excludedFolders ?? [], context, true);
|
||||
const result = await publish(tx, source, scan, input.selectedPaths, input.excludedFolders ?? [], context, true, prepared);
|
||||
return { ...result, source: await detail(companyId, id, tx) };
|
||||
});
|
||||
context.publishCommittedActivity?.();
|
||||
@@ -289,11 +322,14 @@ export function skillSourceService(db: Db) {
|
||||
}
|
||||
}
|
||||
await authorizeScan(source, scan, selectedPaths, context);
|
||||
const prepared = await preparePackages(companyId, scan, selectedPaths, id, options.signal ?? context.signal);
|
||||
(options.signal ?? context.signal)?.throwIfAborted();
|
||||
const published = await db.transaction(async tx => {
|
||||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update');
|
||||
if (!locked || locked.leaseToken !== token || locked.revision !== source.revision || locked.leaseExpiresAt! < new Date()) throw conflict('Source refresh expired or changed. Try again.');
|
||||
const result = await publish(tx, locked, scan, selectedPaths, selection?.excludedFolders ?? source.excludedFolders, context, Boolean(selection));
|
||||
const result = await publish(tx, locked, scan, selectedPaths, selection?.excludedFolders ?? source.excludedFolders, context, Boolean(selection), prepared);
|
||||
if (selection || scan.connectionId !== undefined) await tx.update(sources).set({ ...(selection ? { enabled: true } : {}), connectionId: scan.connectionId ?? connectionId,
|
||||
...(source.refreshGrantId === null && reader.grantId ? { refreshGrantId: reader.grantId } : {}),
|
||||
...(selection?.connectionId !== undefined || (!source.refreshUserId && !source.refreshLocalTrusted && context.setup) ? { refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId ?? null, refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader.grantId ?? null } : {}) }).where(scope(companyId, id));
|
||||
@@ -383,6 +419,7 @@ export function skillSourceService(db: Db) {
|
||||
}
|
||||
async function remove(tx: Tx, copiedCount: number, deletedCount: number) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
// Stop both pending attachments and an in-flight refresh before removing tracking.
|
||||
await tx.update(projectSkillSources).set({ active: false, leaseToken: null, leaseExpiresAt: null })
|
||||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id)));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { companySkillVersions, companySkillRepositorySnapshots, heartbeatRuns,
|
||||
import { companySkillVersions, heartbeatRuns,
|
||||
nativeRunFinalizations, environmentLeases, withDedicatedDbConnection, type Db } from "@paperclipai/db";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { assertSkillSnapshotPath } from "./skill-snapshot.js";
|
||||
@@ -16,11 +17,6 @@ export const SKILL_VERSION_CACHE_IDLE_MS = 60 * 60 * 1000;
|
||||
const locks = new AsyncLocalStorage<Array<{ db: Db; companyId: string }>>();
|
||||
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
export class SkillCacheBusy extends Error {}
|
||||
// Repository snapshots are immutable and referenced with restrictive foreign
|
||||
// keys. Cache only validated entrypoint names, never large file contents, so an
|
||||
// hourly sweep does not transfer the same repository for each skill/version.
|
||||
const verifiedSnapshots = new WeakMap<Db, Map<string, ReadonlySet<string>>>();
|
||||
|
||||
/** Hold through publication AND durable run pinning, not just file creation. */
|
||||
export async function withSkillVersionCacheLock<T>(db: Db, companyId: string, action: () => Promise<T>, tryOnly = false): Promise<T> {
|
||||
const held = locks.getStore() ?? [];
|
||||
@@ -95,18 +91,8 @@ export async function lastUsedAt(filename: string): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal run can still own provider execution or recovery. Only fully settled
|
||||
// runs release their pins; resume preparation reacquires the same cache lock.
|
||||
export async function protectedVersions(db: Db, companyId: string): Promise<Set<string> | null> {
|
||||
const rows = await db.select({
|
||||
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
pins: sql<unknown>`${heartbeatRuns.contextSnapshot}->'skillVersions'`,
|
||||
// Avoid loading large execution inputs for runs with the modern pin map.
|
||||
legacyInput: sql<unknown>`case when ${heartbeatRuns.runtimeMode} = 'native'
|
||||
and not coalesce(${heartbeatRuns.contextSnapshot} ? 'skillVersions', false)
|
||||
then ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput' end`,
|
||||
})
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), sql`(
|
||||
export function skillRunNeedsFilesSql() {
|
||||
return sql`(
|
||||
${heartbeatRuns.status} not in ('succeeded', 'failed', 'timed_out', 'cancelled', 'interrupted')
|
||||
or ${heartbeatRuns.finishedAt} is null
|
||||
or ${heartbeatRuns.controllerLeaseExpiresAt} > now()
|
||||
@@ -119,7 +105,21 @@ export async function protectedVersions(db: Db, companyId: string): Promise<Set<
|
||||
or l.metadata ? 'nativeWorkspaceExportResume'
|
||||
or (l.released_at is null and not coalesce(l.status = 'retained'
|
||||
and l.lease_policy = 'reuse_by_environment' and l.cleanup_status = 'success', false))))
|
||||
)`));
|
||||
)`;
|
||||
}
|
||||
|
||||
// A terminal run can still own provider execution or recovery. Only fully settled
|
||||
// runs release their pins; resume preparation reacquires the same cache lock.
|
||||
export async function protectedVersions(db: Db, companyId: string): Promise<Set<string> | null> {
|
||||
const rows = await db.select({
|
||||
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
pins: sql<unknown>`${heartbeatRuns.contextSnapshot}->'skillVersions'`,
|
||||
// Avoid loading large execution inputs for runs with the modern pin map.
|
||||
legacyInput: sql<unknown>`case when ${heartbeatRuns.runtimeMode} = 'native'
|
||||
and not coalesce(${heartbeatRuns.contextSnapshot} ? 'skillVersions', false)
|
||||
then ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput' end`,
|
||||
})
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), skillRunNeedsFilesSql()));
|
||||
const versions = new Set<string>();
|
||||
for (const { pins, legacyInput, id, agentId, nativeIssueId } of rows) {
|
||||
if (legacyInput != null) {
|
||||
@@ -163,27 +163,27 @@ function validateInventory(inventory: CompanySkillVersionFileInventoryEntry[]) {
|
||||
}
|
||||
|
||||
export async function hasRestorableSnapshot(db: Db, companyId: string, versionId: string) {
|
||||
const [version] = await db.select().from(companySkillVersions).where(and(
|
||||
const [version] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.id, versionId)));
|
||||
if (!version) return false;
|
||||
if (version.repositorySnapshotId) {
|
||||
if (!version.repositorySkillPath) return false;
|
||||
let cache = verifiedSnapshots.get(db);
|
||||
if (!cache) { cache = new Map(); verifiedSnapshots.set(db, cache); }
|
||||
const key = `${companyId}:${version.repositorySnapshotId}`;
|
||||
const verified = cache.get(key);
|
||||
if (verified) return verified.has(version.repositorySkillPath);
|
||||
const [snapshot] = await db.select({ inventory: companySkillRepositorySnapshots.fileInventory })
|
||||
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, companyId),
|
||||
eq(companySkillRepositorySnapshots.id, version.repositorySnapshotId)));
|
||||
if (!snapshot || !validateInventory(snapshot.inventory)) return false;
|
||||
const entrypoints = new Set(snapshot.inventory.filter(file => file.encoding !== "base64"
|
||||
&& repositorySkillFrontmatter(file.content)).map(file => file.path));
|
||||
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, entrypoints);
|
||||
return entrypoints.has(version.repositorySkillPath);
|
||||
const packageId = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
const store = skillPackageStore(db);
|
||||
if (packageId) {
|
||||
if (version.repositorySnapshotId && !version.repositorySkillPath) return false;
|
||||
const manifest = await store.manifest(companyId, packageId);
|
||||
const entrypoint = version.repositorySkillPath ?? version.packageFileMap?.find(file => file.path === "SKILL.md")?.sourcePath ?? "SKILL.md";
|
||||
const entry = manifest.files.find(file => file.path === entrypoint);
|
||||
if (!entry) return false;
|
||||
// Git packages intentionally permit byte eviction. Their immutable recipe,
|
||||
// not a network probe during cleanup, is the restoration contract.
|
||||
if ((await store.descriptor(companyId, packageId)).restorable) return true;
|
||||
if (version.repositorySnapshotId && (entry.encoding === "base64" || !await store.withPackage(companyId, packageId,
|
||||
async root => repositorySkillFrontmatter(await fs.readFile(path.join(root, entrypoint), "utf8"))))) return false;
|
||||
// Never evict the only known-good bytes based on a permanent process cache.
|
||||
return store.verifyDurable(companyId, packageId);
|
||||
}
|
||||
return validateInventory(version.fileInventory) && version.fileInventory.some(file => file.path === "SKILL.md");
|
||||
const files = await store.versionFiles(version);
|
||||
return validateInventory(files) && files.some(file => file.path === "SKILL.md");
|
||||
}
|
||||
|
||||
// Counts allocated bytes without following links, matching what df can reclaim.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withSkillPackageReadScope } from "./skill-package-store.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -417,7 +418,7 @@ export function skillVersionRetentionService(db: Db, defaults: SkillVersionPrune
|
||||
async function pruneCompany(companyId: string, options: SkillVersionPruneOptions & { cursor?: CompanyCursor | null } = {}) {
|
||||
if (scanning.has(companyId)) return { ...emptyResult(), checked: 1, skipped: { scan_busy: 1 } };
|
||||
scanning.add(companyId);
|
||||
try { return await pruneCompanyPage(companyId, options); }
|
||||
try { return await withSkillPackageReadScope(db, () => pruneCompanyPage(companyId, options)); }
|
||||
finally { scanning.delete(companyId); }
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider
|
||||
|
||||
const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, input.body);
|
||||
await fs.writeFile(tempPath, input.body, { signal: input.signal });
|
||||
await fs.rename(tempPath, targetPath);
|
||||
} finally { await fs.rm(tempPath, { force: true }); }
|
||||
},
|
||||
@@ -66,7 +66,7 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider
|
||||
? input.range.end - input.range.start + 1
|
||||
: stat.size;
|
||||
return {
|
||||
stream: createReadStream(filePath, streamOptions),
|
||||
stream: createReadStream(filePath, { ...streamOptions, signal: input.signal }),
|
||||
contentLength,
|
||||
lastModified: stat.mtime,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function putS3Multipart(client: S3Client, bucket: string, key: stri
|
||||
// Scale part sizes to the S3 part-count limit while retaining stream backpressure.
|
||||
const partSize = Math.max(8 * 1024 * 1024, Math.ceil(input.contentLength / 10_000));
|
||||
try {
|
||||
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType })))).UploadId;
|
||||
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }), { abortSignal: input.signal }))).UploadId;
|
||||
if (!uploadId) throw new Error("S3 did not return a multipart upload id");
|
||||
const parts: { ETag: string; PartNumber: number }[] = [];
|
||||
for (let offset = 0; offset < input.contentLength;) {
|
||||
@@ -35,14 +35,14 @@ export async function putS3Multipart(client: S3Client, bucket: string, key: stri
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: parts.length + 1, Body: body, ContentLength: length })));
|
||||
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: parts.length + 1, Body: body, ContentLength: length }), { abortSignal: input.signal }));
|
||||
if (remaining || !result.ETag) throw new Error("S3 upload part is incomplete");
|
||||
parts.push({ ETag: result.ETag, PartNumber: parts.length + 1 });
|
||||
} finally { body.destroy(); }
|
||||
offset += length;
|
||||
}
|
||||
if (pending.length || !(await iterator.next()).done) throw new Error("S3 upload source exceeds its declared size");
|
||||
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } })));
|
||||
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } }), { abortSignal: input.signal }));
|
||||
} catch (error) {
|
||||
if (uploadId) await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId })).catch(() => {});
|
||||
throw error;
|
||||
|
||||
@@ -95,6 +95,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
|
||||
ContentType: input.contentType,
|
||||
ContentLength: input.contentLength,
|
||||
}),
|
||||
{ abortSignal: input.signal },
|
||||
));
|
||||
},
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createStorageService(provider: StorageProvider): StorageService
|
||||
body: input.body,
|
||||
contentType,
|
||||
contentLength: byteSize,
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
await provider.deleteObject({ objectKey }).catch(() => {});
|
||||
@@ -127,7 +128,7 @@ export function createStorageService(provider: StorageProvider): StorageService
|
||||
|
||||
async getObject(companyId: string, objectKey: string, options) {
|
||||
ensureCompanyPrefix(companyId, objectKey);
|
||||
return provider.getObject({ objectKey, range: options?.range });
|
||||
return provider.getObject({ objectKey, range: options?.range, signal: options?.signal });
|
||||
},
|
||||
|
||||
async headObject(companyId: string, objectKey: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Readable } from "node:stream";
|
||||
|
||||
export interface PutObjectInput {
|
||||
objectKey: string;
|
||||
signal?: AbortSignal;
|
||||
// Readable bodies stream straight to the backend (contentLength must be the
|
||||
// exact byte size); Buffer stays supported for small payloads.
|
||||
body: Buffer | Readable;
|
||||
@@ -45,6 +46,7 @@ export interface StorageProvider {
|
||||
}
|
||||
|
||||
export type PutFileInput = {
|
||||
signal?: AbortSignal;
|
||||
/** Server-allocated, company-prefixed key for durable idempotent uploads. Never accept from client input. */
|
||||
objectKey?: string;
|
||||
companyId: string;
|
||||
@@ -65,7 +67,7 @@ export interface PutFileResult {
|
||||
export interface StorageService {
|
||||
provider: StorageProviderId;
|
||||
putFile(input: PutFileInput): Promise<PutFileResult>;
|
||||
getObject(companyId: string, objectKey: string, options?: Pick<GetObjectInput, "range">): Promise<GetObjectResult>;
|
||||
getObject(companyId: string, objectKey: string, options?: Pick<GetObjectInput, "range" | "signal">): Promise<GetObjectResult>;
|
||||
headObject(companyId: string, objectKey: string): Promise<HeadObjectResult>;
|
||||
deleteObject(companyId: string, objectKey: string): Promise<void>;
|
||||
}
|
||||
Reference in new issue
Block a user