mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
This commit is contained in:
commit
eeb7e1a91a
36 changed files with 5238 additions and 271 deletions
|
|
@ -1,3 +1,7 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
||||
|
|
@ -25,6 +29,8 @@ const projectSvc = {
|
|||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
listWorkspaces: vi.fn(),
|
||||
};
|
||||
|
||||
const issueSvc = {
|
||||
|
|
@ -34,6 +40,13 @@ const issueSvc = {
|
|||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const routineSvc = {
|
||||
list: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createTrigger: vi.fn(),
|
||||
};
|
||||
|
||||
const companySkillSvc = {
|
||||
list: vi.fn(),
|
||||
listFull: vi.fn(),
|
||||
|
|
@ -71,6 +84,10 @@ vi.mock("../services/issues.js", () => ({
|
|||
issueService: () => issueSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../services/routines.js", () => ({
|
||||
routineService: () => routineSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../services/company-skills.js", () => ({
|
||||
companySkillService: () => companySkillSvc,
|
||||
}));
|
||||
|
|
@ -184,9 +201,62 @@ describe("company portability", () => {
|
|||
},
|
||||
]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.createWorkspace.mockResolvedValue(null);
|
||||
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([]);
|
||||
issueSvc.getById.mockResolvedValue(null);
|
||||
issueSvc.getByIdentifier.mockResolvedValue(null);
|
||||
routineSvc.list.mockResolvedValue([]);
|
||||
routineSvc.getDetail.mockImplementation(async (id: string) => {
|
||||
const rows = await routineSvc.list();
|
||||
return rows.find((row: { id: string }) => row.id === id) ?? null;
|
||||
});
|
||||
routineSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "routine-created",
|
||||
companyId: "company-1",
|
||||
projectId: input.projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
priority: input.priority ?? "medium",
|
||||
status: input.status ?? "active",
|
||||
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
|
||||
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
routineSvc.createTrigger.mockImplementation(async (_routineId: string, input: Record<string, unknown>) => ({
|
||||
id: "trigger-created",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-created",
|
||||
kind: input.kind,
|
||||
label: input.label ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
cronExpression: input.kind === "schedule" ? input.cronExpression ?? null : null,
|
||||
timezone: input.kind === "schedule" ? input.timezone ?? null : null,
|
||||
nextRunAt: null,
|
||||
lastFiredAt: null,
|
||||
publicId: null,
|
||||
secretId: null,
|
||||
signingMode: input.kind === "webhook" ? input.signingMode ?? "bearer" : null,
|
||||
replayWindowSec: input.kind === "webhook" ? input.replayWindowSec ?? 300 : null,
|
||||
lastRotatedAt: null,
|
||||
lastResult: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const companySkills = [
|
||||
{
|
||||
id: "skill-1",
|
||||
|
|
@ -370,6 +440,64 @@ describe("company portability", () => {
|
|||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||
});
|
||||
|
||||
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-2",
|
||||
companyId: "company-1",
|
||||
name: "Zulu",
|
||||
urlKey: "zulu",
|
||||
description: null,
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: null,
|
||||
archivedAt: null,
|
||||
workspaces: [],
|
||||
},
|
||||
{
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Alpha",
|
||||
urlKey: "alpha",
|
||||
description: null,
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: null,
|
||||
archivedAt: null,
|
||||
workspaces: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain([
|
||||
"sidebar:",
|
||||
" agents:",
|
||||
' - "claudecoder"',
|
||||
' - "cmo"',
|
||||
" projects:",
|
||||
' - "alpha"',
|
||||
' - "zulu"',
|
||||
].join("\n"));
|
||||
expect(exported.manifest.sidebar).toEqual({
|
||||
agents: ["claudecoder", "cmo"],
|
||||
projects: ["alpha", "zulu"],
|
||||
});
|
||||
});
|
||||
|
||||
it("expands referenced skills when requested", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
@ -599,6 +727,388 @@ describe("company portability", () => {
|
|||
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("exports portable project workspace metadata and remaps it on import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: "2026-03-31",
|
||||
color: "#123456",
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
defaultProjectWorkspaceId: "workspace-1",
|
||||
workspaceStrategy: {
|
||||
type: "project_primary",
|
||||
},
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Main Repo",
|
||||
sourceType: "git_repo",
|
||||
cwd: "/Users/dotta/paperclip",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
defaultRef: "main",
|
||||
visibility: "default",
|
||||
setupCommand: "pnpm install",
|
||||
cleanupCommand: "rm -rf .paperclip-tmp",
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: {
|
||||
language: "typescript",
|
||||
},
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "workspace-2",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Local Scratch",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/paperclip-local",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "advanced",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
isPrimary: false,
|
||||
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
archivedAt: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Write launch task",
|
||||
description: "Task body",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("workspaces:");
|
||||
expect(extension).toContain("main-repo:");
|
||||
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||
expect(extension).toContain('defaultProjectWorkspaceKey: "main-repo"');
|
||||
expect(extension).toContain('projectWorkspaceKey: "main-repo"');
|
||||
expect(extension).not.toContain("/Users/dotta/paperclip");
|
||||
expect(extension).not.toContain("workspace-1");
|
||||
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
projectSvc.create.mockResolvedValue({
|
||||
id: "project-imported",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
});
|
||||
projectSvc.update.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||
id: projectId,
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
...data,
|
||||
}));
|
||||
projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||
id: "workspace-imported",
|
||||
companyId: "company-imported",
|
||||
projectId,
|
||||
name: `${data.name ?? "Workspace"}`,
|
||||
sourceType: `${data.sourceType ?? "git_repo"}`,
|
||||
cwd: null,
|
||||
repoUrl: typeof data.repoUrl === "string" ? data.repoUrl : null,
|
||||
repoRef: typeof data.repoRef === "string" ? data.repoRef : null,
|
||||
defaultRef: typeof data.defaultRef === "string" ? data.defaultRef : null,
|
||||
visibility: `${data.visibility ?? "default"}`,
|
||||
setupCommand: typeof data.setupCommand === "string" ? data.setupCommand : null,
|
||||
cleanupCommand: typeof data.cleanupCommand === "string" ? data.cleanupCommand : null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
isPrimary: Boolean(data.isPrimary),
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
}));
|
||||
issueSvc.create.mockResolvedValue({
|
||||
id: "issue-imported",
|
||||
title: "Write launch task",
|
||||
});
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: true,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: true,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(projectSvc.createWorkspace).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||
name: "Main Repo",
|
||||
sourceType: "git_repo",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
defaultRef: "main",
|
||||
visibility: "default",
|
||||
}));
|
||||
expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||
executionWorkspacePolicy: expect.objectContaining({
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
defaultProjectWorkspaceId: "workspace-imported",
|
||||
}),
|
||||
}));
|
||||
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
projectId: "project-imported",
|
||||
projectWorkspaceId: "workspace-imported",
|
||||
title: "Write launch task",
|
||||
}));
|
||||
});
|
||||
|
||||
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
|
||||
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
|
||||
execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], {
|
||||
cwd: repoDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Paperclip App",
|
||||
urlKey: "paperclip-app",
|
||||
description: "Ship it",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
defaultProjectWorkspaceId: "workspace-1",
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "paperclip",
|
||||
sourceType: "local_path",
|
||||
cwd: repoDir,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
archivedAt: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Task one",
|
||||
description: "Task body",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||
expect(extension).toContain('projectWorkspaceKey: "paperclip"');
|
||||
expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl"));
|
||||
expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1"));
|
||||
});
|
||||
|
||||
it("collapses repeated task workspace warnings into one summary per missing workspace", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Local Scratch",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/local-only",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
archivedAt: null,
|
||||
},
|
||||
]);
|
||||
issueSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Task one",
|
||||
description: null,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "Task two",
|
||||
description: null,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
{
|
||||
id: "issue-3",
|
||||
identifier: "PAP-3",
|
||||
title: "Task three",
|
||||
description: null,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
|
||||
expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably.");
|
||||
expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0);
|
||||
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
@ -654,6 +1164,360 @@ describe("company portability", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("exports routines as recurring task packages with Paperclip routine extensions", 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,
|
||||
},
|
||||
]);
|
||||
routineSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "routine-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Monday Review",
|
||||
description: "Review pipeline health",
|
||||
assigneeAgentId: "agent-1",
|
||||
priority: "high",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
triggers: [
|
||||
{
|
||||
id: "trigger-1",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-1",
|
||||
kind: "schedule",
|
||||
label: "Weekly cadence",
|
||||
enabled: true,
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
nextRunAt: null,
|
||||
lastFiredAt: null,
|
||||
publicId: "public-1",
|
||||
secretId: "secret-1",
|
||||
signingMode: null,
|
||||
replayWindowSec: null,
|
||||
lastRotatedAt: null,
|
||||
lastResult: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "trigger-2",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-1",
|
||||
kind: "webhook",
|
||||
label: "External nudge",
|
||||
enabled: false,
|
||||
cronExpression: null,
|
||||
timezone: null,
|
||||
nextRunAt: null,
|
||||
lastFiredAt: null,
|
||||
publicId: "public-2",
|
||||
secretId: "secret-2",
|
||||
signingMode: "hmac_sha256",
|
||||
replayWindowSec: 120,
|
||||
lastRotatedAt: null,
|
||||
lastResult: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
lastRun: null,
|
||||
activeIssue: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(asTextFile(exported.files["tasks/monday-review/TASK.md"])).toContain('recurring: true');
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("routines:");
|
||||
expect(extension).toContain("monday-review:");
|
||||
expect(extension).toContain('cronExpression: "0 9 * * 1"');
|
||||
expect(extension).toContain('signingMode: "hmac_sha256"');
|
||||
expect(extension).not.toContain("secretId");
|
||||
expect(extension).not.toContain("publicId");
|
||||
expect(exported.manifest.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "monday-review",
|
||||
recurring: true,
|
||||
status: "paused",
|
||||
priority: "high",
|
||||
routine: expect.objectContaining({
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
triggers: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
|
||||
expect.objectContaining({ kind: "webhook", enabled: false, signingMode: "hmac_sha256", replayWindowSec: 120 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports recurring task packages as routines instead of one-time issues", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.create.mockResolvedValue({
|
||||
id: "agent-created",
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
projectSvc.create.mockResolvedValue({
|
||||
id: "project-created",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
});
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
|
||||
const files = {
|
||||
"COMPANY.md": [
|
||||
"---",
|
||||
'schema: "agentcompanies/v1"',
|
||||
'name: "Imported Paperclip"',
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
"agents/claudecoder/AGENTS.md": [
|
||||
"---",
|
||||
'name: "ClaudeCoder"',
|
||||
"---",
|
||||
"",
|
||||
"You write code.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"projects/launch/PROJECT.md": [
|
||||
"---",
|
||||
'name: "Launch"',
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
"tasks/monday-review/TASK.md": [
|
||||
"---",
|
||||
'name: "Monday Review"',
|
||||
'project: "launch"',
|
||||
'assignee: "claudecoder"',
|
||||
"recurring: true",
|
||||
"---",
|
||||
"",
|
||||
"Review pipeline health.",
|
||||
"",
|
||||
].join("\n"),
|
||||
".paperclip.yaml": [
|
||||
'schema: "paperclip/v1"',
|
||||
"routines:",
|
||||
" monday-review:",
|
||||
' status: "paused"',
|
||||
' priority: "high"',
|
||||
' concurrencyPolicy: "always_enqueue"',
|
||||
' catchUpPolicy: "enqueue_missed_with_cap"',
|
||||
" triggers:",
|
||||
" - kind: schedule",
|
||||
' cronExpression: "0 9 * * 1"',
|
||||
' timezone: "America/Chicago"',
|
||||
' - kind: webhook',
|
||||
' enabled: false',
|
||||
' signingMode: "hmac_sha256"',
|
||||
' replayWindowSec: 120',
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(preview.errors).toEqual([]);
|
||||
expect(preview.plan.issuePlans).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "monday-review",
|
||||
reason: "Recurring task will be imported as a routine.",
|
||||
}),
|
||||
]);
|
||||
|
||||
await portability.importBundle({
|
||||
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
projectId: "project-created",
|
||||
title: "Monday Review",
|
||||
assigneeAgentId: "agent-created",
|
||||
priority: "high",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
}), expect.any(Object));
|
||||
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
|
||||
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
}), expect.any(Object));
|
||||
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||
kind: "webhook",
|
||||
enabled: false,
|
||||
signingMode: "hmac_sha256",
|
||||
replayWindowSec: 120,
|
||||
}), expect.any(Object));
|
||||
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates legacy schedule.recurrence imports into routine triggers", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.create.mockResolvedValue({
|
||||
id: "agent-created",
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
projectSvc.create.mockResolvedValue({
|
||||
id: "project-created",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
});
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
|
||||
const files = {
|
||||
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||
"agents/claudecoder/AGENTS.md": ['---', 'name: "ClaudeCoder"', "---", "", "You write code.", ""].join("\n"),
|
||||
"projects/launch/PROJECT.md": ['---', 'name: "Launch"', "---", ""].join("\n"),
|
||||
"tasks/monday-review/TASK.md": [
|
||||
"---",
|
||||
'name: "Monday Review"',
|
||||
'project: "launch"',
|
||||
'assignee: "claudecoder"',
|
||||
"schedule:",
|
||||
' timezone: "America/Chicago"',
|
||||
' startsAt: "2026-03-16T09:00:00-05:00"',
|
||||
" recurrence:",
|
||||
' frequency: "weekly"',
|
||||
" interval: 1",
|
||||
" weekdays:",
|
||||
' - "monday"',
|
||||
"---",
|
||||
"",
|
||||
"Review pipeline health.",
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(preview.errors).toEqual([]);
|
||||
expect(preview.manifest.issues[0]).toEqual(expect.objectContaining({
|
||||
recurring: true,
|
||||
legacyRecurrence: expect.objectContaining({ frequency: "weekly" }),
|
||||
}));
|
||||
|
||||
await portability.importBundle({
|
||||
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
}), expect.any(Object));
|
||||
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flags recurring task imports that are missing routine-required fields", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||
"tasks/monday-review/TASK.md": [
|
||||
"---",
|
||||
'name: "Monday Review"',
|
||||
"recurring: true",
|
||||
"---",
|
||||
"",
|
||||
"Review pipeline health.",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
include: { company: true, agents: false, projects: false, issues: true, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(preview.errors).toContain("Recurring task monday-review must declare a project to import as a routine.");
|
||||
expect(preview.errors).toContain("Recurring task monday-review must declare an assignee to import as a routine.");
|
||||
});
|
||||
|
||||
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
@ -1026,6 +1890,61 @@ describe("company portability", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("disables timer heartbeats on imported agents", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: `agent-${String(input.name).toLowerCase()}`,
|
||||
name: input.name,
|
||||
adapterConfig: input.adapterConfig,
|
||||
runtimeConfig: input.runtimeConfig,
|
||||
}));
|
||||
|
||||
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",
|
||||
}, "user-1");
|
||||
|
||||
const createdClaude = agentSvc.create.mock.calls.find(([, input]) => input.name === "ClaudeCoder");
|
||||
expect(createdClaude?.[1]).toMatchObject({
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
@ -1096,6 +2015,11 @@ describe("company portability", () => {
|
|||
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
name: "CMO",
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(result.company.action).toBe("unchanged");
|
||||
expect(result.agents).toEqual([
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import {
|
||||
discoverProjectWorkspaceSkillDirectories,
|
||||
findMissingLocalSkillIds,
|
||||
normalizeGitHubSkillDirectory,
|
||||
parseSkillImportSourceInput,
|
||||
readLocalSkillImportFromDirectory,
|
||||
} from "../services/company-skills.js";
|
||||
|
|
@ -86,6 +87,13 @@ describe("company skill import source parsing", () => {
|
|||
});
|
||||
|
||||
describe("project workspace skill discovery", () => {
|
||||
it("normalizes GitHub skill directories for blob imports and legacy metadata", () => {
|
||||
expect(normalizeGitHubSkillDirectory("retro/.", "retro")).toBe("retro");
|
||||
expect(normalizeGitHubSkillDirectory("retro/SKILL.md", "retro")).toBe("retro");
|
||||
expect(normalizeGitHubSkillDirectory("SKILL.md", "root-skill")).toBe("");
|
||||
expect(normalizeGitHubSkillDirectory("", "fallback-skill")).toBe("fallback-skill");
|
||||
});
|
||||
|
||||
it("finds bounded skill roots under supported workspace paths", async () => {
|
||||
const workspace = await makeTempDir("paperclip-skill-workspace-");
|
||||
await writeSkillDir(workspace, "Workspace Root");
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ export async function createApp(
|
|||
const app = express();
|
||||
|
||||
app.use(express.json({
|
||||
// Company import/export payloads can inline full portable packages.
|
||||
limit: "10mb",
|
||||
verify: (req, _res, buf) => {
|
||||
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface OrgNode {
|
|||
role: string;
|
||||
status: string;
|
||||
reports: OrgNode[];
|
||||
/** Populated by collapseTree: the flattened list of hidden descendants for avatar grid rendering. */
|
||||
collapsedReports?: OrgNode[];
|
||||
}
|
||||
|
||||
export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic";
|
||||
|
|
@ -321,6 +323,12 @@ const CARD_PAD_X = 22;
|
|||
const AVATAR_SIZE = 34;
|
||||
const GAP_X = 24;
|
||||
const GAP_Y = 56;
|
||||
|
||||
// ── Collapsed avatar grid constants ─────────────────────────────
|
||||
const MINI_AVATAR_SIZE = 14;
|
||||
const MINI_AVATAR_GAP = 6;
|
||||
const MINI_AVATAR_PADDING = 10;
|
||||
const MINI_AVATAR_MAX_COLS = 8; // max avatars per row in the grid
|
||||
const PADDING = 48;
|
||||
const LOGO_PADDING = 16;
|
||||
|
||||
|
|
@ -330,11 +338,42 @@ function measureText(text: string, fontSize: number): number {
|
|||
return text.length * fontSize * 0.58;
|
||||
}
|
||||
|
||||
/** Calculate how many rows the avatar grid needs. */
|
||||
function avatarGridRows(count: number): number {
|
||||
return Math.ceil(count / MINI_AVATAR_MAX_COLS);
|
||||
}
|
||||
|
||||
/** Width needed for the avatar grid. */
|
||||
function avatarGridWidth(count: number): number {
|
||||
const cols = Math.min(count, MINI_AVATAR_MAX_COLS);
|
||||
return cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
|
||||
}
|
||||
|
||||
/** Height of the avatar grid area. */
|
||||
function avatarGridHeight(count: number): number {
|
||||
if (count === 0) return 0;
|
||||
const rows = avatarGridRows(count);
|
||||
return rows * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
|
||||
}
|
||||
|
||||
function cardWidth(node: OrgNode): number {
|
||||
const { roleLabel } = getRoleInfo(node);
|
||||
const { roleLabel: defaultRoleLabel } = getRoleInfo(node);
|
||||
const roleLabel = node.role.startsWith("×") ? node.role : defaultRoleLabel;
|
||||
const nameW = measureText(node.name, 14) + CARD_PAD_X * 2;
|
||||
const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2;
|
||||
return Math.max(CARD_MIN_W, Math.max(nameW, roleW));
|
||||
let w = Math.max(CARD_MIN_W, Math.max(nameW, roleW));
|
||||
// Widen for avatar grid if needed
|
||||
if (node.collapsedReports && node.collapsedReports.length > 0) {
|
||||
w = Math.max(w, avatarGridWidth(node.collapsedReports.length));
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
function cardHeight(node: OrgNode): number {
|
||||
if (node.collapsedReports && node.collapsedReports.length > 0) {
|
||||
return CARD_H + avatarGridHeight(node.collapsedReports.length);
|
||||
}
|
||||
return CARD_H;
|
||||
}
|
||||
|
||||
// ── Tree layout (top-down, centered) ─────────────────────────────
|
||||
|
|
@ -354,18 +393,19 @@ function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
|
|||
const sw = subtreeWidth(node);
|
||||
const cardX = x + (sw - w) / 2;
|
||||
|
||||
const h = cardHeight(node);
|
||||
const layoutNode: LayoutNode = {
|
||||
node,
|
||||
x: cardX,
|
||||
y,
|
||||
width: w,
|
||||
height: CARD_H,
|
||||
height: h,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (node.reports && node.reports.length > 0) {
|
||||
let childX = x;
|
||||
const childY = y + CARD_H + GAP_Y;
|
||||
const childY = y + h + GAP_Y;
|
||||
for (let i = 0; i < node.reports.length; i++) {
|
||||
const child = node.reports[i];
|
||||
const childSW = subtreeWidth(child);
|
||||
|
|
@ -394,7 +434,19 @@ function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: strin
|
|||
}
|
||||
|
||||
function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
|
||||
const { roleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
|
||||
// Overflow placeholder card: just shows "+N more" text, no avatar
|
||||
if (ln.node.role === "overflow") {
|
||||
const cx = ln.x + ln.width / 2;
|
||||
const cy = ln.y + ln.height / 2;
|
||||
return `<g>
|
||||
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.bgColor}" stroke="${theme.cardBorder}" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="${theme.font}" font-size="13" font-weight="600" fill="${theme.roleColor}">${escapeXml(ln.node.name)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const { roleLabel: defaultRoleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
|
||||
// Use node.role directly when it's a collapse badge (e.g. "×15 reports")
|
||||
const roleLabel = ln.node.role.startsWith("×") ? ln.node.role : defaultRoleLabel;
|
||||
const cx = ln.x + ln.width / 2;
|
||||
|
||||
const avatarCY = ln.y + 27;
|
||||
|
|
@ -417,12 +469,33 @@ function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
|
|||
const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)";
|
||||
const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)";
|
||||
|
||||
// Render collapsed avatar grid if this node has hidden reports
|
||||
let avatarGridSvg = "";
|
||||
const collapsed = ln.node.collapsedReports;
|
||||
if (collapsed && collapsed.length > 0) {
|
||||
const gridTop = ln.y + CARD_H + MINI_AVATAR_PADDING;
|
||||
const cols = Math.min(collapsed.length, MINI_AVATAR_MAX_COLS);
|
||||
const gridTotalW = cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP;
|
||||
const gridStartX = ln.x + (ln.width - gridTotalW) / 2;
|
||||
|
||||
for (let i = 0; i < collapsed.length; i++) {
|
||||
const col = i % MINI_AVATAR_MAX_COLS;
|
||||
const row = Math.floor(i / MINI_AVATAR_MAX_COLS);
|
||||
const dotCx = gridStartX + col * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
|
||||
const dotCy = gridTop + row * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
|
||||
const { bg: dotBg } = getRoleInfo(collapsed[i]);
|
||||
const dotFill = isLight ? dotBg : "rgba(255,255,255,0.1)";
|
||||
avatarGridSvg += `<circle cx="${dotCx}" cy="${dotCy}" r="${MINI_AVATAR_SIZE / 2}" fill="${dotFill}" stroke="${theme.cardBorder}" stroke-width="0.5"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<g>
|
||||
${shadowDef}
|
||||
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1" ${shadowFilter}/>
|
||||
${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)}
|
||||
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="14" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
|
||||
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="11" font-weight="500" fill="${theme.roleColor}">${escapeXml(roleLabel)}</text>
|
||||
${avatarGridSvg}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
|
|
@ -496,19 +569,154 @@ const PAPERCLIP_LOGO_SVG = `<g>
|
|||
const TARGET_W = 1280;
|
||||
const TARGET_H = 640;
|
||||
|
||||
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string {
|
||||
export interface OrgChartOverlay {
|
||||
/** Company name displayed top-left */
|
||||
companyName?: string;
|
||||
/** Summary stats displayed bottom-right, e.g. "Agents: 5, Skills: 8" */
|
||||
stats?: string;
|
||||
}
|
||||
|
||||
/** Count total nodes in a tree. */
|
||||
function countNodes(nodes: OrgNode[]): number {
|
||||
let count = 0;
|
||||
for (const n of nodes) {
|
||||
count += 1 + countNodes(n.reports ?? []);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Threshold: auto-collapse orgs larger than this. */
|
||||
const COLLAPSE_THRESHOLD = 20;
|
||||
/** Max cards that can fit across the 1280px image. */
|
||||
const MAX_LEVEL_WIDTH = 8;
|
||||
/** Max children shown per parent before truncation with "and N more". */
|
||||
const MAX_CHILDREN_SHOWN = 6;
|
||||
|
||||
/** Flatten all descendants of a node into a single list. */
|
||||
function flattenDescendants(nodes: OrgNode[]): OrgNode[] {
|
||||
const result: OrgNode[] = [];
|
||||
for (const n of nodes) {
|
||||
result.push(n);
|
||||
result.push(...flattenDescendants(n.reports ?? []));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Collect all nodes at a given depth in the tree. */
|
||||
function nodesAtDepth(nodes: OrgNode[], depth: number): OrgNode[] {
|
||||
if (depth === 0) return nodes;
|
||||
const result: OrgNode[] = [];
|
||||
for (const n of nodes) {
|
||||
result.push(...nodesAtDepth(n.reports ?? [], depth - 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate how many cards would be shown at the next level if we expand,
|
||||
* considering truncation (each parent shows at most MAX_CHILDREN_SHOWN + 1 placeholder).
|
||||
*/
|
||||
function estimateNextLevelWidth(parentNodes: OrgNode[]): number {
|
||||
let total = 0;
|
||||
for (const p of parentNodes) {
|
||||
const childCount = (p.reports ?? []).length;
|
||||
if (childCount === 0) continue;
|
||||
total += Math.min(childCount, MAX_CHILDREN_SHOWN + 1); // +1 for "and N more" placeholder
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a node's children to avatar dots (for wide levels that can't expand).
|
||||
*/
|
||||
function collapseToAvatars(node: OrgNode): OrgNode {
|
||||
const childCount = countNodes(node.reports ?? []);
|
||||
if (childCount === 0) return node;
|
||||
return {
|
||||
...node,
|
||||
role: `×${childCount} reports`,
|
||||
collapsedReports: flattenDescendants(node.reports ?? []),
|
||||
reports: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a node's children: keep first MAX_CHILDREN_SHOWN, replace rest with
|
||||
* a summary "and N more" placeholder node (rendered as a count card).
|
||||
*/
|
||||
function truncateChildren(node: OrgNode): OrgNode {
|
||||
const children = node.reports ?? [];
|
||||
if (children.length <= MAX_CHILDREN_SHOWN) return node;
|
||||
const kept = children.slice(0, MAX_CHILDREN_SHOWN);
|
||||
const hiddenCount = children.length - MAX_CHILDREN_SHOWN;
|
||||
const placeholder: OrgNode = {
|
||||
id: `${node.id}-more`,
|
||||
name: `+${hiddenCount} more`,
|
||||
role: "overflow",
|
||||
status: "active",
|
||||
reports: [],
|
||||
};
|
||||
return { ...node, reports: [...kept, placeholder] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive collapse: expands levels as long as they fit, truncates or collapses
|
||||
* when a level is too wide.
|
||||
*/
|
||||
function smartCollapseTree(roots: OrgNode[]): OrgNode[] {
|
||||
// Deep clone so we can mutate
|
||||
const clone = (nodes: OrgNode[]): OrgNode[] =>
|
||||
nodes.map((n) => ({ ...n, reports: clone(n.reports ?? []) }));
|
||||
const tree = clone(roots);
|
||||
|
||||
// Walk levels from root down
|
||||
for (let depth = 0; depth < 10; depth++) {
|
||||
const parents = nodesAtDepth(tree, depth);
|
||||
const parentsWithChildren = parents.filter((p) => (p.reports ?? []).length > 0);
|
||||
if (parentsWithChildren.length === 0) break;
|
||||
|
||||
const nextWidth = estimateNextLevelWidth(parentsWithChildren);
|
||||
if (nextWidth <= MAX_LEVEL_WIDTH) {
|
||||
// Next level fits with truncation — truncate oversized parents, then continue deeper
|
||||
for (const p of parentsWithChildren) {
|
||||
if ((p.reports ?? []).length > MAX_CHILDREN_SHOWN) {
|
||||
const truncated = truncateChildren(p);
|
||||
p.reports = truncated.reports;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Next level is too wide — collapse all children at this level to avatars
|
||||
for (const p of parentsWithChildren) {
|
||||
const collapsed = collapseToAvatars(p);
|
||||
p.role = collapsed.role;
|
||||
p.collapsedReports = collapsed.collapsedReports;
|
||||
p.reports = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): string {
|
||||
const theme = THEMES[style] || THEMES.warmth;
|
||||
|
||||
// Auto-collapse large orgs to keep the chart readable
|
||||
const totalNodes = countNodes(orgTree);
|
||||
const effectiveTree = totalNodes > COLLAPSE_THRESHOLD ? smartCollapseTree(orgTree) : orgTree;
|
||||
|
||||
let root: OrgNode;
|
||||
if (orgTree.length === 1) {
|
||||
root = orgTree[0];
|
||||
if (effectiveTree.length === 1) {
|
||||
root = effectiveTree[0];
|
||||
} else {
|
||||
root = {
|
||||
id: "virtual-root",
|
||||
name: "Organization",
|
||||
role: "Root",
|
||||
status: "active",
|
||||
reports: orgTree,
|
||||
reports: effectiveTree,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -529,6 +737,14 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
|||
const logoX = TARGET_W - 110 - LOGO_PADDING;
|
||||
const logoY = LOGO_PADDING;
|
||||
|
||||
// Optional overlay elements
|
||||
const overlayNameSvg = overlay?.companyName
|
||||
? `<text x="${LOGO_PADDING}" y="${LOGO_PADDING + 16}" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="22" font-weight="700" fill="${theme.nameColor}">${svgEscape(overlay.companyName)}</text>`
|
||||
: "";
|
||||
const overlayStatsSvg = overlay?.stats
|
||||
? `<text x="${TARGET_W - LOGO_PADDING}" y="${TARGET_H - LOGO_PADDING}" text-anchor="end" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="500" fill="${theme.roleColor}">${svgEscape(overlay.stats)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TARGET_W}" height="${TARGET_H}" viewBox="0 0 ${TARGET_W} ${TARGET_H}">
|
||||
<defs>${theme.defs(TARGET_W, TARGET_H)}</defs>
|
||||
<rect width="100%" height="100%" fill="${theme.bgColor}" rx="6"/>
|
||||
|
|
@ -536,6 +752,8 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
|||
<g transform="translate(${logoX}, ${logoY})" color="${theme.watermarkColor}">
|
||||
${PAPERCLIP_LOGO_SVG}
|
||||
</g>
|
||||
${overlayNameSvg}
|
||||
${overlayStatsSvg}
|
||||
<g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
|
||||
${renderConnectors(layout, theme)}
|
||||
${renderCards(layout, theme)}
|
||||
|
|
@ -543,8 +761,12 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
|||
</svg>`;
|
||||
}
|
||||
|
||||
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise<Buffer> {
|
||||
const svg = renderOrgChartSvg(orgTree, style);
|
||||
function svgEscape(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): Promise<Buffer> {
|
||||
const svg = renderOrgChartSvg(orgTree, style, overlay);
|
||||
const sharpModule = await import("sharp");
|
||||
const sharp = sharpModule.default;
|
||||
// Render at 2x density for retina quality, resize to exact target dimensions
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,8 @@ type RuntimeSkillEntryOptions = {
|
|||
materializeMissing?: boolean;
|
||||
};
|
||||
|
||||
const skillInventoryRefreshPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
||||
"skills",
|
||||
"skills/.curated",
|
||||
|
|
@ -188,6 +190,18 @@ function normalizeSkillKey(value: string | null | undefined) {
|
|||
return segments.length > 0 ? segments.join("/") : null;
|
||||
}
|
||||
|
||||
export function normalizeGitHubSkillDirectory(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const normalized = normalizePortablePath(value ?? "");
|
||||
if (!normalized) return normalizePortablePath(fallback);
|
||||
if (path.posix.basename(normalized).toLowerCase() === "skill.md") {
|
||||
return normalizePortablePath(path.posix.dirname(normalized));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hashSkillValue(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
||||
}
|
||||
|
|
@ -1017,7 +1031,10 @@ async function readUrlSkillImports(
|
|||
repo: parsed.repo,
|
||||
ref: ref,
|
||||
trackingRef,
|
||||
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||
repoSkillDir: normalizeGitHubSkillDirectory(
|
||||
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||
slug,
|
||||
),
|
||||
};
|
||||
const inventory = filteredPaths
|
||||
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||
|
|
@ -1474,8 +1491,25 @@ export function companySkillService(db: Db) {
|
|||
}
|
||||
|
||||
async function ensureSkillInventoryCurrent(companyId: string) {
|
||||
await ensureBundledSkills(companyId);
|
||||
await pruneMissingLocalPathSkills(companyId);
|
||||
const existingRefresh = skillInventoryRefreshPromises.get(companyId);
|
||||
if (existingRefresh) {
|
||||
await existingRefresh;
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
await ensureBundledSkills(companyId);
|
||||
await pruneMissingLocalPathSkills(companyId);
|
||||
})();
|
||||
|
||||
skillInventoryRefreshPromises.set(companyId, refreshPromise);
|
||||
try {
|
||||
await refreshPromise;
|
||||
} finally {
|
||||
if (skillInventoryRefreshPromises.get(companyId) === refreshPromise) {
|
||||
skillInventoryRefreshPromises.delete(companyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function list(companyId: string): Promise<CompanySkillListItem[]> {
|
||||
|
|
@ -1646,7 +1680,7 @@ export function companySkillService(db: Db) {
|
|||
const owner = asString(metadata.owner);
|
||||
const repo = asString(metadata.repo);
|
||||
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
|
||||
const repoSkillDir = normalizePortablePath(asString(metadata.repoSkillDir) ?? skill.slug);
|
||||
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
|
||||
if (!owner || !repo) {
|
||||
throw unprocessable("Skill source metadata is incomplete.");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue