mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +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
106
ui/src/lib/agent-order.ts
Normal file
106
ui/src/lib/agent-order.ts
Normal 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;
|
||||
}
|
||||
41
ui/src/lib/company-export-selection.test.ts
Normal file
41
ui/src/lib/company-export-selection.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
ui/src/lib/company-export-selection.ts
Normal file
56
ui/src/lib/company-export-selection.ts
Normal 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;
|
||||
}
|
||||
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
100
ui/src/lib/company-portability-sidebar.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
61
ui/src/lib/company-portability-sidebar.ts
Normal file
61
ui/src/lib/company-portability-sidebar.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue