diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 131c837b..51c94d5e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -238,6 +238,8 @@ export type { CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, + CompanyPortabilityExportPreviewFile, + CompanyPortabilityExportPreviewResult, CompanyPortabilitySource, CompanyPortabilityImportTarget, CompanyPortabilityAgentSelection, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 3a7927e0..cec0021e 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -115,6 +115,27 @@ export interface CompanyPortabilityExportResult { paperclipExtensionPath: string; } +export interface CompanyPortabilityExportPreviewFile { + path: string; + kind: "company" | "agent" | "skill" | "project" | "issue" | "extension" | "readme" | "other"; +} + +export interface CompanyPortabilityExportPreviewResult { + rootPath: string; + manifest: CompanyPortabilityManifest; + files: Record; + fileInventory: CompanyPortabilityExportPreviewFile[]; + counts: { + files: number; + agents: number; + skills: number; + projects: number; + issues: number; + }; + warnings: string[]; + paperclipExtensionPath: string; +} + export type CompanyPortabilitySource = | { type: "inline"; @@ -220,8 +241,11 @@ export interface CompanyPortabilityImportResult { export interface CompanyPortabilityExportRequest { include?: Partial; + agents?: string[]; + skills?: string[]; projects?: string[]; issues?: string[]; projectIssues?: string[]; + selectedFiles?: string[]; expandReferencedSkills?: boolean; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2080ffa6..8d4a2832 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -136,6 +136,8 @@ export type { CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, + CompanyPortabilityExportPreviewFile, + CompanyPortabilityExportPreviewResult, CompanyPortabilitySource, CompanyPortabilityImportTarget, CompanyPortabilityAgentSelection, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 605841f0..9cdc6a52 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -150,9 +150,12 @@ export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "rep export const companyPortabilityExportSchema = z.object({ include: portabilityIncludeSchema.optional(), + agents: z.array(z.string().min(1)).optional(), + skills: z.array(z.string().min(1)).optional(), projects: z.array(z.string().min(1)).optional(), issues: z.array(z.string().min(1)).optional(), projectIssues: z.array(z.string().min(1)).optional(), + selectedFiles: z.array(z.string().min(1)).optional(), expandReferencedSkills: z.boolean().optional(), }); diff --git a/server/src/__tests__/companies-route-path-guard.test.ts b/server/src/__tests__/companies-route-path-guard.test.ts index ca43ae60..aef2c292 100644 --- a/server/src/__tests__/companies-route-path-guard.test.ts +++ b/server/src/__tests__/companies-route-path-guard.test.ts @@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({ }), companyPortabilityService: () => ({ exportBundle: vi.fn(), + previewExport: vi.fn(), previewImport: vi.fn(), importBundle: vi.fn(), }), diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index 352bdda4..86d9441c 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -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 () => { diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts new file mode 100644 index 00000000..bf1c16d3 --- /dev/null +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -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) { + 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"); + }); +}); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index a559814b..587d469f 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -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({ diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 49945db5..8cf6c307 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -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)) { diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 9ec0387d..0ba74d3b 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -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, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 22b74676..0c8cfff5 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -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 | 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) { + const agents = new Set(); + const projects = new Set(); + const tasks = new Set(); + 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) { + 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, + 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 = {}; + 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) { 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 { 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(); + 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(); + 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(); const idToSlug = new Map(); for (const agent of agentRows) { @@ -1890,8 +2061,35 @@ export function companyPortabilityService(db: Db) { const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; - const skillExportDirs = buildSkillExportDirMap(companySkillRows, company.issuePrefix); + const skillByReference = new Map(); 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(); + 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 { + async function previewExport( + companyId: string, + input: CompanyPortabilityExport, + ): Promise { + 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 { + 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 { - const plan = await buildPreview(input); + async function previewImport( + input: CompanyPortabilityPreview, + options?: ImportBehaviorOptions, + ): Promise { + const plan = await buildPreview(input, options); return plan.preview; } async function importBundle( input: CompanyPortabilityImport, actorUserId: string | null | undefined, + options?: ImportBehaviorOptions, ): Promise { - 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(); + 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; - 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, }; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index c3b6a5e3..87b7c11c 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -52,6 +52,17 @@ type ImportedSkill = { metadata: Record | 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) { + 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) { + 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): Promise { + async function importPackageFiles( + companyId: string, + files: Record, + options?: { + onConflict?: PackageSkillConflictStrategy; + }, + ): Promise { 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 { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 4a0eaa34..ae052dca 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -241,40 +241,67 @@ PATCH /api/agents/{agentId}/instructions-path ## Key Endpoints (Quick Reference) -| Action | Endpoint | -| ------------------------------------- | ------------------------------------------------------------------------------------------ | -| My identity | `GET /api/agents/me` | -| My compact inbox | `GET /api/agents/me/inbox-lite` | -| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | -| Checkout task | `POST /api/issues/:issueId/checkout` | -| Get task + ancestors | `GET /api/issues/:issueId` | -| List issue documents | `GET /api/issues/:issueId/documents` | -| Get issue document | `GET /api/issues/:issueId/documents/:key` | -| Create/update issue document | `PUT /api/issues/:issueId/documents/:key` | -| Get issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` | -| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` | -| Get comments | `GET /api/issues/:issueId/comments` | -| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` | -| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` | -| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | -| Add comment | `POST /api/issues/:issueId/comments` | -| Create subtask | `POST /api/companies/:companyId/issues` | -| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | -| Create project | `POST /api/companies/:companyId/projects` | -| Create project workspace | `POST /api/projects/:projectId/workspaces` | -| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | -| Release task | `POST /api/issues/:issueId/release` | -| List agents | `GET /api/companies/:companyId/agents` | -| List company skills | `GET /api/companies/:companyId/skills` | -| Import company skills | `POST /api/companies/:companyId/skills/import` | -| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | -| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` | -| Dashboard | `GET /api/companies/:companyId/dashboard` | -| Search issues | `GET /api/companies/:companyId/issues?q=search+term` | -| Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` | -| List issue attachments | `GET /api/issues/:issueId/attachments` | -| Get attachment content | `GET /api/attachments/:attachmentId/content` | -| Delete attachment | `DELETE /api/attachments/:attachmentId` | +| Action | Endpoint | +| ----------------------------------------- | ------------------------------------------------------------------------------------------ | +| My identity | `GET /api/agents/me` | +| My compact inbox | `GET /api/agents/me/inbox-lite` | +| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | +| Checkout task | `POST /api/issues/:issueId/checkout` | +| Get task + ancestors | `GET /api/issues/:issueId` | +| List issue documents | `GET /api/issues/:issueId/documents` | +| Get issue document | `GET /api/issues/:issueId/documents/:key` | +| Create/update issue document | `PUT /api/issues/:issueId/documents/:key` | +| Get issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` | +| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` | +| Get comments | `GET /api/issues/:issueId/comments` | +| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` | +| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` | +| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | +| Add comment | `POST /api/issues/:issueId/comments` | +| Create subtask | `POST /api/companies/:companyId/issues` | +| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | +| Create project | `POST /api/companies/:companyId/projects` | +| Create project workspace | `POST /api/projects/:projectId/workspaces` | +| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | +| Release task | `POST /api/issues/:issueId/release` | +| List agents | `GET /api/companies/:companyId/agents` | +| List company skills | `GET /api/companies/:companyId/skills` | +| Import company skills | `POST /api/companies/:companyId/skills/import` | +| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | +| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` | +| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` | +| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` | +| Preview company export | `POST /api/companies/:companyId/exports/preview` | +| Build company export | `POST /api/companies/:companyId/exports` | +| Dashboard | `GET /api/companies/:companyId/dashboard` | +| Search issues | `GET /api/companies/:companyId/issues?q=search+term` | +| Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` | +| List issue attachments | `GET /api/issues/:issueId/attachments` | +| Get attachment content | `GET /api/attachments/:attachmentId/content` | +| Delete attachment | `DELETE /api/attachments/:attachmentId` | + +## Company Import / Export + +Use the company-scoped routes when a CEO agent needs to inspect or move package content. + +- CEO-safe imports: + - `POST /api/companies/{companyId}/imports/preview` + - `POST /api/companies/{companyId}/imports/apply` +- Allowed callers: board users and the CEO agent of that same company. +- Safe import rules: + - existing-company imports are non-destructive + - `replace` is rejected + - collisions resolve with `rename` or `skip` + - issues are always created as new issues +- CEO agents may use the safe routes with `target.mode = "new_company"` to create a new company directly. Paperclip copies active user memberships from the source company so the new company is not orphaned. + +For export, preview first and keep tasks explicit: + +- `POST /api/companies/{companyId}/exports/preview` +- `POST /api/companies/{companyId}/exports` +- Export preview defaults to `issues: false` +- Add `issues` or `projectIssues` only when you intentionally need task files +- Use `selectedFiles` to narrow the final package to specific agents, skills, projects, or tasks after you inspect the preview inventory ## Searching Issues diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index cbf5ef05..be4e2143 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -39,6 +39,72 @@ Detailed reference for the Paperclip control plane API. For the core heartbeat p Use `chainOfCommand` to know who to escalate to. Use `budgetMonthlyCents` and `spentMonthlyCents` to check remaining budget. +### Company Portability + +CEO-safe package routes are company-scoped: + +- `POST /api/companies/:companyId/imports/preview` +- `POST /api/companies/:companyId/imports/apply` +- `POST /api/companies/:companyId/exports/preview` +- `POST /api/companies/:companyId/exports` + +Rules: + +- Allowed callers: board users and the CEO agent of that same company +- Safe import routes reject `collisionStrategy: "replace"` +- Existing-company safe imports only create new entities or skip collisions +- `new_company` safe imports are allowed and copy active user memberships from the source company +- Export preview defaults to `issues: false`; add task selectors explicitly when needed +- Use `selectedFiles` on export to narrow the final package after previewing the inventory + +Example safe import preview: + +```json +POST /api/companies/company-1/imports/preview +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "target": { "mode": "existing_company", "companyId": "company-1" }, + "collisionStrategy": "rename" +} +``` + +Example new-company safe import: + +```json +POST /api/companies/company-1/imports/apply +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": false }, + "target": { "mode": "new_company", "newCompanyName": "Imported Acme" }, + "collisionStrategy": "rename" +} +``` + +Example export preview without tasks: + +```json +POST /api/companies/company-1/exports/preview +{ + "include": { "company": true, "agents": true, "projects": true } +} +``` + +Example narrowed export with explicit tasks: + +```json +POST /api/companies/company-1/exports +{ + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "selectedFiles": [ + "COMPANY.md", + "agents/ceo/AGENTS.md", + "skills/paperclip/SKILL.md", + "tasks/pap-42/TASK.md" + ] +} +``` + ### Issue with Ancestors (`GET /api/issues/:issueId`) Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy. diff --git a/skills/paperclip/references/company-skills.md b/skills/paperclip/references/company-skills.md index 839599be..852424cc 100644 --- a/skills/paperclip/references/company-skills.md +++ b/skills/paperclip/references/company-skills.md @@ -149,3 +149,9 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents" - Built-in Paperclip runtime skills are still added automatically when required by the adapter. - If a reference is missing or ambiguous, the API returns `422`. - Prefer linking back to the relevant issue, approval, and agent when you comment about skill changes. +- Use company portability routes when you need whole-package import/export, not just a skill: + - `POST /api/companies/:companyId/imports/preview` + - `POST /api/companies/:companyId/imports/apply` + - `POST /api/companies/:companyId/exports/preview` + - `POST /api/companies/:companyId/exports` +- Use skill-only import when the task is specifically to add a skill to the company library without importing the surrounding company/team/package structure. diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index f19a063c..60a41742 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -1,5 +1,6 @@ import type { Company, + CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, @@ -38,12 +39,41 @@ export const companiesApi = { companyId: string, data: { include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + agents?: string[]; + skills?: string[]; projects?: string[]; issues?: string[]; projectIssues?: string[]; + selectedFiles?: string[]; }, ) => api.post(`/companies/${companyId}/export`, data), + exportPreview: ( + companyId: string, + data: { + include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + }, + ) => + api.post(`/companies/${companyId}/exports/preview`, data), + exportPackage: ( + companyId: string, + data: { + include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + }, + ) => + api.post(`/companies/${companyId}/exports`, data), importPreview: (data: CompanyPortabilityPreviewRequest) => api.post("/companies/import/preview", data), importBundle: (data: CompanyPortabilityImportRequest) => diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 4909f6fd..3cb44f2a 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import type { CompanyPortabilityExportResult, CompanyPortabilityManifest } from "@paperclipai/shared"; +import type { + CompanyPortabilityExportPreviewResult, + CompanyPortabilityExportResult, + CompanyPortabilityManifest, +} from "@paperclipai/shared"; import { useNavigate, useLocation } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -526,12 +530,13 @@ export function CompanyExport() { const navigate = useNavigate(); const location = useLocation(); - const [exportData, setExportData] = useState(null); + const [exportData, setExportData] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); const [treeSearch, setTreeSearch] = useState(""); const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); + const [includeTasks, setIncludeTasks] = useState(false); const savedExpandedRef = useRef | null>(null); const initialFileFromUrl = useRef(filePathFromLocation(location.pathname)); @@ -573,20 +578,21 @@ export function CompanyExport() { ]); }, [setBreadcrumbs]); - // Load export data on mount - const exportMutation = useMutation({ + const exportPreviewMutation = useMutation({ mutationFn: () => - companiesApi.exportBundle(selectedCompanyId!, { - include: { company: true, agents: true, projects: true, issues: true }, + companiesApi.exportPreview(selectedCompanyId!, { + include: { company: true, agents: true, projects: true, issues: includeTasks }, }), onSuccess: (result) => { setExportData(result); - // Check all files EXCEPT tasks by default - const checked = new Set(); - for (const filePath of Object.keys(result.files)) { - if (!isTaskPath(filePath)) checked.add(filePath); - } - setCheckedFiles(checked); + setCheckedFiles((prev) => { + const next = new Set(); + for (const filePath of Object.keys(result.files)) { + if (prev.has(filePath)) next.add(filePath); + else if (!isTaskPath(filePath)) next.add(filePath); + } + return next; + }); // Expand top-level dirs (except tasks — collapsed by default) const tree = buildFileTree(result.files); const topDirs = new Set(); @@ -618,13 +624,36 @@ export function CompanyExport() { }, }); + const downloadMutation = useMutation({ + mutationFn: () => + companiesApi.exportPackage(selectedCompanyId!, { + include: { company: true, agents: true, projects: true, issues: includeTasks }, + selectedFiles: Array.from(checkedFiles).sort(), + }), + onSuccess: (result) => { + const resultCheckedFiles = new Set(Object.keys(result.files)); + downloadZip(result, resultCheckedFiles, result.files); + pushToast({ + tone: "success", + title: "Export downloaded", + body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`, + }); + }, + onError: (err) => { + pushToast({ + tone: "error", + title: "Export failed", + body: err instanceof Error ? err.message : "Failed to build export package.", + }); + }, + }); + useEffect(() => { - if (selectedCompanyId && !exportData && !exportMutation.isPending) { - exportMutation.mutate(); - } - // Only run on mount + if (!selectedCompanyId || exportPreviewMutation.isPending) return; + setExportData(null); + exportPreviewMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCompanyId]); + }, [selectedCompanyId, includeTasks]); const tree = useMemo( () => (exportData ? buildFileTree(exportData.files) : []), @@ -774,20 +803,15 @@ export function CompanyExport() { } function handleDownload() { - if (!exportData) return; - downloadZip(exportData, checkedFiles, effectiveFiles); - pushToast({ - tone: "success", - title: "Export downloaded", - body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.zip`, - }); + if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return; + downloadMutation.mutate(); } if (!selectedCompanyId) { return ; } - if (exportMutation.isPending && !exportData) { + if (exportPreviewMutation.isPending && !exportData) { return ; } @@ -809,6 +833,13 @@ export function CompanyExport() { {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected + {warnings.length > 0 && ( {warnings.length} warning{warnings.length === 1 ? "" : "s"} @@ -818,10 +849,12 @@ export function CompanyExport() { diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 124ae4fd..d59e3c96 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewResult, CompanyPortabilitySource, CompanyPortabilityAdapterOverride, @@ -609,6 +610,7 @@ export function CompanyImport() { const [nameOverrides, setNameOverrides] = useState>({}); const [skippedSlugs, setSkippedSlugs] = useState>(new Set()); const [confirmedSlugs, setConfirmedSlugs] = useState>(new Set()); + const [collisionStrategy, setCollisionStrategy] = useState("rename"); // Adapter override state const [adapterOverrides, setAdapterOverrides] = useState>({}); @@ -656,7 +658,7 @@ export function CompanyImport() { targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, - collisionStrategy: "rename", + collisionStrategy, }); }, onSuccess: (result) => { @@ -760,7 +762,7 @@ export function CompanyImport() { targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, - collisionStrategy: "rename", + collisionStrategy, nameOverrides: buildFinalNameOverrides(), selectedFiles: buildSelectedFiles(), adapterOverrides: buildFinalAdapterOverrides(), @@ -1116,6 +1118,24 @@ export function CompanyImport() { )} + + + +