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:
DottaandPaperclip committed 2026-10-28 17:15:19 -05:00
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();
});
});
+17 -1
View File
@@ -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');
+5
View File
@@ -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,
+2 -2
View File
@@ -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) => {
+1
View File
@@ -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);
+112 -49
View File
@@ -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>;
+5
View File
@@ -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--;
}
}
+143
View File
@@ -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 };
}
+443
View File
@@ -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);
});
});
+155
View File
@@ -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 };
}
+3 -3
View File
@@ -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;
+48 -11
View File
@@ -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)));
+37 -37
View File
@@ -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); }
}
+2 -2
View File
@@ -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,
};
+3 -3
View File
@@ -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;
+1
View File
@@ -95,6 +95,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
ContentType: input.contentType,
ContentLength: input.contentLength,
}),
{ abortSignal: input.signal },
));
},
+2 -1
View File
@@ -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 -1
View File
@@ -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>;
}