0096b56a1c
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system needs host contracts and runtime support before large plugins can integrate cleanly. > - The source branch mixed the LLM Wiki package with supporting host/runtime work, managed plugin skills, root-level storage spaces, and a bookmarks reference plugin. > - [PAP-9173](/PAP/issues/PAP-9173) asked for the current branch to be split by file boundary: plugin package separately from everything else. > - [PAP-9188](/PAP/issues/PAP-9188) clarified that LLM Wiki may have plugin-local spaces, but Paperclip core should not reorganize top-level local storage into spaces. > - Follow-up review clarified that the bookmarks example should not ship in this PR either. > - This pull request contains the non-`packages/plugins/plugin-llm-wiki/` host/runtime work, keeps runtime state under the selected Paperclip instance root, and no longer includes the bookmarks example. ## What Changed - Added/updated plugin host contracts, SDK types, worker RPC plumbing, managed plugin skill support, and related server tests. - Removed the bookmarks example plugin package and its bundled-example/workspace references. - Removed the root-level local spaces CLI/migration surface and restored instance-root runtime defaults for config, db, logs, storage, secrets, workspaces, projects, and adapter homes. - Replaced shared root `space-paths` helpers with `home-paths` helpers for core runtime storage. - Tightened stranded recovery unique-conflict detection so concurrent recovery scans reuse the raced recovery issue when Postgres errors are wrapped. - Kept `packages/plugins/plugin-llm-wiki/` out of this PR diff; plugin-local spaces remain in the stacked plugin-only PR. ## Verification - `pnpm exec vitest run cli/src/__tests__/data-dir.test.ts cli/src/__tests__/home-paths.test.ts cli/src/__tests__/onboard.test.ts packages/shared/src/home-paths.test.ts packages/db/src/runtime-config.test.ts server/src/__tests__/agent-instructions-service.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts` - `pnpm exec vitest run packages/db/src/runtime-config.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "reuses the raced stranded recovery issue"` skipped locally because embedded Postgres did not initialize on this macOS temp host; the code path was typechecked and is covered by Linux CI. - Boundary check: no core references remain for `PAPERCLIP_SPACE_ID`, `spaces migrate-default`, `@paperclipai/shared/space-paths`, `registerSpacesCommands`, or the removed bookmarks example. - Previous PR head `4f23e034` had green GitHub checks: `verify`, all four serialized server shards, `e2e`, `Canary Dry Run`, `policy`, Snyk, and `Greptile Review`. Current head `582f466d` is re-running checks after the bookmarks deletion. ## Risks - Plugin host changes touch shared runtime paths, so regressions would most likely appear in adapter startup, plugin loading, or local dev path defaults. - Removing the bookmarks example also removes one demonstration of plugin database namespaces plus local-folder persistence; remaining plugin examples still cover bundled example discovery and plugin host flows. - The plugin package itself is intentionally deferred to the stacked plugin-only PR, where LLM Wiki plugin-local spaces live. - Existing installs that tested the transient root-level spaces CLI should stop using it; this PR intentionally removes that unsupported migration surface before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via Codex CLI, tool use and local code execution enabled; context window not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass, except where noted above for host-specific embedded Postgres initialization - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Stacked follow-up: PR #5592 contains only `packages/plugins/plugin-llm-wiki/` and targets this branch. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
282 lines
9.7 KiB
TypeScript
282 lines
9.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
companies,
|
|
companySkills,
|
|
createDb,
|
|
pluginManagedResources,
|
|
plugins,
|
|
} from "@paperclipai/db";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { buildHostServices } from "../services/plugin-host-services.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function createEventBusStub() {
|
|
return {
|
|
forPlugin() {
|
|
return {
|
|
emit: async () => {},
|
|
subscribe: () => {},
|
|
};
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
function issuePrefix(id: string) {
|
|
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
}
|
|
|
|
function manifest(): PaperclipPluginManifestV1 {
|
|
return {
|
|
id: "paperclip.managed-skills-test",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Managed Skills Test",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["skills.managed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
skills: [{
|
|
skillKey: "wiki-maintainer",
|
|
displayName: "Wiki Maintainer Skill",
|
|
description: "Use LLM Wiki tools to maintain company knowledge.",
|
|
files: [{
|
|
path: "references/wiki-style.md",
|
|
content: "# Wiki style\n\nKeep pages cited and terse.\n",
|
|
}],
|
|
}],
|
|
};
|
|
}
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres plugin-managed skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("plugin-managed skills", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-skills-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(pluginManagedResources);
|
|
await db.delete(companySkills);
|
|
await db.delete(plugins);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
|
|
const companyId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: issuePrefix(companyId),
|
|
});
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: pluginManifest.id,
|
|
packageName: "@paperclipai/plugin-managed-skills-test",
|
|
version: pluginManifest.version,
|
|
apiVersion: pluginManifest.apiVersion,
|
|
categories: pluginManifest.categories,
|
|
manifestJson: pluginManifest,
|
|
status: "ready",
|
|
installOrder: 1,
|
|
});
|
|
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
|
manifest: pluginManifest,
|
|
});
|
|
return { companyId, pluginId, pluginManifest, services };
|
|
}
|
|
|
|
it("installs and resolves managed company skills by stable resource key", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
|
|
const created = await services.skills.managedReconcile({
|
|
companyId,
|
|
skillKey: "wiki-maintainer",
|
|
});
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.skill).toMatchObject({
|
|
name: "Wiki Maintainer Skill",
|
|
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
|
|
sourceType: "catalog",
|
|
fileInventory: expect.arrayContaining([
|
|
expect.objectContaining({ path: "SKILL.md", kind: "skill" }),
|
|
expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }),
|
|
]),
|
|
});
|
|
|
|
const resolved = await services.skills.managedGet({
|
|
companyId,
|
|
skillKey: "wiki-maintainer",
|
|
});
|
|
expect(resolved.status).toBe("resolved");
|
|
expect(resolved.skillId).toBe(created.skillId);
|
|
|
|
const [binding] = await db.select().from(pluginManagedResources);
|
|
expect(binding).toMatchObject({
|
|
companyId,
|
|
resourceKind: "skill",
|
|
resourceKey: "wiki-maintainer",
|
|
resourceId: created.skillId,
|
|
});
|
|
});
|
|
|
|
it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(created.skillId).toBeTruthy();
|
|
|
|
await db
|
|
.update(companySkills)
|
|
.set({
|
|
name: "Custom Wiki Skill",
|
|
markdown: "# Custom instructions\n",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySkills.id, created.skillId!));
|
|
|
|
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(reconciled.status).toBe("resolved");
|
|
expect(reconciled.skill).toMatchObject({
|
|
name: "Custom Wiki Skill",
|
|
markdown: "# Custom instructions\n",
|
|
});
|
|
|
|
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(reset.status).toBe("reset");
|
|
expect(reset.skill).toMatchObject({
|
|
name: "Wiki Maintainer Skill",
|
|
description: "Use LLM Wiki tools to maintain company knowledge.",
|
|
});
|
|
expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\"");
|
|
});
|
|
|
|
it("does not rewrite managed skill bindings when defaults are unchanged", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(created.skillId).toBeTruthy();
|
|
|
|
const [binding] = await db.select().from(pluginManagedResources);
|
|
const oldUpdatedAt = new Date("2026-01-01T00:00:00.000Z");
|
|
await db
|
|
.update(pluginManagedResources)
|
|
.set({ updatedAt: oldUpdatedAt })
|
|
.where(eq(pluginManagedResources.id, binding.id));
|
|
|
|
const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
const [bindingAfter] = await db.select().from(pluginManagedResources);
|
|
|
|
expect(reconciled.status).toBe("resolved");
|
|
expect(bindingAfter.updatedAt.toISOString()).toBe(oldUpdatedAt.toISOString());
|
|
});
|
|
|
|
it("relinks an existing canonical skill without overwriting operator edits", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(created.skillId).toBeTruthy();
|
|
|
|
await db.delete(pluginManagedResources).where(eq(pluginManagedResources.resourceId, created.skillId!));
|
|
await db
|
|
.update(companySkills)
|
|
.set({
|
|
name: "Existing Customized Wiki Skill",
|
|
markdown: "# Existing customized instructions\n",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySkills.id, created.skillId!));
|
|
|
|
const relinked = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
|
|
expect(relinked.status).toBe("relinked");
|
|
expect(relinked.skillId).toBe(created.skillId);
|
|
expect(relinked.skill).toMatchObject({
|
|
name: "Existing Customized Wiki Skill",
|
|
markdown: "# Existing customized instructions\n",
|
|
});
|
|
expect(relinked.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
|
|
|
|
const [binding] = await db.select().from(pluginManagedResources);
|
|
expect(binding).toMatchObject({
|
|
companyId,
|
|
resourceKind: "skill",
|
|
resourceKey: "wiki-maintainer",
|
|
resourceId: created.skillId,
|
|
});
|
|
});
|
|
|
|
it("reports drift when installed skill files differ from plugin defaults", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(created.defaultDrift).toBeNull();
|
|
|
|
await db
|
|
.update(companySkills)
|
|
.set({
|
|
markdown: "# Custom instructions\n",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySkills.id, created.skillId!));
|
|
|
|
const drifted = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(drifted.status).toBe("resolved");
|
|
expect(drifted.defaultDrift).toEqual({ changedFiles: ["SKILL.md"] });
|
|
|
|
const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" });
|
|
expect(reset.defaultDrift).toBeNull();
|
|
});
|
|
|
|
it("adds the canonical managed key to manifest-provided markdown skills", async () => {
|
|
const pluginManifest = manifest();
|
|
pluginManifest.skills = [
|
|
...(pluginManifest.skills ?? []),
|
|
{
|
|
skillKey: "markdown-skill",
|
|
displayName: "Markdown Skill",
|
|
markdown: [
|
|
"---",
|
|
"name: markdown-skill",
|
|
"description: Markdown source without a key.",
|
|
"---",
|
|
"",
|
|
"# Markdown Skill",
|
|
"",
|
|
"Follow the managed markdown.",
|
|
].join("\n"),
|
|
},
|
|
];
|
|
const { companyId, services } = await seedCompanyAndPlugin(pluginManifest);
|
|
|
|
const created = await services.skills.managedReconcile({ companyId, skillKey: "markdown-skill" });
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.skill).toMatchObject({
|
|
key: "plugin/paperclip-managed-skills-test/markdown-skill",
|
|
name: "markdown-skill",
|
|
});
|
|
expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\"");
|
|
});
|
|
});
|