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:
parent
685c7549e1
commit
51ca713181
18 changed files with 1166 additions and 96 deletions
|
|
@ -238,6 +238,8 @@ export type {
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
CompanyPortabilityExportPreviewFile,
|
||||||
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilitySource,
|
CompanyPortabilitySource,
|
||||||
CompanyPortabilityImportTarget,
|
CompanyPortabilityImportTarget,
|
||||||
CompanyPortabilityAgentSelection,
|
CompanyPortabilityAgentSelection,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,27 @@ export interface CompanyPortabilityExportResult {
|
||||||
paperclipExtensionPath: string;
|
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<string, string>;
|
||||||
|
fileInventory: CompanyPortabilityExportPreviewFile[];
|
||||||
|
counts: {
|
||||||
|
files: number;
|
||||||
|
agents: number;
|
||||||
|
skills: number;
|
||||||
|
projects: number;
|
||||||
|
issues: number;
|
||||||
|
};
|
||||||
|
warnings: string[];
|
||||||
|
paperclipExtensionPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type CompanyPortabilitySource =
|
export type CompanyPortabilitySource =
|
||||||
| {
|
| {
|
||||||
type: "inline";
|
type: "inline";
|
||||||
|
|
@ -220,8 +241,11 @@ export interface CompanyPortabilityImportResult {
|
||||||
|
|
||||||
export interface CompanyPortabilityExportRequest {
|
export interface CompanyPortabilityExportRequest {
|
||||||
include?: Partial<CompanyPortabilityInclude>;
|
include?: Partial<CompanyPortabilityInclude>;
|
||||||
|
agents?: string[];
|
||||||
|
skills?: string[];
|
||||||
projects?: string[];
|
projects?: string[];
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
projectIssues?: string[];
|
projectIssues?: string[];
|
||||||
|
selectedFiles?: string[];
|
||||||
expandReferencedSkills?: boolean;
|
expandReferencedSkills?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,8 @@ export type {
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
CompanyPortabilityExportPreviewFile,
|
||||||
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilitySource,
|
CompanyPortabilitySource,
|
||||||
CompanyPortabilityImportTarget,
|
CompanyPortabilityImportTarget,
|
||||||
CompanyPortabilityAgentSelection,
|
CompanyPortabilityAgentSelection,
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,12 @@ export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "rep
|
||||||
|
|
||||||
export const companyPortabilityExportSchema = z.object({
|
export const companyPortabilityExportSchema = z.object({
|
||||||
include: portabilityIncludeSchema.optional(),
|
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(),
|
projects: z.array(z.string().min(1)).optional(),
|
||||||
issues: z.array(z.string().min(1)).optional(),
|
issues: z.array(z.string().min(1)).optional(),
|
||||||
projectIssues: 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(),
|
expandReferencedSkills: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
}),
|
}),
|
||||||
companyPortabilityService: () => ({
|
companyPortabilityService: () => ({
|
||||||
exportBundle: vi.fn(),
|
exportBundle: vi.fn(),
|
||||||
|
previewExport: vi.fn(),
|
||||||
previewImport: vi.fn(),
|
previewImport: vi.fn(),
|
||||||
importBundle: vi.fn(),
|
importBundle: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const mockBudgetService = vi.hoisted(() => ({
|
||||||
|
|
||||||
const mockCompanyPortabilityService = vi.hoisted(() => ({
|
const mockCompanyPortabilityService = vi.hoisted(() => ({
|
||||||
exportBundle: vi.fn(),
|
exportBundle: vi.fn(),
|
||||||
|
previewExport: vi.fn(),
|
||||||
previewImport: vi.fn(),
|
previewImport: vi.fn(),
|
||||||
importBundle: vi.fn(),
|
importBundle: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -170,10 +171,8 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
||||||
.send({ brandColor: null, logoAssetId: null });
|
.send({ brandColor: null, logoAssetId: null });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
|
expect(res.body.brandColor).toBeNull();
|
||||||
brandColor: null,
|
expect(res.body.logoAssetId).toBeNull();
|
||||||
logoAssetId: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-branding fields in the request body", async () => {
|
it("rejects non-branding fields in the request body", async () => {
|
||||||
|
|
|
||||||
174
server/src/__tests__/company-portability-routes.test.ts
Normal file
174
server/src/__tests__/company-portability-routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,8 @@ const agentSvc = {
|
||||||
|
|
||||||
const accessSvc = {
|
const accessSvc = {
|
||||||
ensureMembership: vi.fn(),
|
ensureMembership: vi.fn(),
|
||||||
|
listActiveUserMemberships: vi.fn(),
|
||||||
|
copyActiveUserMemberships: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectSvc = {
|
const projectSvc = {
|
||||||
|
|
@ -241,6 +243,17 @@ describe("company portability", () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
companySkillSvc.importPackageFiles.mockResolvedValue([]);
|
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 }) => ({
|
agentInstructionsSvc.exportFiles.mockImplementation(async (agent: { name: string }) => ({
|
||||||
files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." },
|
files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." },
|
||||||
entryFile: "AGENTS.md",
|
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");
|
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 () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -503,7 +562,9 @@ describe("company portability", () => {
|
||||||
collisionStrategy: "rename",
|
collisionStrategy: "rename",
|
||||||
}, "user-1");
|
}, "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({
|
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
adapterConfig: expect.objectContaining({
|
adapterConfig: expect.objectContaining({
|
||||||
paperclipSkillSync: {
|
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 () => {
|
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -567,12 +682,18 @@ describe("company portability", () => {
|
||||||
"COMPANY.md": expect.any(String),
|
"COMPANY.md": expect.any(String),
|
||||||
"agents/cmo/AGENTS.md": expect.any(String),
|
"agents/cmo/AGENTS.md": expect.any(String),
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
onConflict: "replace",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
|
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
expect.not.objectContaining({
|
expect.not.objectContaining({
|
||||||
"agents/claudecoder/AGENTS.md": expect.any(String),
|
"agents/claudecoder/AGENTS.md": expect.any(String),
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
onConflict: "replace",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
||||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
router.get("/", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const result = await svc.list();
|
const result = await svc.list();
|
||||||
|
|
@ -94,20 +108,18 @@ export function companyRoutes(db: Db) {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
if (req.body.target.mode === "existing_company") {
|
if (req.body.target.mode === "existing_company") {
|
||||||
assertCompanyAccess(req, req.body.target.companyId);
|
assertCompanyAccess(req, req.body.target.companyId);
|
||||||
} else {
|
|
||||||
assertBoard(req);
|
|
||||||
}
|
}
|
||||||
const preview = await portability.previewImport(req.body);
|
const preview = await portability.previewImport(req.body);
|
||||||
res.json(preview);
|
res.json(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
|
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
if (req.body.target.mode === "existing_company") {
|
if (req.body.target.mode === "existing_company") {
|
||||||
assertCompanyAccess(req, req.body.target.companyId);
|
assertCompanyAccess(req, req.body.target.companyId);
|
||||||
} else {
|
|
||||||
assertBoard(req);
|
|
||||||
}
|
}
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
|
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);
|
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) => {
|
router.post("/", validate(createCompanySchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,20 @@ export function accessService(db: Db) {
|
||||||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
.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(
|
async function setMemberPermissions(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
memberId: 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 {
|
return {
|
||||||
isInstanceAdmin,
|
isInstanceAdmin,
|
||||||
canUser,
|
canUser,
|
||||||
|
|
@ -258,6 +286,8 @@ export function accessService(db: Db) {
|
||||||
getMembership,
|
getMembership,
|
||||||
ensureMembership,
|
ensureMembership,
|
||||||
listMembers,
|
listMembers,
|
||||||
|
listActiveUserMemberships,
|
||||||
|
copyActiveUserMemberships,
|
||||||
setMemberPermissions,
|
setMemberPermissions,
|
||||||
promoteInstanceAdmin,
|
promoteInstanceAdmin,
|
||||||
demoteInstanceAdmin,
|
demoteInstanceAdmin,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
CompanyPortabilityCollisionStrategy,
|
CompanyPortabilityCollisionStrategy,
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityExport,
|
CompanyPortabilityExport,
|
||||||
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityImport,
|
CompanyPortabilityImport,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
|
|
@ -54,6 +55,27 @@ const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
|
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) {
|
function normalizeSkillSlug(value: string | null | undefined) {
|
||||||
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +379,13 @@ type ImportPlanInternal = {
|
||||||
selectedAgents: CompanyPortabilityAgentManifestEntry[];
|
selectedAgents: CompanyPortabilityAgentManifestEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ImportMode = "board_full" | "agent_safe";
|
||||||
|
|
||||||
|
type ImportBehaviorOptions = {
|
||||||
|
mode?: ImportMode;
|
||||||
|
sourceCompanyId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentLike = {
|
type AgentLike = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -515,6 +544,115 @@ function normalizeFileMap(
|
||||||
return out;
|
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>) {
|
function findPaperclipExtensionPath(files: Record<string, string>) {
|
||||||
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
||||||
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
||||||
|
|
@ -1731,6 +1869,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
): Promise<CompanyPortabilityExportResult> {
|
): Promise<CompanyPortabilityExportResult> {
|
||||||
const include = normalizeInclude({
|
const include = normalizeInclude({
|
||||||
...input.include,
|
...input.include,
|
||||||
|
agents: input.agents && input.agents.length > 0 ? true : input.include?.agents,
|
||||||
projects: input.projects && input.projects.length > 0 ? true : input.include?.projects,
|
projects: input.projects && input.projects.length > 0 ? true : input.include?.projects,
|
||||||
issues:
|
issues:
|
||||||
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
(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 rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||||
|
|
||||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
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);
|
const companySkillRows = await companySkills.listFull(companyId);
|
||||||
if (include.agents) {
|
if (include.agents) {
|
||||||
const skipped = allAgentRows.length - agentRows.length;
|
const skipped = allAgentRows.length - liveAgentRows.length;
|
||||||
if (skipped > 0) {
|
if (skipped > 0) {
|
||||||
warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`);
|
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 usedSlugs = new Set<string>();
|
||||||
const idToSlug = new Map<string, string>();
|
const idToSlug = new Map<string, string>();
|
||||||
for (const agent of agentRows) {
|
for (const agent of agentRows) {
|
||||||
|
|
@ -1890,8 +2061,35 @@ export function companyPortabilityService(db: Db) {
|
||||||
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
||||||
const paperclipTasksOut: 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) {
|
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"}`;
|
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
||||||
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||||
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
||||||
|
|
@ -2062,32 +2260,94 @@ export function companyPortabilityService(db: Db) {
|
||||||
{ preserveEmptyStrings: true },
|
{ preserveEmptyStrings: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolved = buildManifestFromPackageFiles(files, {
|
let finalFiles = filterExportFiles(files, input.selectedFiles, paperclipExtensionPath);
|
||||||
|
let resolved = buildManifestFromPackageFiles(finalFiles, {
|
||||||
sourceLabel: {
|
sourceLabel: {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
companyName: company.name,
|
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.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||||
resolved.warnings.unshift(...warnings);
|
resolved.warnings.unshift(...warnings);
|
||||||
|
|
||||||
// Generate README.md with Mermaid org chart
|
if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) {
|
||||||
files["README.md"] = generateReadme(resolved.manifest, {
|
finalFiles["README.md"] = generateReadme(resolved.manifest, {
|
||||||
companyName: company.name,
|
companyName: company.name,
|
||||||
companyDescription: company.description ?? null,
|
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 {
|
return {
|
||||||
rootPath,
|
rootPath,
|
||||||
manifest: resolved.manifest,
|
manifest: resolved.manifest,
|
||||||
files,
|
files: finalFiles,
|
||||||
warnings: resolved.warnings,
|
warnings: resolved.warnings,
|
||||||
paperclipExtensionPath,
|
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 requestedInclude = normalizeInclude(input.include);
|
||||||
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
|
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
|
||||||
const manifest = source.manifest;
|
const manifest = source.manifest;
|
||||||
|
|
@ -2098,6 +2358,9 @@ export function companyPortabilityService(db: Db) {
|
||||||
issues: requestedInclude.issues && manifest.issues.length > 0,
|
issues: requestedInclude.issues && manifest.issues.length > 0,
|
||||||
};
|
};
|
||||||
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
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 warnings = [...source.warnings];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
|
@ -2221,6 +2484,20 @@ export function companyPortabilityService(db: Db) {
|
||||||
}
|
}
|
||||||
existingProjectSlugs.add(existing.urlKey);
|
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) {
|
for (const manifestAgent of selectedAgents) {
|
||||||
|
|
@ -2236,7 +2513,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collisionStrategy === "replace") {
|
if (mode === "board_full" && collisionStrategy === "replace") {
|
||||||
agentPlans.push({
|
agentPlans.push({
|
||||||
slug: manifestAgent.slug,
|
slug: manifestAgent.slug,
|
||||||
action: "update",
|
action: "update",
|
||||||
|
|
@ -2282,7 +2559,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (collisionStrategy === "replace") {
|
if (mode === "board_full" && collisionStrategy === "replace") {
|
||||||
projectPlans.push({
|
projectPlans.push({
|
||||||
slug: manifestProject.slug,
|
slug: manifestProject.slug,
|
||||||
action: "update",
|
action: "update",
|
||||||
|
|
@ -2370,7 +2647,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
plan: {
|
plan: {
|
||||||
companyAction: input.target.mode === "new_company"
|
companyAction: input.target.mode === "new_company"
|
||||||
? "create"
|
? "create"
|
||||||
: include.company
|
: include.company && mode === "board_full"
|
||||||
? "update"
|
? "update"
|
||||||
: "none",
|
: "none",
|
||||||
agentPlans,
|
agentPlans,
|
||||||
|
|
@ -2393,19 +2670,34 @@ export function companyPortabilityService(db: Db) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function previewImport(input: CompanyPortabilityPreview): Promise<CompanyPortabilityPreviewResult> {
|
async function previewImport(
|
||||||
const plan = await buildPreview(input);
|
input: CompanyPortabilityPreview,
|
||||||
|
options?: ImportBehaviorOptions,
|
||||||
|
): Promise<CompanyPortabilityPreviewResult> {
|
||||||
|
const plan = await buildPreview(input, options);
|
||||||
return plan.preview;
|
return plan.preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBundle(
|
async function importBundle(
|
||||||
input: CompanyPortabilityImport,
|
input: CompanyPortabilityImport,
|
||||||
actorUserId: string | null | undefined,
|
actorUserId: string | null | undefined,
|
||||||
|
options?: ImportBehaviorOptions,
|
||||||
): Promise<CompanyPortabilityImportResult> {
|
): Promise<CompanyPortabilityImportResult> {
|
||||||
const plan = await buildPreview(input);
|
const mode = resolveImportMode(options);
|
||||||
|
const plan = await buildPreview(input, options);
|
||||||
if (plan.preview.errors.length > 0) {
|
if (plan.preview.errors.length > 0) {
|
||||||
throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`);
|
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 sourceManifest = plan.source.manifest;
|
||||||
const warnings = [...plan.preview.warnings];
|
const warnings = [...plan.preview.warnings];
|
||||||
|
|
@ -2415,6 +2707,15 @@ export function companyPortabilityService(db: Db) {
|
||||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||||
|
|
||||||
if (input.target.mode === "new_company") {
|
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 =
|
const companyName =
|
||||||
asString(input.target.newCompanyName) ??
|
asString(input.target.newCompanyName) ??
|
||||||
sourceManifest.company?.name ??
|
sourceManifest.company?.name ??
|
||||||
|
|
@ -2428,13 +2729,17 @@ export function companyPortabilityService(db: Db) {
|
||||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
|
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
|
||||||
: 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;
|
targetCompany = created;
|
||||||
companyAction = "created";
|
companyAction = "created";
|
||||||
} else {
|
} else {
|
||||||
targetCompany = await companies.getById(input.target.companyId);
|
targetCompany = await companies.getById(input.target.companyId);
|
||||||
if (!targetCompany) throw notFound("Target company not found");
|
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, {
|
const updated = await companies.update(targetCompany.id, {
|
||||||
name: sourceManifest.company.name,
|
name: sourceManifest.company.name,
|
||||||
description: sourceManifest.company.description,
|
description: sourceManifest.company.description,
|
||||||
|
|
@ -2462,7 +2767,19 @@ export function companyPortabilityService(db: Db) {
|
||||||
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
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) {
|
if (include.agents) {
|
||||||
for (const planAgent of plan.preview.plan.agentPlans) {
|
for (const planAgent of plan.preview.plan.agentPlans) {
|
||||||
|
|
@ -2501,7 +2818,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
? { ...adapterOverride.adapterConfig }
|
? { ...adapterOverride.adapterConfig }
|
||||||
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
|
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
|
||||||
|
|
||||||
const desiredSkills = manifestAgent.skills ?? [];
|
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
|
||||||
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
||||||
baseAdapterConfig,
|
baseAdapterConfig,
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
|
|
@ -2689,6 +3006,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exportBundle,
|
exportBundle,
|
||||||
|
previewExport,
|
||||||
previewImport,
|
previewImport,
|
||||||
importBundle,
|
importBundle,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ type ImportedSkill = {
|
||||||
metadata: Record<string, unknown> | null;
|
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 = {
|
type ParsedSkillImportSource = {
|
||||||
resolvedSource: string;
|
resolvedSource: string;
|
||||||
requestedSkillSlug: string | null;
|
requestedSkillSlug: string | null;
|
||||||
|
|
@ -180,6 +191,29 @@ function hashSkillValue(value: string) {
|
||||||
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
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) {
|
function buildSkillRuntimeName(key: string, slug: string) {
|
||||||
if (key.startsWith("paperclipai/paperclip/")) return slug;
|
if (key.startsWith("paperclipai/paperclip/")) return slug;
|
||||||
return `${slug}--${hashSkillValue(key)}`;
|
return `${slug}--${hashSkillValue(key)}`;
|
||||||
|
|
@ -1953,7 +1987,13 @@ export function companySkillService(db: Db) {
|
||||||
return out;
|
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);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const normalizedFiles = normalizePackageFileMap(files);
|
const normalizedFiles = normalizePackageFileMap(files);
|
||||||
const importedSkills = readInlineSkillImports(companyId, normalizedFiles);
|
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[]> {
|
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
|
||||||
|
|
|
||||||
|
|
@ -241,40 +241,67 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||||
|
|
||||||
## Key Endpoints (Quick Reference)
|
## Key Endpoints (Quick Reference)
|
||||||
|
|
||||||
| Action | Endpoint |
|
| Action | Endpoint |
|
||||||
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| My identity | `GET /api/agents/me` |
|
| My identity | `GET /api/agents/me` |
|
||||||
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
||||||
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||||
| List issue documents | `GET /api/issues/:issueId/documents` |
|
| List issue documents | `GET /api/issues/:issueId/documents` |
|
||||||
| Get issue document | `GET /api/issues/:issueId/documents/:key` |
|
| Get issue document | `GET /api/issues/:issueId/documents/:key` |
|
||||||
| Create/update issue document | `PUT /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 issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` |
|
||||||
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
||||||
| Get comments | `GET /api/issues/:issueId/comments` |
|
| Get comments | `GET /api/issues/:issueId/comments` |
|
||||||
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
||||||
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
||||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||||
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||||
| Create project | `POST /api/companies/:companyId/projects` |
|
| Create project | `POST /api/companies/:companyId/projects` |
|
||||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||||
| Release task | `POST /api/issues/:issueId/release` |
|
| Release task | `POST /api/issues/:issueId/release` |
|
||||||
| List agents | `GET /api/companies/:companyId/agents` |
|
| List agents | `GET /api/companies/:companyId/agents` |
|
||||||
| List company skills | `GET /api/companies/:companyId/skills` |
|
| List company skills | `GET /api/companies/:companyId/skills` |
|
||||||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||||
| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` |
|
| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` |
|
||||||
| Dashboard | `GET /api/companies/:companyId/dashboard` |
|
| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` |
|
||||||
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
|
| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` |
|
||||||
| Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` |
|
| Preview company export | `POST /api/companies/:companyId/exports/preview` |
|
||||||
| List issue attachments | `GET /api/issues/:issueId/attachments` |
|
| Build company export | `POST /api/companies/:companyId/exports` |
|
||||||
| Get attachment content | `GET /api/attachments/:attachmentId/content` |
|
| Dashboard | `GET /api/companies/:companyId/dashboard` |
|
||||||
| Delete attachment | `DELETE /api/attachments/:attachmentId` |
|
| 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
|
## Searching Issues
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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`)
|
### 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityImportRequest,
|
CompanyPortabilityImportRequest,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
|
|
@ -38,12 +39,41 @@ export const companiesApi = {
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: {
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
||||||
|
agents?: string[];
|
||||||
|
skills?: string[];
|
||||||
projects?: string[];
|
projects?: string[];
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
projectIssues?: string[];
|
projectIssues?: string[];
|
||||||
|
selectedFiles?: string[];
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
api.post<CompanyPortabilityExportResult>(`/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<CompanyPortabilityExportPreviewResult>(`/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<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
||||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||||
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
|
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
|
||||||
importBundle: (data: CompanyPortabilityImportRequest) =>
|
importBundle: (data: CompanyPortabilityImportRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { useNavigate, useLocation } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
|
@ -526,12 +530,13 @@ export function CompanyExport() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const [exportData, setExportData] = useState<CompanyPortabilityExportResult | null>(null);
|
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||||
const [treeSearch, setTreeSearch] = useState("");
|
const [treeSearch, setTreeSearch] = useState("");
|
||||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||||
|
const [includeTasks, setIncludeTasks] = useState(false);
|
||||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||||
|
|
||||||
|
|
@ -573,20 +578,21 @@ export function CompanyExport() {
|
||||||
]);
|
]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
// Load export data on mount
|
const exportPreviewMutation = useMutation({
|
||||||
const exportMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
companiesApi.exportBundle(selectedCompanyId!, {
|
companiesApi.exportPreview(selectedCompanyId!, {
|
||||||
include: { company: true, agents: true, projects: true, issues: true },
|
include: { company: true, agents: true, projects: true, issues: includeTasks },
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setExportData(result);
|
setExportData(result);
|
||||||
// Check all files EXCEPT tasks by default
|
setCheckedFiles((prev) => {
|
||||||
const checked = new Set<string>();
|
const next = new Set<string>();
|
||||||
for (const filePath of Object.keys(result.files)) {
|
for (const filePath of Object.keys(result.files)) {
|
||||||
if (!isTaskPath(filePath)) checked.add(filePath);
|
if (prev.has(filePath)) next.add(filePath);
|
||||||
}
|
else if (!isTaskPath(filePath)) next.add(filePath);
|
||||||
setCheckedFiles(checked);
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// Expand top-level dirs (except tasks — collapsed by default)
|
// Expand top-level dirs (except tasks — collapsed by default)
|
||||||
const tree = buildFileTree(result.files);
|
const tree = buildFileTree(result.files);
|
||||||
const topDirs = new Set<string>();
|
const topDirs = new Set<string>();
|
||||||
|
|
@ -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(() => {
|
useEffect(() => {
|
||||||
if (selectedCompanyId && !exportData && !exportMutation.isPending) {
|
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
||||||
exportMutation.mutate();
|
setExportData(null);
|
||||||
}
|
exportPreviewMutation.mutate();
|
||||||
// Only run on mount
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId, includeTasks]);
|
||||||
|
|
||||||
const tree = useMemo(
|
const tree = useMemo(
|
||||||
() => (exportData ? buildFileTree(exportData.files) : []),
|
() => (exportData ? buildFileTree(exportData.files) : []),
|
||||||
|
|
@ -774,20 +803,15 @@ export function CompanyExport() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!exportData) return;
|
if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return;
|
||||||
downloadZip(exportData, checkedFiles, effectiveFiles);
|
downloadMutation.mutate();
|
||||||
pushToast({
|
|
||||||
tone: "success",
|
|
||||||
title: "Export downloaded",
|
|
||||||
body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.zip`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Package} message="Select a company to export." />;
|
return <EmptyState icon={Package} message="Select a company to export." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exportMutation.isPending && !exportData) {
|
if (exportPreviewMutation.isPending && !exportData) {
|
||||||
return <PageSkeleton variant="detail" />;
|
return <PageSkeleton variant="detail" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -809,6 +833,13 @@ export function CompanyExport() {
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
|
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground underline underline-offset-4"
|
||||||
|
onClick={() => setIncludeTasks((value) => !value)}
|
||||||
|
>
|
||||||
|
{includeTasks ? "Hide task files" : "Load task files"}
|
||||||
|
</button>
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<span className="text-amber-500">
|
<span className="text-amber-500">
|
||||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||||
|
|
@ -818,10 +849,12 @@ export function CompanyExport() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0 || downloadMutation.isPending}
|
||||||
>
|
>
|
||||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Export {selectedCount} file{selectedCount === 1 ? "" : "s"}
|
{downloadMutation.isPending
|
||||||
|
? "Building export..."
|
||||||
|
: `Export ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
CompanyPortabilityCollisionStrategy,
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
CompanyPortabilitySource,
|
CompanyPortabilitySource,
|
||||||
CompanyPortabilityAdapterOverride,
|
CompanyPortabilityAdapterOverride,
|
||||||
|
|
@ -609,6 +610,7 @@ export function CompanyImport() {
|
||||||
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
|
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
|
||||||
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
||||||
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
||||||
|
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>("rename");
|
||||||
|
|
||||||
// Adapter override state
|
// Adapter override state
|
||||||
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
||||||
|
|
@ -656,7 +658,7 @@ export function CompanyImport() {
|
||||||
targetMode === "new"
|
targetMode === "new"
|
||||||
? { mode: "new_company", newCompanyName: newCompanyName || null }
|
? { mode: "new_company", newCompanyName: newCompanyName || null }
|
||||||
: { mode: "existing_company", companyId: selectedCompanyId! },
|
: { mode: "existing_company", companyId: selectedCompanyId! },
|
||||||
collisionStrategy: "rename",
|
collisionStrategy,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
|
@ -760,7 +762,7 @@ export function CompanyImport() {
|
||||||
targetMode === "new"
|
targetMode === "new"
|
||||||
? { mode: "new_company", newCompanyName: newCompanyName || null }
|
? { mode: "new_company", newCompanyName: newCompanyName || null }
|
||||||
: { mode: "existing_company", companyId: selectedCompanyId! },
|
: { mode: "existing_company", companyId: selectedCompanyId! },
|
||||||
collisionStrategy: "rename",
|
collisionStrategy,
|
||||||
nameOverrides: buildFinalNameOverrides(),
|
nameOverrides: buildFinalNameOverrides(),
|
||||||
selectedFiles: buildSelectedFiles(),
|
selectedFiles: buildSelectedFiles(),
|
||||||
adapterOverrides: buildFinalAdapterOverrides(),
|
adapterOverrides: buildFinalAdapterOverrides(),
|
||||||
|
|
@ -1116,6 +1118,24 @@ export function CompanyImport() {
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Collision strategy"
|
||||||
|
hint="Board imports can rename, skip, or replace matching company content."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
value={collisionStrategy}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCollisionStrategy(e.target.value as CompanyPortabilityCollisionStrategy);
|
||||||
|
setImportPreview(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="rename">Rename on conflict</option>
|
||||||
|
<option value="skip">Skip on conflict</option>
|
||||||
|
<option value="replace">Replace existing</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1142,7 +1162,7 @@ export function CompanyImport() {
|
||||||
</span>
|
</span>
|
||||||
{conflicts.length > 0 && (
|
{conflicts.length > 0 && (
|
||||||
<span className="text-amber-500">
|
<span className="text-amber-500">
|
||||||
{conflicts.length} rename{conflicts.length === 1 ? "" : "s"}
|
{conflicts.length} conflict{conflicts.length === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{importPreview.errors.length > 0 && (
|
{importPreview.errors.length > 0 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue