Files
PaperClipAI/server/src/__tests__/folders-service.test.ts
T
DottaandPaperclip 52aea90263 feat: organize skills with nested folders and My Skills (#9633)
## Thinking Path

> - Paperclip is the open source control plane people use to organize
and govern AI-agent companies
> - Company skills are durable resources that users browse, import,
assign, and maintain over time
> - A flat skill list plus tags does not provide a stable location or
hierarchy for personal, company, project-imported, and bundled skills
> - Folder paths need to be canonical, company-scoped, safe to move, and
preserved across re-imports without changing skill IDs
> - The `/skills` UI also needs traversal, breadcrumbs, move/create
flows, and a dedicated My Skills namespace that work on desktop and
mobile
> - This pull request adds the folder data model and APIs, reserved-root
lifecycle, project import behavior, and the folder-first skills
experience
> - The benefit is a predictable filesystem-like organization model
while tags remain available for cross-cutting classification

## Linked Issues or Issue Description

Refs #9619 — the reviewed folder foundation was intentionally closed and
folded into this combined feature PR.
Refs #9026 — earlier flat-folder attempt superseded by this integrated
implementation.
Refs #3281 — related skill organization proposal; this PR uses canonical
persisted folders rather than deriving groups from skill keys, and does
not add hidden-skill behavior.

**Feature request**

- **Problem:** Skills currently lack a canonical hierarchical location,
making personal skills, project imports, bundled skills, and
company-authored skills difficult to traverse and manage at scale.
- **Proposed behavior:** Add nested company-scoped folders with stable
paths, reserved My/Projects/Bundled roots, subtree queries, safe
move/create operations, and a folder-first `/skills` library UI.
- **Import behavior:** New project scans file skills under
`projects/<project-slug>`; later imports update content without
overriding a user-selected folder.
- **Alternatives considered:** Tags alone remain useful for
cross-cutting classification, but they do not provide canonical
location, nesting, reserved namespaces, or stable import placement.
- **Roadmap alignment:** Extends the completed Skills Manager and
Scheduled Routines capabilities without duplicating an active roadmap
item.

## What Changed

- Adds `folders` persistence for routine and skill folders, nested
canonical paths, parent/slug/system-key fields, migration backfills, and
reapply-safe migrations `0174`–`0175` after current master migrations.
- Adds company-scoped folder CRUD, cycle/depth/namespace validation,
reserved My/Projects/Bundled lifecycle, item moves, subtree filtering,
and folder paths on skill results.
- Preserves project-import placement: first import files into the
project folder, while re-import keeps user-owned placement and stable
skill IDs.
- Adds the `/skills` folder tree rail, tags facet, breadcrumbs,
subfolder browser, move/new-folder dialog, canonical detail location,
inline tag editing, and folder-aware Studio creation.
- Keeps bundled skills read-only even when their source metadata is
incomplete by detecting the reserved Bundled folder and hiding
selection/move actions.
- Extends routine folder UI and OpenAPI coverage, and adds regression
tests across migrations, services, routes, tree helpers, pages, and
Studio creation.

## Verification

- `pnpm exec vitest run
packages/db/src/nested-skill-folders-migration.test.ts
server/src/__tests__/folders-routes.test.ts
server/src/__tests__/folders-service.test.ts
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/routines-service.test.ts
ui/src/components/folders/FolderControls.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx
ui/src/components/folders/skill-folder-tree.test.ts
ui/src/pages/CompanySkills.test.tsx ui/src/pages/Routines.test.tsx
ui/src/pages/SkillStudio.test.tsx
ui/src/lib/company-skill-routes.test.ts ui/src/lib/skill-create.test.ts`
— 13 files, 192 tests passed.
- `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx` — 2 files, 20 tests
passed after preserving the existing PR's bundled-skill fixes.
- `pnpm -r typecheck` — passed for all workspace packages.
- `pnpm test:run` — passed in an isolated CI-like environment with
inherited Paperclip runtime identity and static AWS credential variables
removed.
- `pnpm build` — production build passed for all workspace packages.
- Greptile iteration 2 — 5/5 confidence with zero unresolved threads on
commit `ff2d67aa71`.
- Latest-head GitHub checks — all success, neutral, or skipped; PR is
mergeable with a clean merge state.
- `pnpm check:token-gates` — reports nine existing `#9627` comment false
positives already present on `master`; this PR introduces no new token
violation.

## Risks

- **Migration/backfill:** `0174` creates the foundation and `0175` adds
nested/reserved semantics. Both are ordered after current master
migration `0173`, are covered by numbering/safety checks, and are
designed to be reapply-safe.
- **Reserved namespaces:** My, Projects, and Bundled roots are
service-managed. Regression coverage prevents namespace squatting,
cross-company folder use, bundled writes, cycles, and excessive depth.
- **Behavioral change:** Project scans choose a project folder only on
initial creation; existing skills deliberately retain their current
folder during refresh.
- **UI scope:** The folder rail applies to the Installed library;
Catalog retains the discovery-oriented category sidebar.

> 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.5` in Codex CLI, medium reasoning mode; runtime did not
expose a context-window value. Used repository/file tools, terminal
execution, Git/GitHub operations, test execution, and code editing.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-16 15:50:45 -05:00

448 lines
18 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
folderSlugSchema,
} from "@paperclipai/shared";
import {
companies,
companySkills,
createDb,
folders,
routines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { folderService } from "../services/folders.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("folder service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(companySkills);
await db.delete(routines);
await db.delete(folders);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
return companyId;
}
async function seedRoutine(companyId: string, title: string, folderId?: string | null) {
const [routine] = await db
.insert(routines)
.values({
companyId,
title,
folderId: folderId ?? null,
responsibleUserId: "responsible-user",
})
.returning();
return routine!;
}
async function seedSkill(companyId: string, slug: string, folderId?: string | null) {
const [skill] = await db
.insert(companySkills)
.values({
companyId,
folderId: folderId ?? null,
key: `company/${companyId}/${slug}`,
slug,
name: slug,
markdown: `# ${slug}`,
})
.returning();
return skill!;
}
it("creates, updates, reorders, and lists routine folders with counts", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const reporting = await svc.create(companyId, {
kind: "routine",
name: "Reporting",
color: "green",
});
const cleanup = await svc.create(companyId, {
kind: "routine",
name: "Cleanup",
color: null,
});
await seedRoutine(companyId, "Filed", reporting.id);
await seedRoutine(companyId, "Unfiled");
const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" });
expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" });
const cleared = await svc.update(companyId, cleanup.id, { color: null });
expect(cleared).toMatchObject({ id: cleanup.id, color: null });
const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 });
expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 });
const listed = await svc.list(companyId, "routine");
expect(listed.allCount).toBe(2);
expect(listed.unfiledCount).toBe(1);
expect(listed.folders).toEqual([
expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }),
expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }),
]);
});
it("moves routines and skills to folders and back to virtual Unfiled", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" });
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
const routine = await seedRoutine(companyId, "Daily report");
const skill = await seedSkill(companyId, "review");
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: routineFolder.id,
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id });
await expect(svc.moveItem(companyId, {
kind: "skill",
itemId: skill.id,
folderId: skillFolder.id,
})).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id });
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: null,
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null });
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
expect(updatedRoutine?.folderId).toBeNull();
expect(updatedSkill?.folderId).toBe(skillFolder.id);
});
it("rejects moving an item into a folder of the wrong kind", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
const routine = await seedRoutine(companyId, "Daily report");
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: skillFolder.id,
})).rejects.toMatchObject({
status: 422,
message: "Folder kind must match item kind",
});
});
it("deletes folders without deleting contents by moving items to Unfiled", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const folder = await svc.create(companyId, { kind: "routine", name: "Reports" });
const routine = await seedRoutine(companyId, "Daily report", folder.id);
const deleted = await svc.deleteFolder(companyId, folder.id);
expect(deleted).toMatchObject({ id: folder.id, name: "Reports" });
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
expect(updatedRoutine?.folderId).toBeNull();
expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0);
});
it("computes canonical paths and updates descendant paths after rename and move", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const root = await svc.create(companyId, { kind: "skill", name: "Engineering" });
const child = await svc.create(companyId, { kind: "skill", parentId: root.id, name: "Code Review" });
const destination = await svc.create(companyId, { kind: "skill", name: "Operations" });
expect(child).toMatchObject({ path: "engineering/code-review", depth: 2 });
await svc.update(companyId, root.id, { name: "Product Engineering" });
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
path: "product-engineering/code-review",
depth: 2,
});
await svc.moveFolder(companyId, child.id, { parentId: destination.id, position: 0 });
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
parentId: destination.id,
path: "operations/code-review",
depth: 2,
});
});
it("rejects invalid slugs, cycles, and folders deeper than four levels", async () => {
expect(folderSlugSchema.safeParse("../escape").success).toBe(false);
expect(folderSlugSchema.safeParse("Valid Slug").success).toBe(false);
expect(folderSlugSchema.safeParse("valid-slug-2").success).toBe(true);
const companyId = await seedCompany();
const svc = folderService(db);
const level1 = await svc.create(companyId, { kind: "skill", name: "Level 1" });
const level2 = await svc.create(companyId, { kind: "skill", parentId: level1.id, name: "Level 2" });
const level3 = await svc.create(companyId, { kind: "skill", parentId: level2.id, name: "Level 3" });
const level4 = await svc.create(companyId, { kind: "skill", parentId: level3.id, name: "Level 4" });
await expect(svc.create(companyId, {
kind: "skill",
parentId: level4.id,
name: "Level 5",
})).rejects.toMatchObject({ status: 422, message: "Folder depth cannot exceed 4" });
await expect(svc.moveFolder(companyId, level1.id, {
parentId: level3.id,
position: 0,
})).rejects.toMatchObject({ status: 422, message: "A folder cannot be moved into its own subtree" });
});
it("creates stable personal roots and protects bundled folders", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const repeated = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const bundled = await svc.ensureBundledCategory(companyId, "software-development");
expect(repeated.id).toBe(personal.id);
expect(personal).toMatchObject({ systemKey: "my:user-1", path: "my/ada-lovelace", depth: 2 });
expect(bundled.path).toBe("bundled/software-development");
await expect(svc.create(companyId, {
kind: "skill",
parentId: bundled.id,
name: "Nested",
})).rejects.toMatchObject({ status: 403, message: "Bundled folders are read-only" });
await expect(svc.update(companyId, bundled.id, { name: "Changed" })).rejects.toMatchObject({ status: 403 });
});
it("heals legacy bundled category names without changing folder identity", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const legacy = await svc.ensureBundledCategory(companyId, "software-development");
const reconciled = await svc.ensureBundledCategory(companyId, "Software Development");
expect(reconciled).toMatchObject({
id: legacy.id,
name: "Software Development",
path: "bundled/software-development",
systemKey: "bundled:software-development",
});
});
it("creates reserved folders idempotently under concurrent requests", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const [personalA, personalB] = await Promise.all([
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
]);
const [projectA, projectB] = await Promise.all([
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
]);
const [bundledA, bundledB] = await Promise.all([
svc.ensureBundledCategory(companyId, "software-development"),
svc.ensureBundledCategory(companyId, "software-development"),
]);
expect(personalA.id).toBe(personalB.id);
expect(projectA.id).toBe(projectB.id);
expect(bundledA.id).toBe(bundledB.id);
});
it("reserves system skill roots from manual create, update, and move", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
for (const slug of ["bundled", "my", "projects"]) {
await expect(svc.create(companyId, {
kind: "skill",
name: slug,
slug,
})).rejects.toMatchObject({ status: 403, message: "Reserved skill folders are system-managed" });
}
const editable = await svc.create(companyId, { kind: "skill", name: "Editable" });
await expect(svc.update(companyId, editable.id, { slug: "bundled" })).rejects.toMatchObject({ status: 403 });
const parent = await svc.create(companyId, { kind: "skill", name: "Parent" });
const nestedReserved = await svc.create(companyId, { kind: "skill", parentId: parent.id, name: "Projects" });
await expect(svc.moveFolder(companyId, nestedReserved.id, { parentId: null, position: 0 })).rejects.toMatchObject({
status: 403,
message: "Reserved skill folders are system-managed",
});
});
it("allows only system helpers to create children under personal and project roots", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
const myRoot = await svc.getFolder(companyId, personal.parentId!);
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
const movable = await svc.create(companyId, { kind: "skill", name: "Movable" });
expect(myRoot?.systemKey).toBe("my");
expect(projectsRoot?.systemKey).toBe("projects");
await expect(svc.create(companyId, {
kind: "skill",
parentId: myRoot!.id,
name: "Spoofed User",
})).rejects.toMatchObject({ status: 403 });
await expect(svc.moveFolder(companyId, movable.id, {
parentId: projectsRoot!.id,
position: 0,
})).rejects.toMatchObject({ status: 403 });
});
it("moves squatted roots aside instead of adopting them as system containers", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const [squattedMy, squattedProjects] = await db.insert(folders).values([
{ companyId, kind: "skill", parentId: null, name: "Attacker My", slug: "my", position: 0 },
{ companyId, kind: "skill", parentId: null, name: "Attacker Projects", slug: "projects", position: 1 },
]).returning();
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
const myRoot = await svc.getFolder(companyId, personal.parentId!);
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
const repairedMy = await svc.getFolder(companyId, squattedMy!.id);
const repairedProjects = await svc.getFolder(companyId, squattedProjects!.id);
expect(myRoot).toMatchObject({ slug: "my", systemKey: "my" });
expect(projectsRoot).toMatchObject({ slug: "projects", systemKey: "projects" });
expect(repairedMy).toMatchObject({ name: "Attacker My", systemKey: null });
expect(repairedMy?.slug).toMatch(/^my-[a-f0-9]{8}$/);
expect(repairedProjects).toMatchObject({ name: "Attacker Projects", systemKey: null });
expect(repairedProjects?.slug).toMatch(/^projects-[a-f0-9]{8}$/);
});
it("suffixes system children when legacy rows squat personal and project slugs", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const initialPersonal = await svc.ensureMyFolder(companyId, "seed-user", "Seed User");
const initialProject = await svc.ensureProjectFolder(companyId, "seed-project", "Seed Project");
const myRootId = initialPersonal.parentId!;
const projectsRootId = initialProject.parentId!;
await db.insert(folders).values([
{ companyId, kind: "skill", parentId: myRootId, name: "Ada Squat", slug: "ada-lovelace", position: 1 },
{ companyId, kind: "skill", parentId: projectsRootId, name: "Core Squat", slug: "core-app", position: 1 },
]);
const personal = await svc.ensureMyFolder(companyId, "user-12345678", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-12345678", "Core App");
expect(personal).toMatchObject({ path: "my/ada-lovelace-user-12345678", systemKey: "my:user-12345678" });
expect(project).toMatchObject({ path: "projects/core-app-project-12345678", systemKey: "project:project-12345678" });
});
it("does not adopt a legacy category row under the bundled root", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const initialCategory = await svc.ensureBundledCategory(companyId, "initial");
const bundledRootId = initialCategory.parentId!;
const [squatted] = await db.insert(folders).values({
companyId,
kind: "skill",
parentId: bundledRootId,
name: "User Software Development",
slug: "software-development",
position: 1,
}).returning();
const category = await svc.ensureBundledCategory(companyId, "software-development");
expect(category).toMatchObject({
path: "bundled/software-development-bundled",
systemKey: "bundled:software-development",
});
expect(await svc.getFolder(companyId, squatted!.id)).toMatchObject({
path: "bundled/software-development",
systemKey: null,
});
});
it("serializes concurrent system folder ensures", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
await db.insert(folders).values({
companyId,
kind: "skill",
name: "Squatted My",
slug: "my",
position: 0,
});
const personalFolders = await Promise.all(
Array.from({ length: 8 }, () => svc.ensureMyFolder(companyId, "user-123", "Ada Lovelace")),
);
expect(new Set(personalFolders.map((folder) => folder.id)).size).toBe(1);
const rows = await db.select().from(folders).where(eq(folders.companyId, companyId));
expect(rows.filter((row) => row.systemKey === "my")).toHaveLength(1);
expect(rows.filter((row) => row.systemKey === "my:user-123")).toHaveLength(1);
expect(rows.find((row) => row.systemKey === null)).toMatchObject({ name: "Squatted My" });
});
it("rechecks nested folders after waiting for the company mutation lock", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const parent = await svc.create(companyId, { kind: "routine", name: "Parent" });
let releaseLock!: () => void;
let markLockAcquired!: () => void;
const lockAcquired = new Promise<void>((resolve) => { markLockAcquired = resolve; });
const holdLock = new Promise<void>((resolve) => { releaseLock = resolve; });
const lockKey = `paperclip:folders:${companyId}`;
const blocker = db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
markLockAcquired();
await holdLock;
});
await lockAcquired;
const deletion = svc.deleteFolder(companyId, parent.id);
await db.insert(folders).values({
companyId,
kind: "routine",
parentId: parent.id,
name: "Child",
slug: "child",
position: 0,
});
releaseLock();
await blocker;
await expect(deletion).rejects.toMatchObject({
status: 409,
message: "Move or delete nested folders first",
});
await expect(svc.getFolder(companyId, parent.id)).resolves.toMatchObject({ id: parent.id });
});
});