mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
485 lines
34 KiB
TypeScript
485 lines
34 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { createS3StorageProvider } from "../storage/s3-provider.js";
|
|
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
|
|
import { removeRuntimeAssetTree } from "../services/native-runtime/runtime-asset-retention.js";
|
|
import { eq } from "drizzle-orm";
|
|
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
|
import { materializeSelectedSkills, rehydratePinnedSkillAssets } from "../services/native-runtime/runtime-context.js";
|
|
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { applyPendingMigrations, agents, heartbeatRuns, companySkills, companySkillVersions, companies, createDb, instanceSettings } from "@paperclipai/db";
|
|
import { companySkillService } from "../services/company-skills.js";
|
|
import * as storageModule from "../storage/index.js";
|
|
import { createStorageService } from "../storage/service.js";
|
|
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
const externalDatabase = process.env.PAPERCLIP_SKILL_TEST_DATABASE_URL;
|
|
const support = externalDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
if (!support.supported) console.warn("Managed skill integration database unavailable:", support);
|
|
describe.skipIf(!support.supported)("managed skills use committed versions", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let root: string;
|
|
let home: string;
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
|
const previousStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
|
beforeAll(async () => {
|
|
if (externalDatabase) {
|
|
await applyPendingMigrations(externalDatabase);
|
|
database = { connectionString: externalDatabase, cleanup: async () => {} };
|
|
} else database = await startEmbeddedPostgresTestDatabase("managed-skill-content-");
|
|
db = createDb(database.connectionString);
|
|
root = await fs.mkdtemp(path.join(os.tmpdir(), "managed-skill-content-"));
|
|
home = path.join(root, "server-a");
|
|
process.env.PAPERCLIP_HOME = home;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
|
vi.spyOn(storageModule, "getStorageService").mockReturnValue(
|
|
createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))),
|
|
);
|
|
}, 30_000);
|
|
afterAll(async () => {
|
|
vi.restoreAllMocks();
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousHome;
|
|
if (previousStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
|
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousStorage;
|
|
await db?.$client.end({ timeout: 1 });
|
|
await database?.cleanup();
|
|
if (root) await removeRuntimeAssetTree(root);
|
|
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
|
|
});
|
|
async function company() {
|
|
const id = randomUUID();
|
|
await db.insert(companies).values({ id, name: "Managed content", issuePrefix: `M${id.slice(0, 6)}` });
|
|
return id;
|
|
}
|
|
|
|
it("imports supporting files from a root-level package without truncating their paths", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const [imported] = await service.importPackageFiles(companyId, {
|
|
"SKILL.md": "---\nname: Root package\n---\n# Root\n",
|
|
"references/guide.md": "complete root guide",
|
|
});
|
|
expect(imported).toBeTruthy();
|
|
const skills = await service.list(companyId);
|
|
const skill = skills.find(entry => entry.name === "Root package")!;
|
|
expect(await service.readFile(companyId, skill.id, "references/guide.md"))
|
|
.toMatchObject({ content: "complete root guide" });
|
|
});
|
|
|
|
it("loads persisted Cloud configuration for read-only maintenance without seeding it", async () => {
|
|
const companyId = await company();
|
|
const objects = new Map<string, Buffer>();
|
|
const server = createServer((request, response) => {
|
|
void (async () => {
|
|
const key = new URL(request.url!, "http://fixture").pathname;
|
|
if (request.method === "PUT") {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
let body = Buffer.concat(chunks);
|
|
if (request.headers["content-encoding"]?.includes("aws-chunked")) {
|
|
const decoded: Buffer[] = [];
|
|
let offset = 0;
|
|
while (offset < body.length) {
|
|
const end = body.indexOf("\r\n", offset);
|
|
const size = Number.parseInt(body.subarray(offset, end).toString().split(";")[0]!, 16);
|
|
if (size === 0) break;
|
|
decoded.push(body.subarray(end + 2, end + 2 + size)); offset = end + 2 + size + 2;
|
|
}
|
|
body = Buffer.concat(decoded);
|
|
}
|
|
objects.set(key, body); response.writeHead(200, { etag: '"fixture"' }).end(); return;
|
|
}
|
|
const body = objects.get(key);
|
|
if (!body) { response.writeHead(404).end(); return; }
|
|
response.writeHead(200, { "content-length": body.length, "content-type": "application/octet-stream", etag: '"fixture"' });
|
|
response.end(request.method === "HEAD" ? undefined : body);
|
|
})().catch(() => { response.writeHead(500).end(); });
|
|
});
|
|
await new Promise<void>(resolve => { server.listen(0, "127.0.0.1", resolve); });
|
|
const address = server.address(); if (!address || typeof address === "string") throw new Error("Expected fixture address");
|
|
const endpoint = `http://127.0.0.1:${address.port}`;
|
|
const credentials = { AWS_ACCESS_KEY_ID: "fixture-access-key", AWS_SECRET_ACCESS_KEY: "fixture-secret-key", AWS_SESSION_TOKEN: "" };
|
|
const previousCredentials = Object.fromEntries(Object.keys(credentials).map(key => [key, process.env[key]]));
|
|
Object.assign(process.env, credentials);
|
|
vi.mocked(storageModule.getStorageService).mockReturnValue(createStorageService(createS3StorageProvider({
|
|
bucket: "managed-skill-fixture", region: "us-east-1", endpoint, forcePathStyle: true,
|
|
})));
|
|
const key = "cloud-runtime-config:v1";
|
|
try {
|
|
await companySkillService(db).createLocalSkill(companyId, { name: "Cloud CLI", markdown: "# Cloud CLI" });
|
|
const run = (phase: string) => promisify(execFile)(process.execPath, [
|
|
"--import", fileURLToPath(import.meta.resolve("tsx")),
|
|
fileURLToPath(new URL("../../scripts/migrate-skill-packages.ts", import.meta.url)),
|
|
`--phase=${phase}`, `--company=${companyId}`, "--limit=50",
|
|
], { env: { ...process.env, ...credentials, DATABASE_URL: database.connectionString,
|
|
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "disposable-cloud-cli-fixture",
|
|
PAPERCLIP_SECRETS_MASTER_KEY: "a".repeat(64), PAPERCLIP_STORAGE_PROVIDER: "s3",
|
|
PAPERCLIP_STORAGE_S3_BUCKET: "managed-skill-fixture", PAPERCLIP_STORAGE_S3_REGION: "us-east-1",
|
|
PAPERCLIP_STORAGE_S3_ENDPOINT: endpoint, PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: "true", PAPERCLIP_STORAGE_S3_PREFIX: "" },
|
|
timeout: 60_000, maxBuffer: 1024 * 1024 });
|
|
await expect(run("eligibility")).rejects.toThrow("Initialize Cloud runtime configuration");
|
|
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toHaveLength(0);
|
|
await db.insert(instanceSettings).values({ singletonKey: key, general: { version: 1, revision: 0, config: null } });
|
|
const eligibility = JSON.parse((await run("eligibility")).stdout.trim().split("\n").at(-1)!);
|
|
expect(eligibility.results).toEqual([expect.objectContaining({ status: "version_backed" })]);
|
|
const before = [...objects.entries()].map(([key, body]) => [key, Buffer.from(body)] as const);
|
|
const verification = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
|
expect(verification.results).toEqual([expect.objectContaining({ status: "remote_storage" })]);
|
|
expect([...objects.entries()]).toEqual(before);
|
|
const archiveKey = [...objects.keys()].find(key => key.endsWith("/files.tar.gz"))!;
|
|
const archive = objects.get(archiveKey)!;
|
|
const corrupted = Buffer.from(archive); corrupted[0] = corrupted[0]! ^ 1;
|
|
objects.set(archiveKey, corrupted);
|
|
const corrupt = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
|
expect(corrupt.results).toEqual([expect.objectContaining({ status: "corrupt_package" })]);
|
|
objects.set(archiveKey, archive);
|
|
const manifestKey = [...objects.keys()].find(key => key.endsWith("/manifest.json"))!;
|
|
const manifest = objects.get(manifestKey)!; objects.delete(manifestKey);
|
|
const unavailable = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
|
expect(unavailable.results).toEqual([expect.objectContaining({ status: "unavailable_or_corrupt_package" })]);
|
|
objects.set(manifestKey, manifest);
|
|
const recovered = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
|
expect(recovered.results).toEqual([expect.objectContaining({ status: "remote_storage" })]);
|
|
expect([...objects.entries()].sort()).toEqual([...before].sort());
|
|
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key)))
|
|
.toEqual([expect.objectContaining({ general: { version: 1, revision: 0, config: null } })]);
|
|
} finally {
|
|
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, key));
|
|
vi.mocked(storageModule.getStorageService).mockReturnValue(createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))));
|
|
for (const [key, value] of Object.entries(previousCredentials)) {
|
|
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
}
|
|
server.closeAllConnections(); await new Promise<void>(resolve => { server.close(() => resolve()); });
|
|
}
|
|
}, 180_000);
|
|
|
|
it.each([
|
|
{ storage: "legacy local", env: { PAPERCLIP_STORAGE_PROVIDER: "local_disk" }, expectedStatus: "local_storage" },
|
|
{ storage: "shared S3", env: { PAPERCLIP_STORAGE_PROVIDER: "s3",
|
|
PAPERCLIP_STORAGE_S3_BUCKET: "managed-skill-cli-fixture", PAPERCLIP_STORAGE_S3_REGION: "us-east-1",
|
|
PAPERCLIP_SECRETS_MASTER_KEY: "12345678901234567890123456789012" }, expectedStatus: "provider_mismatch" },
|
|
])("loads persisted Cloud configuration for read-only $storage maintenance without seeding it", async ({ env, expectedStatus }) => {
|
|
const companyId = await company();
|
|
const skill = await companySkillService(db).createLocalSkill(companyId, { name: "Cloud CLI", markdown: "# Cloud CLI" });
|
|
const local = await skillInstallationMaintenance(db).verifyVersions({ companyId, limit: 50 });
|
|
expect(local.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "local_storage" })]);
|
|
const key = "cloud-runtime-config:v1";
|
|
const run = (phase: string) => promisify(execFile)(process.execPath, [
|
|
"--import", fileURLToPath(import.meta.resolve("tsx")),
|
|
fileURLToPath(new URL("../../scripts/migrate-skill-packages.ts", import.meta.url)),
|
|
`--phase=${phase}`, `--company=${companyId}`, "--limit=50",
|
|
], { env: { ...process.env, DATABASE_URL: database.connectionString,
|
|
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "disposable-cloud-cli-fixture",
|
|
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(root, "objects"), ...env },
|
|
timeout: 60_000, maxBuffer: 1024 * 1024 });
|
|
await expect(run("eligibility")).rejects.toThrow("Initialize Cloud runtime configuration");
|
|
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toHaveLength(0);
|
|
const [persisted] = await db.insert(instanceSettings).values({ singletonKey: key, general: { version: 1, revision: 0, config: null } }).returning();
|
|
try {
|
|
const eligibility = JSON.parse((await run("eligibility")).stdout.trim().split("\n").at(-1)!);
|
|
expect(eligibility.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "version_backed" })]);
|
|
const verification = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
|
expect(verification.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: expectedStatus })]);
|
|
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toEqual([persisted]);
|
|
} finally {
|
|
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, key));
|
|
}
|
|
}, 180_000);
|
|
|
|
it("forks live linked-local content even when an older saved version exists", async () => {
|
|
const companyId = await company();
|
|
const directory = path.join(root, `linked-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Linked\n");
|
|
await fs.writeFile(path.join(directory, "support.txt"), "old saved content");
|
|
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
|
|
markdown: "# Linked\n", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
|
|
const service = companySkillService(db);
|
|
const saved = await service.createVersion(companyId, linked!.id);
|
|
await fs.writeFile(path.join(directory, "support.txt"), "current local content");
|
|
await fs.writeFile(path.join(directory, "added.txt"), "new local file");
|
|
const forked = await service.createLocalSkill(companyId, { name: "Live fork", forkedFromSkillId: linked!.id });
|
|
expect(await service.readFile(companyId, forked.id, "support.txt")).toMatchObject({ content: "current local content" });
|
|
expect(await service.readFile(companyId, forked.id, "added.txt")).toMatchObject({ content: "new local file" });
|
|
expect((await service.getVersion(companyId, linked!.id, saved.id))?.fileInventory)
|
|
.toContainEqual(expect.objectContaining({ path: "support.txt", content: "old saved content" }));
|
|
});
|
|
|
|
it("carries committed version preconditions through a multi-file linked-local restore", async () => {
|
|
const companyId = await company();
|
|
const directory = path.join(root, `restore-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Original\n");
|
|
await fs.writeFile(path.join(directory, "support.txt"), "original support");
|
|
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
|
|
markdown: "# Original\n", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
|
|
const service = companySkillService(db);
|
|
const saved = await service.createVersion(companyId, linked!.id);
|
|
const original = (await service.getVersion(companyId, linked!.id, saved.id))!;
|
|
await service.updateFile(companyId, linked!.id, "SKILL.md", "# Changed\n");
|
|
await service.updateFile(companyId, linked!.id, "support.txt", "changed support");
|
|
let versionId = (await service.readFile(companyId, linked!.id, "SKILL.md"))!.versionId;
|
|
expect(versionId).toBeTruthy();
|
|
for (const file of original.fileInventory) {
|
|
const result = await service.updateFile(companyId, linked!.id, file.path, file.content, null,
|
|
{ expectedVersionId: versionId });
|
|
expect(result.versionId).toBeTruthy();
|
|
expect(result.versionId).not.toBe(versionId);
|
|
versionId = result.versionId;
|
|
}
|
|
expect(await service.readFile(companyId, linked!.id, "SKILL.md")).toMatchObject({ content: "# Original\n", versionId });
|
|
expect(await service.readFile(companyId, linked!.id, "support.txt")).toMatchObject({ content: "original support", versionId });
|
|
});
|
|
|
|
it("keeps an unassigned skill, supporting bytes and executable mode across fresh homes", async () => {
|
|
const companyId = await company();
|
|
const a = companySkillService(db);
|
|
const skill = await a.createLocalSkill(companyId, { name: "Cold", slug: "cold", markdown: "# First\n" });
|
|
expect(skill.sourceType).toBe("managed");
|
|
expect(skill.sourceLocator).toBeNull();
|
|
expect(skill.currentVersionId).toBeTruthy();
|
|
await expect(fs.stat(path.join(home, "instances/default/skills", companyId, "cold"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
const saved = await a.updateFile(companyId, skill.id, "scripts/run.sh", "#!/bin/sh\necho cold\n", null,
|
|
{ executable: true, expectedVersionId: skill.currentVersionId });
|
|
const binary = await a.updateFile(companyId, skill.id, "data.bin", "AP8H", null,
|
|
{ encoding: "base64", expectedVersionId: saved.versionId });
|
|
await fs.rm(home, { recursive: true, force: true });
|
|
process.env.PAPERCLIP_HOME = path.join(root, "server-b");
|
|
const b = companySkillService(db);
|
|
expect((await b.list(companyId)).some(item => item.id === skill.id)).toBe(true);
|
|
expect(await b.readFile(companyId, skill.id, "data.bin")).toMatchObject({ content: "AP8H", encoding: "base64", versionId: binary.versionId });
|
|
const updated = await b.updateFile(companyId, skill.id, "SKILL.md", "# Saved on B\n", null, { expectedVersionId: binary.versionId });
|
|
const [runtime] = await b.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true });
|
|
expect(runtime).toMatchObject({ versionId: updated.versionId, sourceStatus: "available" });
|
|
expect((await fs.stat(path.join(runtime!.source, "scripts/run.sh"))).mode & 0o111).not.toBe(0);
|
|
expect(await fs.readFile(path.join(runtime!.source, "data.bin"))).toEqual(Buffer.from([0, 255, 7]));
|
|
await fs.rm(process.env.PAPERCLIP_HOME, { recursive: true, force: true });
|
|
process.env.PAPERCLIP_HOME = path.join(root, "server-c");
|
|
expect(await companySkillService(db).readFile(companyId, skill.id, "SKILL.md"))
|
|
.toMatchObject({ content: "# Saved on B\n", versionId: updated.versionId });
|
|
expect(await a.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ versionId: updated.versionId });
|
|
});
|
|
|
|
it("rejects a save whose base changed while its package was being prepared", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Race" });
|
|
await expect(service.updateFile(companyId, skill.id, "first.md", "first", null, { publication: {
|
|
beforeLock: async () => { await service.updateFile(companyId, skill.id, "second.md", "second"); },
|
|
} })).rejects.toMatchObject({ status: 409 });
|
|
expect(await service.readFile(companyId, skill.id, "second.md")).toMatchObject({ content: "second" });
|
|
await expect(service.readFile(companyId, skill.id, "first.md")).rejects.toMatchObject({ status: 404 });
|
|
await expect(service.updateFile(companyId, skill.id, "first.md", "stale", null,
|
|
{ expectedVersionId: skill.currentVersionId })).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("rolls back publication without changing the head or an existing version", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Rollback", markdown: "# Original" });
|
|
await expect(service.updateFile(companyId, skill.id, "SKILL.md", "# Rejected", null,
|
|
{ publication: { afterPublish: async () => { throw new Error("failed audit"); } } })).rejects.toThrow("failed audit");
|
|
expect(await service.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ content: "# Original", versionId: skill.currentVersionId });
|
|
expect(await service.listVersions(companyId, skill.id)).toHaveLength(1);
|
|
});
|
|
|
|
it("does not overwrite a metadata edit committed while content was being prepared", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Metadata race", markdown: "# Original" });
|
|
await expect(service.updateFile(companyId, skill.id, "SKILL.md", "# Candidate", null, { publication: {
|
|
beforeLock: async () => { await service.updateSkill(companyId, skill.id, { description: "Acknowledged metadata" }); },
|
|
} })).rejects.toMatchObject({ status: 409 });
|
|
expect(await service.getById(companyId, skill.id)).toMatchObject({ description: "Acknowledged metadata", currentVersionId: skill.currentVersionId });
|
|
expect(await service.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ content: "# Original" });
|
|
});
|
|
|
|
it("restores the exact historical inventory and preserves history through rename and fork", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "History", markdown: "# Original" });
|
|
const extra = await service.updateFile(companyId, skill.id, "new.md", "not in first version");
|
|
const restored = await service.createVersion(companyId, skill.id,
|
|
{ sourceVersionId: skill.currentVersionId!, expectedVersionId: extra.versionId });
|
|
await expect(service.readFile(companyId, skill.id, "new.md")).rejects.toMatchObject({ status: 404 });
|
|
const renamed = await service.renameSkill(companyId, skill.id, { name: "Renamed", expectedVersionId: restored.id });
|
|
expect(renamed.skill).toMatchObject({ id: skill.id, sourceType: "managed", sourceLocator: null });
|
|
expect((await service.getVersion(companyId, skill.id, skill.currentVersionId!))!.fileInventory)
|
|
.toContainEqual({ path: "SKILL.md", kind: "skill", content: "# Original" });
|
|
const fork = await service.forkSkill(companyId, skill.id);
|
|
expect(fork.skill).toMatchObject({ sourceType: "managed", sourceLocator: null, forkedFromSkillId: skill.id });
|
|
expect((await service.readFile(companyId, fork.skill.id, "SKILL.md"))!.content)
|
|
.toBe((await service.readFile(companyId, skill.id, "SKILL.md"))!.content);
|
|
});
|
|
it("persists selection before delivery and rejects deleting a detached active run's pin", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Pinned", markdown: "# pinned bytes" });
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Runner" }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "running" }).returning();
|
|
let persisted = false;
|
|
const entries = await service.withRuntimeSkillEntries(companyId, {
|
|
onlyKeys: new Set([skill.key]), snapshotLatest: true,
|
|
selectVersions: async selected => {
|
|
expect(selected).toEqual([expect.objectContaining({ skillId: skill.id, versionId: skill.currentVersionId })]);
|
|
expect(selected[0]).not.toHaveProperty("source");
|
|
await expect(fs.stat(path.join(process.env.PAPERCLIP_HOME!, "instances/default/skills", companyId,
|
|
"__versions__", skill.id, skill.currentVersionId!))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { skillVersions: { [skill.key]: skill.currentVersionId }, skillSelectionSnapshot: true } })
|
|
.where(eq(heartbeatRuns.id, run!.id));
|
|
persisted = true;
|
|
return new Set([skill.key]);
|
|
},
|
|
}, async entries => { expect(persisted).toBe(true); return entries; });
|
|
const config = writePaperclipSkillSyncPreference({}, [{ key: skill.key, versionId: skill.currentVersionId }]);
|
|
const selected = await materializeSelectedSkills(config, entries, false);
|
|
const context = { ...nativeRuntimeContextFixture(), skills: selected };
|
|
const originalHome = process.env.PAPERCLIP_HOME!;
|
|
process.env.PAPERCLIP_HOME = path.join(root, `recovery-${randomUUID()}`);
|
|
const recovered = await rehydratePinnedSkillAssets(db, { companyId, agentId: agent!.id, runId: run!.id }, context);
|
|
expect(recovered.skills[0]!.bundle.digest).toBe(selected[0]!.bundle.digest);
|
|
expect(recovered.skills[0]!.bundle.rootPath).not.toBe(selected[0]!.bundle.rootPath);
|
|
expect(await fs.readFile(path.join(recovered.skills[0]!.bundle.rootPath, "SKILL.md"), "utf8")).toBe("# pinned bytes");
|
|
await expect(rehydratePinnedSkillAssets(db, { companyId, agentId: agent!.id, runId: run!.id }, {
|
|
...context, skills: [{ ...selected[0]!, bundle: { ...selected[0]!.bundle, digest: "0".repeat(64) } }],
|
|
})).rejects.toThrow("admitted digest");
|
|
await expect(service.deleteSkill(companyId, skill.id)).rejects.toMatchObject({ status: 422 });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
|
|
await expect(service.deleteSkill(companyId, skill.id)).resolves.toMatchObject({ id: skill.id });
|
|
process.env.PAPERCLIP_HOME = originalHome;
|
|
});
|
|
|
|
it("converts a legacy managed installation without changing its identity or identical version", async () => {
|
|
const companyId = await company();
|
|
const directory = path.join(root, `legacy-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# legacy saved");
|
|
await fs.writeFile(path.join(directory, "support.txt"), "support");
|
|
const [legacy] = await db.insert(companySkills).values({ companyId, key: `company/${companyId}/legacy`, slug: "legacy",
|
|
name: "Legacy", markdown: "# legacy saved", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }],
|
|
metadata: { sourceKind: "managed_local" } }).returning();
|
|
const service = companySkillService(db);
|
|
const version = await service.createVersion(companyId, legacy!.id);
|
|
await db.update(companySkillVersions).set({ packageFileMap: null }).where(eq(companySkillVersions.id, version.id));
|
|
const maintenance = skillInstallationMaintenance(db);
|
|
expect((await maintenance.installations({ id: legacy!.id })).results[0]!.status).toBe("conversion_required");
|
|
await expect(maintenance.installations({ companyId, apply: true })).rejects.toMatchObject({ status: 409 });
|
|
expect((await maintenance.installations({ id: legacy!.id, apply: true, writersStopped: true })).results)
|
|
.toEqual([expect.objectContaining({ skillId: legacy!.id, versionId: version.id, status: "converted" })]);
|
|
await fs.rm(directory, { recursive: true });
|
|
expect(await service.getById(companyId, legacy!.id)).toMatchObject({ sourceType: "managed", sourceLocator: null, currentVersionId: version.id });
|
|
expect(await service.readFile(companyId, legacy!.id, "support.txt")).toMatchObject({ content: "support" });
|
|
expect((await maintenance.eligibility({ id: legacy!.id })).results[0]!.status).toBe("version_backed");
|
|
});
|
|
|
|
it("reports an unrecoverable legacy installation and leaves linked sources alone", async () => {
|
|
const companyId = await company();
|
|
const [legacy] = await db.insert(companySkills).values({ companyId, key: "legacy-missing", slug: "legacy-missing", name: "Missing",
|
|
markdown: "partial metadata only", sourceType: "local_path", sourceLocator: path.join(root, "absent"),
|
|
metadata: { sourceKind: "managed_local" } }).returning();
|
|
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
|
|
markdown: "# Linked", sourceType: "local_path", sourceLocator: path.join(root, "linked") }).returning();
|
|
const report = await skillInstallationMaintenance(db).installations({ companyId, apply: true, writersStopped: true });
|
|
expect(report.results).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ skillId: legacy!.id, status: "blocked" }),
|
|
expect.objectContaining({ skillId: linked!.id, status: "linked_local" }),
|
|
]));
|
|
expect(await companySkillService(db).getById(companyId, legacy!.id)).toMatchObject({ sourceType: "local_path", currentVersionId: null });
|
|
});
|
|
|
|
it("converts a missing legacy directory from its complete saved version", async () => {
|
|
const companyId = await company();
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Saved conversion", markdown: "# saved" });
|
|
const saved = await service.updateFile(companyId, skill.id, "support.txt", "complete supporting content");
|
|
await db.update(companySkills).set({ sourceType: "local_path", sourceLocator: path.join(root, "missing-legacy"),
|
|
metadata: { sourceKind: "managed_local" } }).where(eq(companySkills.id, skill.id));
|
|
const maintenance = skillInstallationMaintenance(db);
|
|
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results)
|
|
.toEqual([expect.objectContaining({ skillId: skill.id, versionId: saved.versionId, status: "converted" })]);
|
|
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]!.status).toBe("unchanged");
|
|
expect(await service.readFile(companyId, skill.id, "support.txt")).toMatchObject({ content: "complete supporting content" });
|
|
});
|
|
|
|
it.each([false, true])("blocks an incomplete installed repository version and converts only its exact source (package: %s)", async packaged => {
|
|
const companyId = await company(), service = companySkillService(db);
|
|
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = String(packaged);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Partial legacy repository", markdown: "# Installed revision" });
|
|
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
|
const commit = "a".repeat(40);
|
|
await db.update(companySkills).set({ sourceType: "github", sourceLocator: `https://github.com/fixture/skills/tree/${commit}/fixture`,
|
|
sourceRef: commit, metadata: { owner: "fixture", repo: "skills", repoSkillDir: "fixture" },
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "reference.md", kind: "reference" }],
|
|
}).where(eq(companySkills.id, skill.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unavailable", { status: 404 }));
|
|
try {
|
|
const maintenance = skillInstallationMaintenance(db);
|
|
expect((await maintenance.eligibility({ id: skill.id })).results[0]).toMatchObject({ status: "incomplete_current_version" });
|
|
expect((await maintenance.verifyVersions({ id: skill.currentVersionId! })).results[0]).toMatchObject({ status: "incomplete_current_version" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]).toMatchObject({ status: "blocked" });
|
|
expect((await db.select().from(companySkills).where(eq(companySkills.id, skill.id)))[0].currentVersionId).toBe(skill.currentVersionId);
|
|
fetchMock.mockImplementation(async url => {
|
|
expect(String(url)).toBe(`https://raw.githubusercontent.com/fixture/skills/${commit}/fixture/reference.md`);
|
|
return new Response("Exact installed supporting content");
|
|
});
|
|
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]).toMatchObject({ status: "converted" });
|
|
expect(await service.readFile(companyId, skill.id, "reference.md")).toMatchObject({ content: "Exact installed supporting content" });
|
|
expect((await maintenance.eligibility({ id: skill.id })).results[0]).toMatchObject({ status: "version_backed" });
|
|
expect((await maintenance.verifyVersions({ companyId })).results.every(row => row.status !== "incomplete_current_version")).toBe(true);
|
|
const calls = fetchMock.mock.calls.length;
|
|
expect((await maintenance.installations({ id: skill.id })).results[0]).toMatchObject({ status: "unchanged" });
|
|
expect(fetchMock).toHaveBeenCalledTimes(calls);
|
|
} finally { fetchMock.mockRestore(); }
|
|
});
|
|
|
|
it("rejects unsafe legacy sources without publishing a partial conversion", async () => {
|
|
const companyId = await company();
|
|
const directory = path.join(root, `unsafe-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# legacy");
|
|
await fs.symlink(path.join(root, "unrelated"), path.join(directory, "support.txt"));
|
|
try {
|
|
const [legacy] = await db.insert(companySkills).values({ companyId, key: "unsafe", slug: "unsafe", name: "Unsafe", markdown: "# legacy",
|
|
sourceType: "local_path", sourceLocator: directory, metadata: { sourceKind: "managed_local" } }).returning();
|
|
expect((await skillInstallationMaintenance(db).installations({ id: legacy!.id, apply: true, writersStopped: true })).results)
|
|
.toEqual([expect.objectContaining({ status: "blocked", reason: "Legacy skill contains a symbolic link" })]);
|
|
expect(await companySkillService(db).getById(companyId, legacy!.id)).toMatchObject({ sourceType: "local_path", currentVersionId: null });
|
|
} finally { await fs.unlink(path.join(directory, "support.txt")); }
|
|
});
|
|
|
|
it("keeps the compatibility override durable in Postgres and reports that storage accurately", async () => {
|
|
const companyId = await company();
|
|
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "false";
|
|
try {
|
|
const service = companySkillService(db);
|
|
const skill = await service.createLocalSkill(companyId, { name: "Inline durable", markdown: "# database bytes" });
|
|
const saved = await service.updateFile(companyId, skill.id, "support.txt", "also in Postgres");
|
|
expect((await skillInstallationMaintenance(db).verifyVersions({ id: saved.versionId! })).results)
|
|
.toEqual([expect.objectContaining({ status: "database_snapshot", packageId: null })]);
|
|
const priorHome = process.env.PAPERCLIP_HOME;
|
|
process.env.PAPERCLIP_HOME = path.join(root, `inline-cold-${randomUUID()}`);
|
|
try {
|
|
expect(await companySkillService(db).readFile(companyId, skill.id, "support.txt")).toMatchObject({ content: "also in Postgres" });
|
|
} finally { process.env.PAPERCLIP_HOME = priorHome; }
|
|
} finally { process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true"; }
|
|
});
|
|
|
|
});
|