PAP-10287 extract final teams catalog fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:16 +00:00
1 parent db2b42adfe
commit 25afcdebf8
13 files changed
+598 -33

No files matched your search

@@ -0,0 +1,159 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { teamsCatalogService } from "../services/teams-catalog.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres teams catalog install tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("teams catalog target-manager reparent", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let tempHome: string | null = null;
let oldPaperclipHome: string | undefined;
beforeAll(async () => {
oldPaperclipHome = process.env.PAPERCLIP_HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-teams-catalog-reparent-"));
process.env.PAPERCLIP_HOME = tempHome;
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-teams-catalog-reparent-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = oldPaperclipHome;
if (tempHome) await fs.rm(tempHome, { recursive: true, force: true });
await tempDb?.cleanup();
});
async function seedCompanyWithExistingExecTeam() {
const companyId = randomUUID();
const existingCeoId = randomUUID();
const existingCtoId = randomUUID();
const existingQaId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Existing company",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: existingCeoId,
companyId,
name: "CEO",
role: "ceo",
title: "Chief Executive Officer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: existingCtoId,
companyId,
name: "CTO",
role: "engineering-manager",
title: "Chief Technology Officer",
status: "idle",
reportsTo: existingCeoId,
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: existingQaId,
companyId,
name: "QA",
role: "qa",
title: "QA Engineer",
status: "idle",
reportsTo: existingCtoId,
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, existingCeoId };
}
async function listCompanyAgents(companyId: string) {
const rows = await db
.select({
id: agents.id,
name: agents.name,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(eq(agents.companyId, companyId));
return new Map(rows.map((agent) => [agent.name, agent]));
}
const adapterOverrides = {
ceo: { adapterType: "claude_local" },
cto: { adapterType: "claude_local" },
qa: { adapterType: "claude_local" },
};
it("reparents the renamed imported root to targetManagerSlug while preserving renamed imported hierarchy", async () => {
const { companyId, existingCeoId } = await seedCompanyWithExistingExecTeam();
const svc = teamsCatalogService(db);
await svc.installCatalogTeam(companyId, "core-exec-team", {
targetManagerSlug: "ceo",
collisionStrategy: "rename",
include: { projects: false, issues: false },
adapterOverrides,
});
const byName = await listCompanyAgents(companyId);
const importedCeo = byName.get("CEO 2");
const importedCto = byName.get("CTO 2");
const importedQa = byName.get("QA 2");
expect(importedCeo?.reportsTo).toBe(existingCeoId);
expect(importedCto?.reportsTo).toBe(importedCeo?.id);
expect(importedQa?.reportsTo).toBe(importedCto?.id);
});
it("reparents the renamed imported root to targetManagerAgentId while preserving renamed imported hierarchy", async () => {
const { companyId, existingCeoId } = await seedCompanyWithExistingExecTeam();
const svc = teamsCatalogService(db);
await svc.installCatalogTeam(companyId, "core-exec-team", {
targetManagerAgentId: existingCeoId,
collisionStrategy: "rename",
include: { projects: false, issues: false },
adapterOverrides,
});
const byName = await listCompanyAgents(companyId);
const importedCeo = byName.get("CEO 2");
const importedCto = byName.get("CTO 2");
const importedQa = byName.get("QA 2");
expect(importedCeo?.reportsTo).toBe(existingCeoId);
expect(importedCto?.reportsTo).toBe(importedCeo?.id);
expect(importedQa?.reportsTo).toBe(importedCto?.id);
});
});
@@ -109,7 +109,34 @@ describe("teamsCatalogService", () => {
expect(prepared.source.files["COMPANY.md"]).toEqual(expect.stringContaining("Core Exec Team"));
expect(prepared.source.files["agents/ceo/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/paperclip-operations/task-planning"));
expect(prepared.source.files["agents/cto/AGENTS.md"]).toEqual(expect.stringContaining("paperclipai/bundled/software-development/github-pr-workflow"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsTo: \"engineering-manager\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
});
it("resolves target-manager slug against same-company agents before rendering reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([
{ id: "manager-1", companyId: "company-1", name: "CEO" },
]);
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", {
targetManagerSlug: "ceo",
});
expect(mockAgentService.list).toHaveBeenCalledWith("company-1");
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"ceo\""));
});
it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([]);
const svc = teamsCatalogService({} as any);
await expect(
svc.prepareCatalogTeamSource("company-1", "core-exec-team", {
targetManagerSlug: "missing-manager",
}),
).rejects.toMatchObject({ status: 404 });
});
it("previews through company portability in agent-safe mode", async () => {
+49 -4
View File
@@ -1896,6 +1896,8 @@ const YAML_KEY_PRIORITY = [
"kind",
"slug",
"reportsTo",
"reportsToExistingAgentId",
"reportsToExistingAgentSlug",
"skills",
"owner",
"assignee",
@@ -2699,6 +2701,8 @@ function buildManifestFromPackageFiles(
icon: asString(extension.icon),
capabilities: asString(extension.capabilities),
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug),
adapterType: asString(extensionAdapter?.type) ?? "process",
adapterConfig,
runtimeConfig,
@@ -4021,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const agentPlans: CompanyPortabilityPreviewAgentPlan[] = [];
const existingSlugToAgent = new Map<string, { id: string; name: string }>();
const existingAgentIds = new Set<string>();
const existingSlugs = new Set<string>();
const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = [];
const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = [];
@@ -4032,6 +4037,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const existing of existingAgents) {
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
if (!existingSlugToAgent.has(slug)) existingSlugToAgent.set(slug, existing);
existingAgentIds.add(existing.id);
existingSlugs.add(slug);
}
const existingProjects = await projects.list(input.target.companyId);
@@ -4058,6 +4064,22 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
for (const manifestAgent of selectedAgents) {
if (
manifestAgent.reportsToExistingAgentId
&& !existingAgentIds.has(manifestAgent.reportsToExistingAgentId)
) {
warnings.push(
`Agent ${manifestAgent.slug} references existing manager id ${manifestAgent.reportsToExistingAgentId}, but that agent is not present in the target company.`,
);
}
if (
manifestAgent.reportsToExistingAgentSlug
&& !existingSlugToAgent.has(manifestAgent.reportsToExistingAgentSlug)
) {
warnings.push(
`Agent ${manifestAgent.slug} references existing manager slug ${manifestAgent.reportsToExistingAgentSlug}, but that agent is not present in the target company.`,
);
}
const existing = existingSlugToAgent.get(manifestAgent.slug) ?? null;
if (!existing) {
agentPlans.push({
@@ -4424,10 +4446,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const preImportExistingSlugToAgentId = new Map<string, string>();
const preImportExistingAgentIds = new Set<string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
for (const existing of existingAgents) {
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
existingSlugToAgentId.set(slug, existing.id);
preImportExistingSlugToAgentId.set(slug, existing.id);
preImportExistingAgentIds.add(existing.id);
agentStatusById.set(existing.id, existing.status);
}
const importedSlugToProjectId = new Map<string, string>();
@@ -4610,13 +4637,31 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const agentId = importedSlugToAgentId.get(manifestAgent.slug);
if (!agentId) continue;
const managerSlug = manifestAgent.reportsToSlug;
if (!managerSlug) continue;
const managerId = importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null;
let existingManagerId: string | null = null;
if (
manifestAgent.reportsToExistingAgentId
&& preImportExistingAgentIds.has(manifestAgent.reportsToExistingAgentId)
) {
existingManagerId = manifestAgent.reportsToExistingAgentId;
} else if (manifestAgent.reportsToExistingAgentSlug) {
existingManagerId =
preImportExistingSlugToAgentId.get(manifestAgent.reportsToExistingAgentSlug) ?? null;
}
if (!managerSlug && !existingManagerId) continue;
const managerId =
existingManagerId
?? (managerSlug
? importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null
: null);
if (!managerId || managerId === agentId) continue;
try {
await agents.update(agentId, { reportsTo: managerId });
} catch {
warnings.push(`Could not assign manager ${managerSlug} for imported agent ${manifestAgent.slug}.`);
const managerRef =
managerSlug
?? manifestAgent.reportsToExistingAgentSlug
?? manifestAgent.reportsToExistingAgentId;
warnings.push(`Could not assign manager ${managerRef} for imported agent ${manifestAgent.slug}.`);
}
}
}
+25 -8
View File
@@ -93,6 +93,11 @@ export interface CatalogTeamPreparedSource {
errors: string[];
}
interface CatalogTargetManagerReference {
agentId: string;
slug: string;
}
export interface CatalogTeamImportPreviewResult {
team: CatalogTeam;
portabilityPreview: CompanyPortabilityPreviewResult;
@@ -372,7 +377,7 @@ function catalogProvenance(team: CatalogTeam) {
};
}
function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: string | null) {
function renderCatalogProvenanceYaml(team: CatalogTeam, targetManager: CatalogTargetManagerReference | null) {
const provenance = catalogProvenance(team);
const agentSlugs = Array.from(new Set(team.agentSlugs)).sort();
const projectSlugs = Array.from(new Set(team.projectSlugs)).sort();
@@ -383,7 +388,10 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: strin
const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => [
` ${slug}:`,
...(opts?.reparentRoot ? [` reportsTo: ${yamlScalar(targetManagerSlug)}`] : []),
...(opts?.reparentRoot && targetManager ? [
` reportsToExistingAgentId: ${yamlScalar(targetManager.agentId)}`,
` reportsToExistingAgentSlug: ${yamlScalar(targetManager.slug)}`,
] : []),
" metadata:",
" paperclip:",
" catalogTeam:",
@@ -402,7 +410,7 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManagerSlug: strin
"agents:",
...agentSlugs.flatMap((slug) =>
renderEntity(slug, {
reparentRoot: Boolean(targetManagerSlug && team.rootAgentSlugs.includes(slug)),
reparentRoot: Boolean(targetManager && team.rootAgentSlugs.includes(slug)),
}),
),
];
@@ -708,11 +716,17 @@ export function teamsCatalogService(db: Db) {
const companySkills = companySkillService(db);
const agents = agentService(db);
async function resolveTargetManagerSlug(companyId: string, options: CatalogTeamImportOptions) {
async function resolveTargetManagerReference(
companyId: string,
options: CatalogTeamImportOptions,
): Promise<CatalogTargetManagerReference | null> {
if (options.targetManagerSlug) {
const slug = normalizeAgentUrlKey(options.targetManagerSlug);
if (!slug) throw unprocessable("Target manager slug is invalid.");
return slug;
const managers = await agents.list(companyId);
const manager = managers.find((candidate) => normalizeAgentUrlKey(candidate.name) === slug);
if (!manager) throw notFound("Target manager agent not found");
return { agentId: manager.id, slug };
}
if (!options.targetManagerAgentId) return null;
const manager = await agents.getById(options.targetManagerAgentId);
@@ -720,7 +734,10 @@ export function teamsCatalogService(db: Db) {
if (manager.companyId !== companyId) {
throw forbidden("Target manager agent must belong to the target company.");
}
return normalizeAgentUrlKey(manager.name) ?? manager.id;
return {
agentId: manager.id,
slug: normalizeAgentUrlKey(manager.name) ?? manager.id,
};
}
async function prepareCatalogTeamSource(
@@ -746,9 +763,9 @@ export function teamsCatalogService(db: Db) {
warnings.push(...skillPrep.warnings);
errors.push(...skillPrep.errors);
const targetManagerSlug = await resolveTargetManagerSlug(companyId, options);
const targetManager = await resolveTargetManagerReference(companyId, options);
const files = await readCatalogTeamSourceFiles(team);
files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManagerSlug);
files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManager);
rewriteAgentCatalogSkillRefs(team, files);
return {