PAP-10287 Reparent catalog tasks and show installed status

This commit is contained in:
Dotta committed 2026-06-05 03:20:50 +00:00
1 parent 372caafae2
commit 159d6699ef
13 files changed
+598 -33

No files matched your search

+110
View File
@@ -56,6 +56,19 @@ function catalogTeam(overrides: Record<string, unknown> = {}) {
};
}
function installedCatalogTeam(overrides: Record<string, unknown> = {}) {
return {
catalogId: "paperclipai:bundled:software-development:product-engineering",
catalogKey: "paperclipai/bundled/software-development/product-engineering",
present: true,
currentContentHash: "sha256:catalog-team",
installedOriginHashes: ["sha256:catalog-team"],
agentCount: 3,
outOfDate: false,
...overrides,
};
}
describe("teams CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
@@ -130,6 +143,103 @@ describe("teams CLI commands", () => {
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("lists catalog teams with installed status for a company", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([
catalogTeam(),
catalogTeam({
id: "paperclipai:optional:content:content-machine",
key: "paperclipai/optional/content/content-machine",
kind: "optional",
category: "content",
slug: "content-machine",
name: "Content Machine",
contentHash: "sha256:content-current",
}),
]))
.mockResolvedValueOnce(jsonResponse([
installedCatalogTeam({
currentContentHash: "sha256:catalog-team",
installedOriginHashes: ["sha256:older"],
outOfDate: true,
}),
]));
await runCommand([
"teams",
"list",
"--kind",
"bundled",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/teams/catalog?kind=bundled",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/teams/catalog/installed",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(rendered).toContain("installedStatus");
expect(rendered).toContain("out_of_date");
expect(rendered).toContain("not_installed");
});
it("lists installed status as JSON including removed catalog teams", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([catalogTeam()]))
.mockResolvedValueOnce(jsonResponse([
installedCatalogTeam(),
installedCatalogTeam({
catalogId: "paperclipai:removed:team",
catalogKey: "paperclipai/removed/team",
present: false,
currentContentHash: null,
installedOriginHashes: ["sha256:removed"],
agentCount: 2,
}),
]));
await runCommand([
"teams",
"list",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
const rows = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(rows).toMatchObject([
{
catalogId: "paperclipai:bundled:software-development:product-engineering",
catalogKey: "paperclipai/bundled/software-development/product-engineering",
installedStatus: "installed",
installedAgentCount: 3,
outOfDate: false,
},
{
catalogId: "paperclipai:removed:team",
catalogKey: "paperclipai/removed/team",
installedStatus: "installed_missing",
installedAgentCount: 2,
present: false,
},
]);
});
it("inspects catalog team detail by query ref so keys with slashes work", async () => {
const detail = catalogTeam();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
+142
View File
@@ -6,6 +6,7 @@ import type {
CatalogTeamInstallOptions,
CatalogTeamImportOptions,
CatalogTeamSourcePolicy,
InstalledCatalogTeam,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
@@ -23,6 +24,8 @@ interface TeamBrowseOptions extends BaseClientOptions {
query?: string;
}
interface TeamListOptions extends TeamBrowseOptions {}
interface TeamPreviewOptions extends BaseClientOptions {
companyId?: string;
targetManagerAgentId?: string;
@@ -63,6 +66,29 @@ export function registerTeamCommands(program: Command): void {
}),
);
addCommonClientOptions(
teams
.command("list")
.description("List app-shipped catalog teams with installed status for a company")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.option("--query <text>", "Search catalog text")
.action(async (opts: TeamListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = await listCatalogTeamStatusRows(ctx, opts);
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamStatusRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
teams
.command("search")
@@ -198,6 +224,58 @@ async function listCatalogTeams(
return (await ctx.api.get<CatalogTeam[]>(`/api/teams/catalog${query ? `?${query}` : ""}`)) ?? [];
}
type CatalogTeamInstalledStatus = "not_installed" | "installed" | "out_of_date" | "installed_missing";
interface CatalogTeamStatusRow {
catalogId: string;
catalogKey: string | null;
kind: CatalogTeam["kind"] | null;
category: string | null;
slug: string | null;
name: string;
installedStatus: CatalogTeamInstalledStatus;
installedAgentCount: number;
catalogAgentCount: number | null;
projectCount: number | null;
trustLevel: CatalogTeam["trustLevel"] | null;
present: boolean;
outOfDate: boolean;
currentContentHash: string | null;
installedOriginHashes: string[];
}
async function listCatalogTeamStatusRows(
ctx: ResolvedClientContext,
opts: TeamListOptions,
): Promise<CatalogTeamStatusRow[]> {
if (!ctx.companyId) {
throw new Error("Company ID is required.");
}
const [teams, installed] = await Promise.all([
listCatalogTeams(ctx, opts),
ctx.api.get<InstalledCatalogTeam[]>(
`/api/companies/${encodeURIComponent(ctx.companyId)}/teams/catalog/installed`,
),
]);
const installedByCatalogId = new Map((installed ?? []).map((row) => [row.catalogId, row]));
const rows = teams.map((team) => {
const installedTeam = installedByCatalogId.get(team.id);
if (installedTeam) {
installedByCatalogId.delete(team.id);
}
return buildCatalogTeamStatusRow(team, installedTeam ?? null);
});
for (const installedTeam of installedByCatalogId.values()) {
if (installedTeam.present) continue;
rows.push(buildMissingInstalledTeamStatusRow(installedTeam));
}
return rows;
}
async function getCatalogTeam(ctx: ResolvedClientContext, catalogRef: string): Promise<CatalogTeam> {
const ref = catalogRef.trim();
if (!ref) {
@@ -310,6 +388,70 @@ function printCatalogTeamRows(rows: CatalogTeam[]): void {
})));
}
function buildCatalogTeamStatusRow(
team: CatalogTeam,
installed: InstalledCatalogTeam | null,
): CatalogTeamStatusRow {
return {
catalogId: team.id,
catalogKey: team.key,
kind: team.kind,
category: team.category,
slug: team.slug,
name: team.name,
installedStatus: installed ? (installed.outOfDate ? "out_of_date" : "installed") : "not_installed",
installedAgentCount: installed?.agentCount ?? 0,
catalogAgentCount: team.counts.agents,
projectCount: team.counts.projects,
trustLevel: team.trustLevel,
present: true,
outOfDate: installed?.outOfDate ?? false,
currentContentHash: team.contentHash,
installedOriginHashes: installed?.installedOriginHashes ?? [],
};
}
function buildMissingInstalledTeamStatusRow(installed: InstalledCatalogTeam): CatalogTeamStatusRow {
const name = installed.catalogKey ?? installed.catalogId;
return {
catalogId: installed.catalogId,
catalogKey: installed.catalogKey,
kind: null,
category: null,
slug: null,
name,
installedStatus: "installed_missing",
installedAgentCount: installed.agentCount,
catalogAgentCount: null,
projectCount: null,
trustLevel: null,
present: false,
outOfDate: false,
currentContentHash: installed.currentContentHash,
installedOriginHashes: installed.installedOriginHashes,
};
}
function printCatalogTeamStatusRows(rows: CatalogTeamStatusRow[]): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
printTable(rows.map((row) => ({
id: row.catalogId,
key: row.catalogKey,
kind: row.kind,
category: row.category,
slug: row.slug,
name: row.name,
installedStatus: row.installedStatus,
installedAgents: row.installedAgentCount,
catalogAgents: row.catalogAgentCount,
projects: row.projectCount,
trust: row.trustLevel,
})));
}
function printCatalogTeamDetail(team: CatalogTeam): void {
console.log(
formatInlineRecord({
@@ -138,6 +138,8 @@ export interface CompanyPortabilityAgentManifestEntry {
icon: string | null;
capabilities: string | null;
reportsToSlug: string | null;
reportsToExistingAgentId: string | null;
reportsToExistingAgentSlug: string | null;
adapterType: string;
adapterConfig: Record<string, unknown>;
runtimeConfig: Record<string, unknown>;
@@ -8,7 +8,7 @@ key: paperclipai/optional/content/content-machine
manager: agents/content-lead/AGENTS.md
includes:
- skills/content-calendar/SKILL.md
- tasks/weekly-content-review/TASK.md
- projects/content-operations/PROJECT.md
defaultInstall: false
recommendedForCompanyTypes:
- agency
@@ -22,3 +22,9 @@ tags:
# Content Machine
This optional fixture proves local skill resolution and recurring task inventory without introducing external source risk.
## Contents
- `ContentLead` — content operations lead responsible for calendar planning and publication workflow triage.
- `content-operations` project — rolling backlog for editorial planning and content production review.
- `weekly-content-review` routine — recurring content lead check-in to choose next posts and surface blocked publication work.
@@ -0,0 +1,8 @@
---
name: Content Operations
slug: content-operations
description: Rolling content backlog for editorial planning, publication review, and campaign coordination.
owner: content-lead
---
Use this project for content calendar planning, recurring publication reviews, and follow-up tasks that unblock drafting, editing, or publishing work.
@@ -2,6 +2,7 @@
name: Weekly Content Review
slug: weekly-content-review
assignee: content-lead
project: content-operations
recurring: true
---
+20 -12
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/teams-catalog",
"packageVersion": "0.1.0",
"generatedAt": "2026-06-03T17:31:32.494Z",
"generatedAt": "2026-06-03T19:51:52.952Z",
"teams": [
{
"id": "paperclipai:bundled:company-defaults:core-exec-team",
@@ -392,7 +392,7 @@
],
"counts": {
"agents": 1,
"projects": 0,
"projects": 1,
"tasks": 0,
"routines": 1,
"localSkills": 1,
@@ -405,7 +405,9 @@
"agentSlugs": [
"content-lead"
],
"projectSlugs": [],
"projectSlugs": [
"content-operations"
],
"requiredSkills": [
{
"type": "local",
@@ -423,8 +425,8 @@
{
"path": "TEAM.md",
"kind": "team",
"sizeBytes": 670,
"sha256": "07a02f4a7503db77867a664bc0d30000bef19790f46b494a0ca94162cd378019"
"sizeBytes": 1032,
"sha256": "f7c272c8b0c183fc840d49eb4851b7cdd1a30a5aa8d6415d215110f3238f6f13"
},
{
"path": "agents/content-lead/AGENTS.md",
@@ -432,22 +434,28 @@
"sizeBytes": 251,
"sha256": "6fc2071ff463b6981780d5b40b2c0784cb119900062f8dc40ed372e7b8415bf7"
},
{
"path": "projects/content-operations/PROJECT.md",
"kind": "project",
"sizeBytes": 338,
"sha256": "1471f61cb0b3ffbb9c639a99d2d370f08217a0c63f97b3e24dbbd47861110206"
},
{
"path": "projects/content-operations/tasks/weekly-content-review/TASK.md",
"kind": "task",
"sizeBytes": 236,
"sha256": "3777de716334f2c3f24176f0fe121584ce9c53a7f59e1fca942a2f57995cef77"
},
{
"path": "skills/content-calendar/SKILL.md",
"kind": "skill",
"sizeBytes": 340,
"sha256": "aa62a332fc3149d6c0d58ca589c6b3910a373d542f16f749ec68ef2f3d7f29e9"
},
{
"path": "tasks/weekly-content-review/TASK.md",
"kind": "task",
"sizeBytes": 208,
"sha256": "b42faffd7ce3267bae5d04472bcba5a880c578a538ecc0339f8df90f352650d5"
}
],
"trustLevel": "markdown_only",
"compatibility": "compatible",
"contentHash": "sha256:0127b5a93fc1efe8265a2720e4d18f34b9c9a7ba6dd54b2032c3eb2722af2e17"
"contentHash": "sha256:06822cec5f2b6910cb37ba0ebd5e04ed5c7e34f77225564ddddc2470779cdaa6"
}
]
}
@@ -1,5 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { catalogManifest, catalogTeams, resolveCatalogTeamRef } from "./index.js";
import { asBoolean, asString, parseFrontmatterMarkdown } from "./frontmatter.js";
import type { CatalogTeam } from "./types.js";
const EXPECTED_BUNDLED_KEYS = [
@@ -12,6 +16,8 @@ const EXPECTED_OPTIONAL_KEYS = [
"paperclipai/optional/content/content-machine",
];
const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
describe("shipped teams catalog", () => {
it("ships the expected bundled and optional team fixtures", () => {
const bundledKeys = catalogTeams
@@ -82,6 +88,29 @@ describe("shipped teams catalog", () => {
expect(resolveCatalogTeamRef(sample.key)).toMatchObject({ key: sample.key });
expect(resolveCatalogTeamRef(sample.slug)).toMatchObject({ key: sample.key });
});
it("declares a valid project for every shipped recurring task", () => {
const issues: string[] = [];
for (const team of catalogTeams) {
for (const file of team.files.filter((entry) => entry.kind === "task")) {
const absolutePath = path.join(PACKAGE_DIR, team.path, file.path);
const parsed = parseFrontmatterMarkdown(fs.readFileSync(absolutePath, "utf8"));
if (!asBoolean(parsed.frontmatter.recurring)) continue;
const project = asString(parsed.frontmatter.project);
if (!project) {
issues.push(`${team.key}/${file.path} recurring task must declare a project`);
continue;
}
if (!team.projectSlugs.includes(project)) {
issues.push(`${team.key}/${file.path} project=${project} must match a team project`);
}
}
}
expect(issues).toEqual([]);
});
});
function formatViolations(label: string, teams: CatalogTeam[]) {
@@ -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 {
+18 -7
View File
@@ -2182,6 +2182,17 @@ export function TeamCatalog() {
const categoryFilter = searchParams.get("category") ?? "";
const riskFilter = (searchParams.get("risk") as RiskFilter) ?? "any";
// Preserve the active filter query (search/kind/category/risk) across in-page
// navigation. Without this, auto-select and team-row / file-tree clicks rebuild
// a fresh `/teams/<key>` path from `teamRoute()` and drop the query string, so a
// landing `?search=…` unfiltered the list and emptied the search box (PAP-10257
// follow-up). `applyCompanyPrefix` already preserves query strings.
const filterQuery = searchParams.toString();
const withFilters = useCallback(
(path: string) => (filterQuery ? `${path}?${filterQuery}` : path),
[filterQuery],
);
const [installOpen, setInstallOpen] = useState(false);
const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN}px)`);
@@ -2225,9 +2236,9 @@ export function TeamCatalog() {
// (design §11).
useEffect(() => {
if (isDesktop && !selectedRef && filtered[0]) {
navigate(teamRoute(filtered[0].id), { replace: true });
navigate(withFilters(teamRoute(filtered[0].id)), { replace: true });
}
}, [isDesktop, selectedRef, filtered, navigate]);
}, [isDesktop, selectedRef, filtered, navigate, withFilters]);
const fileQuery = useQuery({
queryKey: queryKeys.teamCatalog.catalogFile(selectedTeam?.id ?? "", selectedFilePath ?? ""),
@@ -2423,7 +2434,7 @@ export function TeamCatalog() {
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(teamRoute(team.id))}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
/>
))}
</>
@@ -2438,7 +2449,7 @@ export function TeamCatalog() {
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(teamRoute(team.id))}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
/>
))}
</>
@@ -2453,7 +2464,7 @@ export function TeamCatalog() {
key={team.id}
team={team}
selected={team.id === selectedTeam?.id}
onSelect={() => navigate(teamRoute(team.id))}
onSelect={() => navigate(withFilters(teamRoute(team.id)))}
installed={installedById.get(team.id) ?? null}
/>
))}
@@ -2474,7 +2485,7 @@ export function TeamCatalog() {
{!isDesktop && selectedTeam && (
<button
type="button"
onClick={() => navigate("/teams")}
onClick={() => navigate(withFilters("/teams"))}
className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" /> Back to catalog
@@ -2485,7 +2496,7 @@ export function TeamCatalog() {
team={selectedTeam}
selectedPath={selectedFilePath}
onSelectFile={(path) =>
navigate(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id))
navigate(withFilters(path ? teamRoute(selectedTeam.id, path) : teamRoute(selectedTeam.id)))
}
onInstall={() => setInstallOpen(true)}
canInstall={canInstall}