Merge pull request #1655 from paperclipai/pr/pap-795-company-portability

feat(portability): improve company import and export flow
This commit is contained in:
Dotta 2026-03-23 19:45:05 -05:00 committed by GitHub
commit eeb7e1a91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5238 additions and 271 deletions

106
ui/src/lib/agent-order.ts Normal file
View file

@ -0,0 +1,106 @@
import type { Agent } from "@paperclipai/shared";
export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated";
const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder";
const ANONYMOUS_USER_ID = "anonymous";
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function normalizeIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function resolveUserId(userId: string | null | undefined): string {
if (!userId) return ANONYMOUS_USER_ID;
const trimmed = userId.trim();
return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID;
}
export function getAgentOrderStorageKey(companyId: string, userId: string | null | undefined): string {
return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
}
export function readAgentOrder(storageKey: string): string[] {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
return normalizeIdList(JSON.parse(raw));
} catch {
return [];
}
}
export function writeAgentOrder(storageKey: string, orderedIds: string[]) {
const normalized = normalizeIdList(orderedIds);
try {
localStorage.setItem(storageKey, JSON.stringify(normalized));
} catch {
// Ignore storage write failures in restricted browser contexts.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<AgentOrderUpdatedDetail>(AGENT_ORDER_UPDATED_EVENT, {
detail: { storageKey, orderedIds: normalized },
}),
);
}
}
export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] {
if (agents.length === 0) return [];
const byId = new Map(agents.map((agent) => [agent.id, agent]));
const childrenOf = new Map<string | null, Agent[]>();
for (const agent of agents) {
const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null;
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(agent);
childrenOf.set(parentId, siblings);
}
for (const siblings of childrenOf.values()) {
siblings.sort((left, right) => left.name.localeCompare(right.name));
}
const sorted: Agent[] = [];
const queue = [...(childrenOf.get(null) ?? [])];
while (queue.length > 0) {
const agent = queue.shift();
if (!agent) continue;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function sortAgentsByStoredOrder(agents: Agent[], orderedIds: string[]): Agent[] {
if (agents.length === 0) return [];
const defaultSorted = sortAgentsByDefaultSidebarOrder(agents);
if (orderedIds.length === 0) return defaultSorted;
const byId = new Map(defaultSorted.map((agent) => [agent.id, agent]));
const sorted: Agent[] = [];
for (const id of orderedIds) {
const agent = byId.get(id);
if (!agent) continue;
sorted.push(agent);
byId.delete(id);
}
for (const agent of defaultSorted) {
if (byId.has(agent.id)) {
sorted.push(agent);
byId.delete(agent.id);
}
}
return sorted;
}

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildInitialExportCheckedFiles } from "./company-export-selection";
describe("buildInitialExportCheckedFiles", () => {
it("checks non-task files and recurring task packages by default", () => {
const checked = buildInitialExportCheckedFiles(
[
"README.md",
".paperclip.yaml",
"tasks/one-off/TASK.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
],
[
{ path: "tasks/one-off/TASK.md", recurring: false },
{ path: "tasks/recurring/TASK.md", recurring: true },
],
new Set<string>(),
);
expect(Array.from(checked).sort()).toEqual([
".paperclip.yaml",
"README.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
]);
});
it("preserves previous manual selections for one-time tasks", () => {
const checked = buildInitialExportCheckedFiles(
["README.md", "tasks/one-off/TASK.md"],
[{ path: "tasks/one-off/TASK.md", recurring: false }],
new Set(["tasks/one-off/TASK.md"]),
);
expect(Array.from(checked).sort()).toEqual([
"README.md",
"tasks/one-off/TASK.md",
]);
});
});

View file

@ -0,0 +1,56 @@
import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared";
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
function buildRecurringTaskPrefixes(
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
): Set<string> {
const prefixes = new Set<string>();
for (const issue of issues) {
if (!issue.recurring) continue;
const filePath = issue.path.trim();
if (!filePath) continue;
prefixes.add(filePath);
const lastSlash = filePath.lastIndexOf("/");
if (lastSlash >= 0) {
prefixes.add(`${filePath.slice(0, lastSlash + 1)}`);
}
}
return prefixes;
}
function isRecurringTaskFile(filePath: string, recurringTaskPrefixes: Set<string>): boolean {
for (const prefix of recurringTaskPrefixes) {
if (filePath === prefix || filePath.startsWith(prefix)) return true;
}
return false;
}
export function buildInitialExportCheckedFiles(
filePaths: string[],
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
previousCheckedFiles: Set<string>,
): Set<string> {
const next = new Set<string>();
const recurringTaskPrefixes = buildRecurringTaskPrefixes(issues);
for (const filePath of filePaths) {
if (previousCheckedFiles.has(filePath)) {
next.add(filePath);
continue;
}
if (!isTaskPath(filePath) || isRecurringTaskFile(filePath, recurringTaskPrefixes)) {
next.add(filePath);
}
}
return next;
}

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { Agent, Project } from "@paperclipai/shared";
import {
buildPortableAgentSlugMap,
buildPortableProjectSlugMap,
buildPortableSidebarOrder,
} from "./company-portability-sidebar";
function makeAgent(id: string, name: string): Agent {
return {
id,
companyId: "company-1",
name,
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
urlKey: name.toLowerCase(),
};
}
function makeProject(id: string, name: string): Project {
return {
id,
companyId: "company-1",
goalId: null,
urlKey: name.toLowerCase(),
name,
description: null,
status: "planned",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
goalIds: [],
goals: [],
primaryWorkspace: null,
workspaces: [],
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/managed",
effectiveLocalFolder: "/tmp/managed",
origin: "managed_checkout",
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe("company portability sidebar order", () => {
it("uses the same unique slug allocation as export and preserves the requested order", () => {
const alphaOne = makeAgent("agent-1", "Alpha");
const alphaTwo = makeAgent("agent-2", "Alpha");
const beta = makeAgent("agent-3", "Beta");
const launch = makeProject("project-1", "Launch");
const launchTwo = makeProject("project-2", "Launch");
expect(Array.from(buildPortableAgentSlugMap([alphaOne, alphaTwo, beta]).entries())).toEqual([
["agent-1", "alpha"],
["agent-2", "alpha-2"],
["agent-3", "beta"],
]);
expect(Array.from(buildPortableProjectSlugMap([launch, launchTwo]).entries())).toEqual([
["project-1", "launch"],
["project-2", "launch-2"],
]);
expect(buildPortableSidebarOrder({
agents: [alphaOne, alphaTwo, beta],
orderedAgents: [beta, alphaTwo, alphaOne],
projects: [launch, launchTwo],
orderedProjects: [launchTwo, launch],
})).toEqual({
agents: ["beta", "alpha-2", "alpha"],
projects: ["launch-2", "launch"],
});
});
});

View file

@ -0,0 +1,61 @@
import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared";
import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared";
function uniqueSlug(base: string, used: Set<string>) {
if (!used.has(base)) {
used.add(base);
return base;
}
let index = 2;
while (true) {
const candidate = `${base}-${index}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
index += 1;
}
}
export function buildPortableAgentSlugMap(agents: Agent[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedAgents = [...agents].sort((left, right) => left.name.localeCompare(right.name));
for (const agent of sortedAgents) {
const baseSlug = normalizeAgentUrlKey(agent.name) ?? "agent";
byId.set(agent.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableProjectSlugMap(projects: Project[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
for (const project of sortedProjects) {
const baseSlug = deriveProjectUrlKey(project.name, project.name);
byId.set(project.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableSidebarOrder(input: {
agents: Agent[];
orderedAgents: Agent[];
projects: Project[];
orderedProjects: Project[];
}): CompanyPortabilitySidebarOrder | undefined {
const agentSlugById = buildPortableAgentSlugMap(input.agents);
const projectSlugById = buildPortableProjectSlugMap(input.projects);
const sidebar = {
agents: input.orderedAgents.map((agent) => agentSlugById.get(agent.id)).filter((slug): slug is string => Boolean(slug)),
projects: input.orderedProjects.map((project) => projectSlugById.get(project.id)).filter((slug): slug is string => Boolean(slug)),
};
return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : undefined;
}