mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
PAP-10287 Reparent catalog tasks and show installed status
This commit is contained in:
1 parent
372caafae2
commit
159d6699ef
13 files changed
+598
-33
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
+8
@@ -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.
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
name: Weekly Content Review
|
||||
slug: weekly-content-review
|
||||
assignee: content-lead
|
||||
project: content-operations
|
||||
recurring: true
|
||||
---
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user