mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent files and installed skills need durable storage before a legacy volume can be removed. > - Their maintenance commands currently require every writer to stop. > - Normal file edits already use agent row locks and skill publication locks. > - Shared agent heads also remain durable while their agents run. > - This pull request uses those owners to copy settled files online and keeps unresolved recovery states intact. > - The benefit is useful migration progress while unrelated work continues. ## Linked Issues or Issue Description Related: #19642. **What happened?** Agent-file adoption and skill installation conversion reject apply requests without a global stopped-writer acknowledgement. Agent-file maintenance also reports an active agent as blocked when its shared head is already durable. Live enrollment omits the existing unresolved-local-instruction-copy guard. **Expected behavior** Copy settled agent files and skill installations under their existing writer locks. Keep old skill directories, versions, and inline bytes. Keep unresolved local copies and unknown historical pin obligations visible to their owners. **Steps to reproduce** Run agent-file maintenance with `--apply` on the original host with durable storage enabled. A managed agent without active execution is rejected without `--writers-stopped`. The same gate rejects skill installation conversion. An agent that already has a shared head is also reported as blocked while it runs. ## What Changed - Adopt settled agent files under the existing agent row lock. Recognize an existing shared head before checking active execution, without rewriting its projection. - Reuse the local working-copy preservation predicate for first adoption and live enrollment. Keep its original public export. - Prepare skill uploads outside the shared run-pinning lock. Verify the exact local tree and source identity under the cache, slug, and row publication locks. Publish catalog origin history in that same transaction. - Retain old skill directories, versions, and inline bytes. Keep pin retention and volume-removal guards unchanged. - Add PostgreSQL and filesystem coverage for active durable heads, unresolved copies, concurrent source edits, and linked files. Update the maintenance docs. ## Verification - Agent-file durability and live enrollment suites: 16 tests passed with real PostgreSQL and filesystem fixtures. - Managed skill suite: 22 tests passed. Seven final controls also passed: upload-time file edits, source relocation, unknown historical pins, hard-linked files, pinning during a blocked upload, and both rejected/successful catalog-origin publication. - Server source typecheck and focused changed-test typecheck passed. - Module boundaries, diff whitespace, and added-line credential/internal-reference scans passed. - Baseline production rejected both online paths at their stopped-writer guards. Review regressions also reproduced a busy pinning lock during upload and extra catalog history after rejection; their fixes passed real PostgreSQL controls. All expected red results are preserved. - One final-control attempt failed the existing 30-second fixture startup hook before executing tests. A sequential retry passed all four controls without changing guards or timeouts; the failed attempt remains recorded. - Exact head `001a5beed9d0e55c7c178906fbad1f9a47f87788`: [required CI passed](https://github.com/paperclipai/paperclip/actions/runs/42309446426), including typecheck, build, tests, Runner and end-to-end gates. Greptile completed successfully at 5/5 with zero unresolved review threads; GitHub reports mergeable/clean. ## Risks Changed or unsafe sources remain blocked. Storage failures keep the original files and recovery state. This conversion does not settle retained provider copies or historical execution. Unknown pin obligations still block retention and volume removal; this PR alone does not qualify removal of the original volume. ## Model Used OpenAI GPT-6 (Codex). The runtime did not expose a more specific backend model ID or context-window size. Used reasoning, code editing, and tool execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
648 lines
46 KiB
TypeScript
648 lines
46 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { createS3StorageProvider } from "../storage/s3-provider.js";
|
|
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
|
|
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
|
import { skillPackageStore } from "../services/skill-package-store.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, storageProvider?: "s3" | "local_disk") => 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,
|
|
...(storageProvider === undefined ? {} : { PAPERCLIP_STORAGE_PROVIDER: storageProvider }) },
|
|
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 })]);
|
|
if (env.PAPERCLIP_STORAGE_PROVIDER === "s3") {
|
|
const legacyVerification = JSON.parse((await run("verification", "local_disk")).stdout.trim().split("\n").at(-1)!);
|
|
expect(legacyVerification.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "local_storage" })]);
|
|
}
|
|
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");
|
|
expect((await maintenance.installations({ id: legacy!.id, apply: 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("copies an installation online while preserving unknown old pins, old inline bytes, files, and active work", async () => {
|
|
const companyId = await company(), directory = path.join(root, `copy-only-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Preserved skill");
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: "copy-only", slug: "copy-only", name: "Copy only",
|
|
markdown: "# Preserved skill", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
|
|
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "# Preserved skill" }];
|
|
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill.id,
|
|
revisionNumber: 1, fileInventory: files }).returning();
|
|
await db.update(companySkills).set({ currentVersionId: version.id }).where(eq(companySkills.id, skill.id));
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Unrelated active worker" }).returning();
|
|
const [active] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, runtimeMode: "native",
|
|
status: "running", contextSnapshot: {} }).returning();
|
|
const maintenance = skillInstallationMaintenance(db);
|
|
expect((await maintenance.installations({ id: skill.id, apply: true })).results)
|
|
.toEqual([expect.objectContaining({ skillId: skill.id, status: "converted" })]);
|
|
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0].fileInventory).toEqual(files);
|
|
expect(await fs.readFile(path.join(directory, "SKILL.md"), "utf8")).toBe("# Preserved skill");
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, active.id)))[0]).toEqual(active);
|
|
// Copy-only publication does not release retention or volume-removal guards.
|
|
expect((await maintenance.eligibility({ id: skill.id })).results[0].status).toBe("unsupported_active_run");
|
|
});
|
|
|
|
it.each(["files", "source_locator"])("keeps a concurrent %s change instead of publishing the earlier conversion", async change => {
|
|
const companyId = await company(), directory = path.join(root, `conversion-race-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Race");
|
|
await fs.writeFile(path.join(directory, "support.txt"), "old version");
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: "conversion-race", slug: "conversion-race", name: "Race",
|
|
markdown: "# Race", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }],
|
|
metadata: { sourceKind: "managed_local" } }).returning();
|
|
const version = await companySkillService(db).createVersion(companyId, skill.id);
|
|
await fs.writeFile(path.join(directory, "support.txt"), "captured unsaved edit");
|
|
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
|
|
let changed = false;
|
|
const nextDirectory = path.join(root, `new-source-${randomUUID()}`);
|
|
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
|
|
const saved = await put(input);
|
|
if (!changed) {
|
|
changed = true;
|
|
if (change === "files") await fs.writeFile(path.join(directory, "support.txt"), "newer unsaved edit");
|
|
else {
|
|
await fs.mkdir(nextDirectory);
|
|
await fs.writeFile(path.join(nextDirectory, "SKILL.md"), "# New source");
|
|
await db.transaction(async tx => {
|
|
await tx.select().from(companySkills).where(eq(companySkills.id, skill.id)).for("update");
|
|
await tx.update(companySkills).set({ sourceLocator: nextDirectory }).where(eq(companySkills.id, skill.id));
|
|
});
|
|
}
|
|
}
|
|
return saved;
|
|
});
|
|
try {
|
|
const result = await skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true });
|
|
expect(changed).toBe(true);
|
|
expect(result.results).toEqual([expect.objectContaining({ status: "blocked" })]);
|
|
const [current] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
|
expect(current).toMatchObject({ sourceType: "local_path", currentVersionId: version.id,
|
|
sourceLocator: change === "files" ? directory : nextDirectory });
|
|
expect(await fs.readFile(path.join(directory, "support.txt"), "utf8"))
|
|
.toBe(change === "files" ? "newer unsaved edit" : "captured unsaved edit");
|
|
} finally { upload.mockRestore(); }
|
|
});
|
|
|
|
it("holds a hard-linked local skill instead of adopting another file owner's mutable bytes", async () => {
|
|
const companyId = await company(), directory = path.join(root, `linked-conversion-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
const original = path.join(root, `shared-content-${randomUUID()}.md`);
|
|
await fs.writeFile(original, "# Shared source");
|
|
await fs.link(original, path.join(directory, "SKILL.md"));
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: "linked-conversion", slug: "linked-conversion", name: "Linked",
|
|
markdown: "# Shared source", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
|
|
expect((await skillInstallationMaintenance(db).installations({ id: skill.id, apply: true })).results)
|
|
.toEqual([expect.objectContaining({ status: "blocked" })]);
|
|
expect((await db.select().from(companySkills).where(eq(companySkills.id, skill.id)))[0]).toEqual(skill);
|
|
expect(await fs.readFile(original, "utf8")).toBe("# Shared source");
|
|
});
|
|
|
|
it("allows new durable skill pinning while an installation upload is blocked", async () => {
|
|
const companyId = await company(), directory = path.join(root, `pinning-during-copy-${randomUUID()}`);
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Old selection");
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: "online-pinning", slug: "online-pinning", name: "Pinning",
|
|
markdown: "# Old selection", sourceType: "local_path", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
|
|
const version = await companySkillService(db).createVersion(companyId, skill.id);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), `# Current local files ${randomUUID()}`);
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "New work" }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "queued" }).returning();
|
|
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
|
|
let release!: () => void, ready!: () => void, blocked = false;
|
|
const barrier = new Promise<void>(resolve => { release = resolve; }), uploading = new Promise<void>(resolve => { ready = resolve; });
|
|
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
|
|
if (!blocked) { blocked = true; ready(); await barrier; }
|
|
return put(input);
|
|
});
|
|
const conversion = skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true });
|
|
try {
|
|
await uploading;
|
|
// This uses the real nonblocking PostgreSQL owner, not a timing assertion.
|
|
await withSkillVersionCacheLock(db, companyId, async () => {
|
|
await db.update(heartbeatRuns).set({ status: "running", contextSnapshot: { skillVersions: { [skill.key]: version.id } } })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
}, true);
|
|
} finally { release(); await conversion; upload.mockRestore(); }
|
|
expect((await conversion).results).toEqual([expect.objectContaining({ status: "converted" })]);
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0])
|
|
.toMatchObject({ status: "running", contextSnapshot: { skillVersions: { [skill.key]: version.id } } });
|
|
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0]).toBeDefined();
|
|
});
|
|
|
|
it.each([true, false])("keeps catalog history unchanged on rejection and preserves its reset version (concurrent edit: %s)", async concurrentEdit => {
|
|
const companyId = await company(), directory = path.join(root, `catalog-copy-${randomUUID()}`), origin = `${directory}-original`;
|
|
await fs.mkdir(directory); await fs.mkdir(origin);
|
|
await fs.writeFile(path.join(directory, "SKILL.md"), "# Installed edit");
|
|
await fs.writeFile(path.join(origin, "SKILL.md"), "# Original package");
|
|
const [skill] = await db.insert(companySkills).values({ companyId, key: "catalog-copy", slug: "catalog-copy", name: "Catalog",
|
|
markdown: "# Installed edit", sourceType: "catalog", sourceLocator: directory,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: {
|
|
sourceKind: "catalog", originSnapshotLocator: origin, userModifiedAt: new Date().toISOString(),
|
|
} }).returning();
|
|
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill.id, revisionNumber: 1,
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Installed edit" }] }).returning();
|
|
await db.update(companySkills).set({ currentVersionId: version.id }).where(eq(companySkills.id, skill.id));
|
|
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
|
|
let changed = false;
|
|
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
|
|
const saved = await put(input);
|
|
if (!changed) {
|
|
changed = true;
|
|
if (concurrentEdit) await fs.writeFile(path.join(origin, "SKILL.md"), "# Concurrent original edit");
|
|
}
|
|
return saved;
|
|
});
|
|
try {
|
|
expect((await skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true })).results)
|
|
.toEqual([expect.objectContaining({ status: concurrentEdit ? "blocked" : "converted" })]);
|
|
expect(changed).toBe(true);
|
|
const history = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill.id));
|
|
expect(history).toHaveLength(concurrentEdit ? 1 : 2);
|
|
expect(history.find(row => row.id === version.id)).toMatchObject({ fileInventory: version.fileInventory });
|
|
const [current] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
|
if (concurrentEdit) expect(current.metadata?.originVersionId).toBeUndefined();
|
|
else {
|
|
const original = history.find(row => row.id !== version.id)!;
|
|
expect(current.metadata?.originVersionId).toBe(original.id);
|
|
const store = skillPackageStore(db, storage), saved = await store.version(companyId, skill.id, original.id);
|
|
expect((await store.versionFiles(saved!)).find(file => file.path === "SKILL.md")?.content).toBe("# Original package");
|
|
}
|
|
expect(await fs.readFile(path.join(origin, "SKILL.md"), "utf8"))
|
|
.toBe(concurrentEdit ? "# Concurrent original edit" : "# Original package");
|
|
} finally { upload.mockRestore(); }
|
|
});
|
|
|
|
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"; }
|
|
});
|
|
|
|
});
|