Add CEO-safe company portability flows

Expose CEO-scoped import/export preview and apply routes, keep safe imports non-destructive, add export preview-first UI behavior, and document the new portability workflows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 21:54:10 -05:00
parent 685c7549e1
commit 51ca713181
18 changed files with 1166 additions and 96 deletions

View File

@@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({
}),
companyPortabilityService: () => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}),

View File

@@ -28,6 +28,7 @@ const mockBudgetService = vi.hoisted(() => ({
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
@@ -170,10 +171,8 @@ describe("PATCH /api/companies/:companyId/branding", () => {
.send({ brandColor: null, logoAssetId: null });
expect(res.status).toBe(200);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
brandColor: null,
logoAssetId: null,
});
expect(res.body.brandColor).toBeNull();
expect(res.body.logoAssetId).toBeNull();
});
it("rejects non-branding fields in the request body", async () => {

View File

@@ -0,0 +1,174 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("company portability routes", () => {
beforeEach(() => {
mockAgentService.getById.mockReset();
mockCompanyPortabilityService.exportBundle.mockReset();
mockCompanyPortabilityService.previewExport.mockReset();
mockCompanyPortabilityService.previewImport.mockReset();
mockCompanyPortabilityService.importBundle.mockReset();
mockLogActivity.mockReset();
});
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it("allows CEO agents to use company-scoped export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
mockCompanyPortabilityService.previewExport.mockResolvedValue({
rootPath: "paperclip",
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
files: {},
fileInventory: [],
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
warnings: [],
paperclipExtensionPath: ".paperclip.yaml",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
expect(res.status).toBe(200);
expect(mockCompanyPortabilityService.previewExport).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
include: { company: true, agents: true, projects: true },
});
});
it("rejects replace collision strategy on CEO-safe import routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "replace",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("does not allow replace");
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("keeps global import preview routes board-only", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/import/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Board access required");
});
});

View File

@@ -14,6 +14,8 @@ const agentSvc = {
const accessSvc = {
ensureMembership: vi.fn(),
listActiveUserMemberships: vi.fn(),
copyActiveUserMemberships: vi.fn(),
};
const projectSvc = {
@@ -241,6 +243,17 @@ describe("company portability", () => {
};
});
companySkillSvc.importPackageFiles.mockResolvedValue([]);
accessSvc.listActiveUserMemberships.mockResolvedValue([
{
id: "membership-1",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
membershipRole: "owner",
status: "active",
},
]);
accessSvc.copyActiveUserMemberships.mockResolvedValue([]);
agentInstructionsSvc.exportFiles.mockImplementation(async (agent: { name: string }) => ({
files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." },
entryFile: "AGENTS.md",
@@ -404,6 +417,52 @@ describe("company portability", () => {
expect(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"]).toContain("paperclipai/paperclip/release-changelog");
});
it("builds export previews without tasks by default", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Write launch task",
description: "Task body",
projectId: "project-1",
assigneeAgentId: "agent-1",
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const preview = await portability.previewExport("company-1", {
include: {
company: true,
agents: true,
projects: true,
},
});
expect(preview.counts.issues).toBe(0);
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
@@ -503,7 +562,9 @@ describe("company portability", () => {
collisionStrategy: "rename",
}, "user-1");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files);
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
onConflict: "replace",
});
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: {
@@ -513,6 +574,60 @@ describe("company portability", () => {
}));
});
it("copies source company memberships for safe new-company imports", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, null, {
mode: "agent_safe",
sourceCompanyId: "company-1",
});
expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1");
expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported");
expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
onConflict: "rename",
});
});
it("imports only selected files and leaves unchecked company metadata alone", async () => {
const portability = companyPortabilityService({} as any);
@@ -567,12 +682,18 @@ describe("company portability", () => {
"COMPANY.md": expect.any(String),
"agents/cmo/AGENTS.md": expect.any(String),
}),
{
onConflict: "replace",
},
);
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
"company-1",
expect.not.objectContaining({
"agents/claudecoder/AGENTS.md": expect.any(String),
}),
{
onConflict: "replace",
},
);
expect(agentSvc.create).toHaveBeenCalledTimes(1);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({

View File

@@ -42,6 +42,20 @@ export function companyRoutes(db: Db) {
}
}
async function assertCanManagePortability(req: Request, companyId: string, capability: "imports" | "exports") {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden(`Only CEO agents can manage company ${capability}`);
}
}
router.get("/", async (req, res) => {
assertBoard(req);
const result = await svc.list();
@@ -94,20 +108,18 @@ export function companyRoutes(db: Db) {
});
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const preview = await portability.previewImport(req.body);
res.json(preview);
});
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
@@ -130,6 +142,70 @@ export function companyRoutes(db: Db) {
res.json(result);
});
router.post("/:companyId/exports/preview", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "exports");
const preview = await portability.previewExport(companyId, req.body);
res.json(preview);
});
router.post("/:companyId/exports", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "exports");
const result = await portability.exportBundle(companyId, req.body);
res.json(result);
});
router.post("/:companyId/imports/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "imports");
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (req.body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const preview = await portability.previewImport(req.body, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
res.json(preview);
});
router.post("/:companyId/imports/apply", validate(companyPortabilityImportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "imports");
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (req.body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
await logActivity(db, {
companyId: result.company.id,
actorType: actor.actorType,
actorId: actor.actorId,
entityType: "company",
entityId: result.company.id,
agentId: actor.agentId,
runId: actor.runId,
action: "company.imported",
details: {
include: req.body.include ?? null,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
importMode: "agent_safe",
},
});
res.json(result);
});
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {

View File

@@ -83,6 +83,20 @@ export function accessService(db: Db) {
.orderBy(sql`${companyMemberships.createdAt} desc`);
}
async function listActiveUserMemberships(companyId: string) {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
),
)
.orderBy(sql`${companyMemberships.createdAt} asc`);
}
async function setMemberPermissions(
companyId: string,
memberId: string,
@@ -251,6 +265,20 @@ export function accessService(db: Db) {
});
}
async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) {
const sourceMemberships = await listActiveUserMemberships(sourceCompanyId);
for (const membership of sourceMemberships) {
await ensureMembership(
targetCompanyId,
"user",
membership.principalId,
membership.membershipRole,
"active",
);
}
return sourceMemberships;
}
return {
isInstanceAdmin,
canUser,
@@ -258,6 +286,8 @@ export function accessService(db: Db) {
getMembership,
ensureMembership,
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
setMemberPermissions,
promoteInstanceAdmin,
demoteInstanceAdmin,

View File

@@ -9,6 +9,7 @@ import type {
CompanyPortabilityCollisionStrategy,
CompanyPortabilityEnvInput,
CompanyPortabilityExport,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityImport,
CompanyPortabilityImportResult,
@@ -54,6 +55,27 @@ const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"
const execFileAsync = promisify(execFile);
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
function resolveImportMode(options?: ImportBehaviorOptions): ImportMode {
return options?.mode ?? "board_full";
}
function resolveSkillConflictStrategy(mode: ImportMode, collisionStrategy: CompanyPortabilityCollisionStrategy) {
if (mode === "board_full") return "replace" as const;
return collisionStrategy === "skip" ? "skip" as const : "rename" as const;
}
function classifyPortableFileKind(pathValue: string): CompanyPortabilityExportPreviewResult["fileInventory"][number]["kind"] {
const normalized = normalizePortablePath(pathValue);
if (normalized === "COMPANY.md") return "company";
if (normalized === ".paperclip.yaml" || normalized === ".paperclip.yml") return "extension";
if (normalized === "README.md") return "readme";
if (normalized.startsWith("agents/")) return "agent";
if (normalized.startsWith("skills/")) return "skill";
if (normalized.startsWith("projects/")) return "project";
if (normalized.startsWith("tasks/")) return "issue";
return "other";
}
function normalizeSkillSlug(value: string | null | undefined) {
return value ? normalizeAgentUrlKey(value) ?? null : null;
}
@@ -357,6 +379,13 @@ type ImportPlanInternal = {
selectedAgents: CompanyPortabilityAgentManifestEntry[];
};
type ImportMode = "board_full" | "agent_safe";
type ImportBehaviorOptions = {
mode?: ImportMode;
sourceCompanyId?: string | null;
};
type AgentLike = {
id: string;
name: string;
@@ -515,6 +544,115 @@ function normalizeFileMap(
return out;
}
function collectSelectedExportSlugs(selectedFiles: Set<string>) {
const agents = new Set<string>();
const projects = new Set<string>();
const tasks = new Set<string>();
for (const filePath of selectedFiles) {
const agentMatch = filePath.match(/^agents\/([^/]+)\//);
if (agentMatch) agents.add(agentMatch[1]!);
const projectMatch = filePath.match(/^projects\/([^/]+)\//);
if (projectMatch) projects.add(projectMatch[1]!);
const taskMatch = filePath.match(/^tasks\/([^/]+)\//);
if (taskMatch) tasks.add(taskMatch[1]!);
}
return { agents, projects, tasks };
}
function filterPortableExtensionYaml(yaml: string, selectedFiles: Set<string>) {
const selected = collectSelectedExportSlugs(selectedFiles);
const lines = yaml.split("\n");
const out: string[] = [];
const filterableSections = new Set(["agents", "projects", "tasks"]);
let currentSection: string | null = null;
let currentEntry: string | null = null;
let includeEntry = true;
let sectionHeaderLine: string | null = null;
let sectionBuffer: string[] = [];
const flushSection = () => {
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
out.push(sectionHeaderLine);
out.push(...sectionBuffer);
}
sectionHeaderLine = null;
sectionBuffer = [];
};
for (const line of lines) {
const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
if (topMatch && !line.startsWith(" ")) {
flushSection();
currentEntry = null;
includeEntry = true;
const key = topMatch[1]!;
if (filterableSections.has(key)) {
currentSection = key;
sectionHeaderLine = line;
continue;
}
currentSection = null;
out.push(line);
continue;
}
if (currentSection && filterableSections.has(currentSection)) {
const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/);
if (entryMatch && !line.startsWith(" ")) {
const slug = entryMatch[1]!;
currentEntry = slug;
const sectionSlugs = selected[currentSection as keyof typeof selected];
includeEntry = sectionSlugs.has(slug);
if (includeEntry) sectionBuffer.push(line);
continue;
}
if (currentEntry !== null) {
if (includeEntry) sectionBuffer.push(line);
continue;
}
sectionBuffer.push(line);
continue;
}
out.push(line);
}
flushSection();
return out.join("\n");
}
function filterExportFiles(
files: Record<string, string>,
selectedFilesInput: string[] | undefined,
paperclipExtensionPath: string,
) {
if (!selectedFilesInput || selectedFilesInput.length === 0) {
return files;
}
const selectedFiles = new Set(
selectedFilesInput
.map((entry) => normalizePortablePath(entry))
.filter((entry) => entry.length > 0),
);
const filtered: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
if (!selectedFiles.has(filePath)) continue;
filtered[filePath] = content;
}
if (selectedFiles.has(paperclipExtensionPath) && filtered[paperclipExtensionPath]) {
filtered[paperclipExtensionPath] = filterPortableExtensionYaml(filtered[paperclipExtensionPath]!, selectedFiles);
}
return filtered;
}
function findPaperclipExtensionPath(files: Record<string, string>) {
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
@@ -1731,6 +1869,7 @@ export function companyPortabilityService(db: Db) {
): Promise<CompanyPortabilityExportResult> {
const include = normalizeInclude({
...input.include,
agents: input.agents && input.agents.length > 0 ? true : input.include?.agents,
projects: input.projects && input.projects.length > 0 ? true : input.include?.projects,
issues:
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
@@ -1746,15 +1885,47 @@ export function companyPortabilityService(db: Db) {
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
const companySkillRows = await companySkills.listFull(companyId);
if (include.agents) {
const skipped = allAgentRows.length - agentRows.length;
const skipped = allAgentRows.length - liveAgentRows.length;
if (skipped > 0) {
warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`);
}
}
const agentByReference = new Map<string, typeof liveAgentRows[number]>();
for (const agent of liveAgentRows) {
agentByReference.set(agent.id, agent);
agentByReference.set(agent.name, agent);
const normalizedName = normalizeAgentUrlKey(agent.name);
if (normalizedName) {
agentByReference.set(normalizedName, agent);
}
}
const selectedAgents = new Map<string, typeof liveAgentRows[number]>();
for (const selector of input.agents ?? []) {
const trimmed = selector.trim();
if (!trimmed) continue;
const normalized = normalizeAgentUrlKey(trimmed) ?? trimmed;
const match = agentByReference.get(trimmed) ?? agentByReference.get(normalized);
if (!match) {
warnings.push(`Agent selector "${selector}" was not found and was skipped.`);
continue;
}
selectedAgents.set(match.id, match);
}
if (include.agents && selectedAgents.size === 0) {
for (const agent of liveAgentRows) {
selectedAgents.set(agent.id, agent);
}
}
const agentRows = Array.from(selectedAgents.values())
.sort((left, right) => left.name.localeCompare(right.name));
const usedSlugs = new Set<string>();
const idToSlug = new Map<string, string>();
for (const agent of agentRows) {
@@ -1890,8 +2061,35 @@ export function companyPortabilityService(db: Db) {
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
const skillExportDirs = buildSkillExportDirMap(companySkillRows, company.issuePrefix);
const skillByReference = new Map<string, typeof companySkillRows[number]>();
for (const skill of companySkillRows) {
skillByReference.set(skill.id, skill);
skillByReference.set(skill.key, skill);
skillByReference.set(skill.slug, skill);
skillByReference.set(skill.name, skill);
}
const selectedSkills = new Map<string, typeof companySkillRows[number]>();
for (const selector of input.skills ?? []) {
const trimmed = selector.trim();
if (!trimmed) continue;
const normalized = normalizeSkillKey(trimmed) ?? normalizeSkillSlug(trimmed) ?? trimmed;
const match = skillByReference.get(trimmed) ?? skillByReference.get(normalized);
if (!match) {
warnings.push(`Skill selector "${selector}" was not found and was skipped.`);
continue;
}
selectedSkills.set(match.id, match);
}
if (selectedSkills.size === 0) {
for (const skill of companySkillRows) {
selectedSkills.set(skill.id, skill);
}
}
const selectedSkillRows = Array.from(selectedSkills.values())
.sort((left, right) => left.key.localeCompare(right.key));
const skillExportDirs = buildSkillExportDirMap(selectedSkillRows, company.issuePrefix);
for (const skill of selectedSkillRows) {
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
@@ -2062,32 +2260,94 @@ export function companyPortabilityService(db: Db) {
{ preserveEmptyStrings: true },
);
const resolved = buildManifestFromPackageFiles(files, {
let finalFiles = filterExportFiles(files, input.selectedFiles, paperclipExtensionPath);
let resolved = buildManifestFromPackageFiles(finalFiles, {
sourceLabel: {
companyId: company.id,
companyName: company.name,
},
});
resolved.manifest.includes = include;
resolved.manifest.includes = {
company: resolved.manifest.company !== null,
agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings);
// Generate README.md with Mermaid org chart
files["README.md"] = generateReadme(resolved.manifest, {
companyName: company.name,
companyDescription: company.description ?? null,
if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) {
finalFiles["README.md"] = generateReadme(resolved.manifest, {
companyName: company.name,
companyDescription: company.description ?? null,
});
}
resolved = buildManifestFromPackageFiles(finalFiles, {
sourceLabel: {
companyId: company.id,
companyName: company.name,
},
});
resolved.manifest.includes = {
company: resolved.manifest.company !== null,
agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings);
return {
rootPath,
manifest: resolved.manifest,
files,
files: finalFiles,
warnings: resolved.warnings,
paperclipExtensionPath,
};
}
async function buildPreview(input: CompanyPortabilityPreview): Promise<ImportPlanInternal> {
async function previewExport(
companyId: string,
input: CompanyPortabilityExport,
): Promise<CompanyPortabilityExportPreviewResult> {
const previewInput: CompanyPortabilityExport = {
...input,
include: {
...input.include,
issues:
input.include?.issues
?? Boolean((input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0))
?? false,
},
};
if (previewInput.include && previewInput.include.issues === undefined) {
previewInput.include.issues = false;
}
const exported = await exportBundle(companyId, previewInput);
return {
...exported,
fileInventory: Object.keys(exported.files)
.sort((left, right) => left.localeCompare(right))
.map((filePath) => ({
path: filePath,
kind: classifyPortableFileKind(filePath),
})),
counts: {
files: Object.keys(exported.files).length,
agents: exported.manifest.agents.length,
skills: exported.manifest.skills.length,
projects: exported.manifest.projects.length,
issues: exported.manifest.issues.length,
},
};
}
async function buildPreview(
input: CompanyPortabilityPreview,
options?: ImportBehaviorOptions,
): Promise<ImportPlanInternal> {
const mode = resolveImportMode(options);
const requestedInclude = normalizeInclude(input.include);
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
const manifest = source.manifest;
@@ -2098,6 +2358,9 @@ export function companyPortabilityService(db: Db) {
issues: requestedInclude.issues && manifest.issues.length > 0,
};
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
if (mode === "agent_safe" && collisionStrategy === "replace") {
throw unprocessable("Safe import routes do not allow replace collision strategy.");
}
const warnings = [...source.warnings];
const errors: string[] = [];
@@ -2221,6 +2484,20 @@ export function companyPortabilityService(db: Db) {
}
existingProjectSlugs.add(existing.urlKey);
}
const existingSkills = await companySkills.listFull(input.target.companyId);
const existingSkillKeys = new Set(existingSkills.map((skill) => skill.key));
const existingSkillSlugs = new Set(existingSkills.map((skill) => normalizeSkillSlug(skill.slug) ?? skill.slug));
for (const skill of manifest.skills) {
const skillSlug = normalizeSkillSlug(skill.slug) ?? skill.slug;
if (existingSkillKeys.has(skill.key) || existingSkillSlugs.has(skillSlug)) {
if (mode === "agent_safe") {
warnings.push(`Existing skill "${skill.slug}" matched during safe import and will ${collisionStrategy === "skip" ? "be skipped" : "be renamed"} instead of overwritten.`);
} else if (collisionStrategy === "replace") {
warnings.push(`Existing skill "${skill.slug}" (${skill.key}) will be overwritten by import.`);
}
}
}
}
for (const manifestAgent of selectedAgents) {
@@ -2236,7 +2513,7 @@ export function companyPortabilityService(db: Db) {
continue;
}
if (collisionStrategy === "replace") {
if (mode === "board_full" && collisionStrategy === "replace") {
agentPlans.push({
slug: manifestAgent.slug,
action: "update",
@@ -2282,7 +2559,7 @@ export function companyPortabilityService(db: Db) {
});
continue;
}
if (collisionStrategy === "replace") {
if (mode === "board_full" && collisionStrategy === "replace") {
projectPlans.push({
slug: manifestProject.slug,
action: "update",
@@ -2370,7 +2647,7 @@ export function companyPortabilityService(db: Db) {
plan: {
companyAction: input.target.mode === "new_company"
? "create"
: include.company
: include.company && mode === "board_full"
? "update"
: "none",
agentPlans,
@@ -2393,19 +2670,34 @@ export function companyPortabilityService(db: Db) {
};
}
async function previewImport(input: CompanyPortabilityPreview): Promise<CompanyPortabilityPreviewResult> {
const plan = await buildPreview(input);
async function previewImport(
input: CompanyPortabilityPreview,
options?: ImportBehaviorOptions,
): Promise<CompanyPortabilityPreviewResult> {
const plan = await buildPreview(input, options);
return plan.preview;
}
async function importBundle(
input: CompanyPortabilityImport,
actorUserId: string | null | undefined,
options?: ImportBehaviorOptions,
): Promise<CompanyPortabilityImportResult> {
const plan = await buildPreview(input);
const mode = resolveImportMode(options);
const plan = await buildPreview(input, options);
if (plan.preview.errors.length > 0) {
throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`);
}
if (
mode === "agent_safe"
&& (
plan.preview.plan.companyAction === "update"
|| plan.preview.plan.agentPlans.some((entry) => entry.action === "update")
|| plan.preview.plan.projectPlans.some((entry) => entry.action === "update")
)
) {
throw unprocessable("Safe import routes only allow create or skip actions.");
}
const sourceManifest = plan.source.manifest;
const warnings = [...plan.preview.warnings];
@@ -2415,6 +2707,15 @@ export function companyPortabilityService(db: Db) {
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
if (input.target.mode === "new_company") {
if (mode === "agent_safe" && !options?.sourceCompanyId) {
throw unprocessable("Safe new-company imports require a source company context.");
}
if (mode === "agent_safe" && options?.sourceCompanyId) {
const sourceMemberships = await access.listActiveUserMemberships(options.sourceCompanyId);
if (sourceMemberships.length === 0) {
throw unprocessable("Safe new-company import requires at least one active user membership on the source company.");
}
}
const companyName =
asString(input.target.newCompanyName) ??
sourceManifest.company?.name ??
@@ -2428,13 +2729,17 @@ export function companyPortabilityService(db: Db) {
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true,
});
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
} else {
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
}
targetCompany = created;
companyAction = "created";
} else {
targetCompany = await companies.getById(input.target.companyId);
if (!targetCompany) throw notFound("Target company not found");
if (include.company && sourceManifest.company) {
if (include.company && sourceManifest.company && mode === "board_full") {
const updated = await companies.update(targetCompany.id, {
name: sourceManifest.company.name,
description: sourceManifest.company.description,
@@ -2462,7 +2767,19 @@ export function companyPortabilityService(db: Db) {
existingProjectSlugToId.set(existing.urlKey, existing.id);
}
await companySkills.importPackageFiles(targetCompany.id, plan.source.files);
const importedSkills = await companySkills.importPackageFiles(targetCompany.id, plan.source.files, {
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
});
const desiredSkillRefMap = new Map<string, string>();
for (const importedSkill of importedSkills) {
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);
desiredSkillRefMap.set(importedSkill.originalSlug, importedSkill.skill.key);
if (importedSkill.action === "skipped") {
warnings.push(`Skipped skill ${importedSkill.originalSlug}; existing skill ${importedSkill.skill.slug} was kept.`);
} else if (importedSkill.originalKey !== importedSkill.skill.key) {
warnings.push(`Imported skill ${importedSkill.originalSlug} as ${importedSkill.skill.slug} to avoid overwriting an existing skill.`);
}
}
if (include.agents) {
for (const planAgent of plan.preview.plan.agentPlans) {
@@ -2501,7 +2818,7 @@ export function companyPortabilityService(db: Db) {
? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = manifestAgent.skills ?? [];
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
baseAdapterConfig,
desiredSkills,
@@ -2689,6 +3006,7 @@ export function companyPortabilityService(db: Db) {
return {
exportBundle,
previewExport,
previewImport,
importBundle,
};

View File

@@ -52,6 +52,17 @@ type ImportedSkill = {
metadata: Record<string, unknown> | null;
};
type PackageSkillConflictStrategy = "replace" | "rename" | "skip";
export type ImportPackageSkillResult = {
skill: CompanySkill;
action: "created" | "updated" | "skipped";
originalKey: string;
originalSlug: string;
requestedRefs: string[];
reason: string | null;
};
type ParsedSkillImportSource = {
resolvedSource: string;
requestedSkillSlug: string | null;
@@ -180,6 +191,29 @@ function hashSkillValue(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 10);
}
function uniqueSkillSlug(baseSlug: string, usedSlugs: Set<string>) {
if (!usedSlugs.has(baseSlug)) return baseSlug;
let attempt = 2;
let candidate = `${baseSlug}-${attempt}`;
while (usedSlugs.has(candidate)) {
attempt += 1;
candidate = `${baseSlug}-${attempt}`;
}
return candidate;
}
function uniqueImportedSkillKey(companyId: string, baseSlug: string, usedKeys: Set<string>) {
const initial = `company/${companyId}/${baseSlug}`;
if (!usedKeys.has(initial)) return initial;
let attempt = 2;
let candidate = `company/${companyId}/${baseSlug}-${attempt}`;
while (usedKeys.has(candidate)) {
attempt += 1;
candidate = `company/${companyId}/${baseSlug}-${attempt}`;
}
return candidate;
}
function buildSkillRuntimeName(key: string, slug: string) {
if (key.startsWith("paperclipai/paperclip/")) return slug;
return `${slug}--${hashSkillValue(key)}`;
@@ -1953,7 +1987,13 @@ export function companySkillService(db: Db) {
return out;
}
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
async function importPackageFiles(
companyId: string,
files: Record<string, string>,
options?: {
onConflict?: PackageSkillConflictStrategy;
},
): Promise<ImportPackageSkillResult[]> {
await ensureSkillInventoryCurrent(companyId);
const normalizedFiles = normalizePackageFileMap(files);
const importedSkills = readInlineSkillImports(companyId, normalizedFiles);
@@ -1967,7 +2007,105 @@ export function companySkillService(db: Db) {
}
}
return upsertImportedSkills(companyId, importedSkills);
const conflictStrategy = options?.onConflict ?? "replace";
const existingSkills = await listFull(companyId);
const existingByKey = new Map(existingSkills.map((skill) => [skill.key, skill]));
const existingBySlug = new Map(
existingSkills.map((skill) => [normalizeSkillSlug(skill.slug) ?? skill.slug, skill]),
);
const usedSlugs = new Set(existingBySlug.keys());
const usedKeys = new Set(existingByKey.keys());
const toPersist: ImportedSkill[] = [];
const prepared: Array<{
skill: ImportedSkill;
originalKey: string;
originalSlug: string;
existingBefore: CompanySkill | null;
actionHint: "created" | "updated";
reason: string | null;
}> = [];
const out: ImportPackageSkillResult[] = [];
for (const importedSkill of importedSkills) {
const originalKey = importedSkill.key;
const originalSlug = importedSkill.slug;
const normalizedSlug = normalizeSkillSlug(importedSkill.slug) ?? importedSkill.slug;
const existingByIncomingKey = existingByKey.get(importedSkill.key) ?? null;
const existingByIncomingSlug = existingBySlug.get(normalizedSlug) ?? null;
const conflict = existingByIncomingKey ?? existingByIncomingSlug;
if (!conflict || conflictStrategy === "replace") {
toPersist.push(importedSkill);
prepared.push({
skill: importedSkill,
originalKey,
originalSlug,
existingBefore: existingByIncomingKey,
actionHint: existingByIncomingKey ? "updated" : "created",
reason: existingByIncomingKey ? "Existing skill key matched; replace strategy." : null,
});
usedSlugs.add(normalizedSlug);
usedKeys.add(importedSkill.key);
continue;
}
if (conflictStrategy === "skip") {
out.push({
skill: conflict,
action: "skipped",
originalKey,
originalSlug,
requestedRefs: Array.from(new Set([originalKey, originalSlug])),
reason: "Existing skill matched; skip strategy.",
});
continue;
}
const renamedSlug = uniqueSkillSlug(normalizedSlug || "skill", usedSlugs);
const renamedKey = uniqueImportedSkillKey(companyId, renamedSlug, usedKeys);
const renamedSkill: ImportedSkill = {
...importedSkill,
slug: renamedSlug,
key: renamedKey,
metadata: {
...(importedSkill.metadata ?? {}),
skillKey: renamedKey,
importedFromSkillKey: originalKey,
importedFromSkillSlug: originalSlug,
},
};
toPersist.push(renamedSkill);
prepared.push({
skill: renamedSkill,
originalKey,
originalSlug,
existingBefore: null,
actionHint: "created",
reason: `Existing skill matched; renamed to ${renamedSlug}.`,
});
usedSlugs.add(renamedSlug);
usedKeys.add(renamedKey);
}
if (toPersist.length === 0) return out;
const persisted = await upsertImportedSkills(companyId, toPersist);
for (let index = 0; index < prepared.length; index += 1) {
const persistedSkill = persisted[index];
const preparedSkill = prepared[index];
if (!persistedSkill || !preparedSkill) continue;
out.push({
skill: persistedSkill,
action: preparedSkill.actionHint,
originalKey: preparedSkill.originalKey,
originalSlug: preparedSkill.originalSlug,
requestedRefs: Array.from(new Set([preparedSkill.originalKey, preparedSkill.originalSlug])),
reason: preparedSkill.reason,
});
}
return out;
}
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {