diff --git a/packages/shared/src/types/teams-catalog.ts b/packages/shared/src/types/teams-catalog.ts index a088b07eb0b..632fe75f8e5 100644 --- a/packages/shared/src/types/teams-catalog.ts +++ b/packages/shared/src/types/teams-catalog.ts @@ -182,6 +182,7 @@ export interface CatalogTeamImportPreviewResult { } export interface CatalogTeamInstallResult { + resourceLinks: { kind: "agent" | "project" | "routine"; id: string; name: string; href: string }[]; team: CatalogTeam; portabilityImport: CompanyPortabilityImportResult; skillPreparations: CatalogTeamSkillPreparation[]; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 996bbf3b628..93d9f631308 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1437,3 +1437,12 @@ it("documents explicit owner removals in the timeline response event enum", () = "created", "commented", "approved", "delegated", "assigned", "unassigned", ]); }); + +it("documents canonical saved-resource links in catalog team install responses", () => { + const operation = buildOpenApiSpec().paths["/api/companies/{companyId}/teams/catalog/{catalogId}/install"].post; + expect(operation.description).toContain("resourceLinks"); + const schema = operation.responses[201].content["application/json"].schema; + expect(schema.required).toContain("resourceLinks"); + expect(schema.properties.resourceLinks.items.properties.kind.enum).toEqual(["agent", "project", "routine"]); + expect(schema.properties.resourceLinks.items.required).toEqual(["kind", "id", "name", "href"]); +}); diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index c5e3445bf7f..a8d0a6da648 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -81,13 +81,18 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides const companyId = await seedEmptyCompany(); const svc = teamsCatalogService(db); - await svc.installCatalogTeam(companyId, "core-exec-team", { + const installed = await svc.installCatalogTeam(companyId, "core-exec-team", { collisionStrategy: "rename", include: { projects: false, issues: false }, }); const byName = await listAdapterTypesByName(companyId); expect(byName.size).toBe(3); + const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); + const saved = await db.select({ id: agents.id, name: agents.name }).from(agents).where(eq(agents.companyId, companyId)); + expect([...installed.resourceLinks].sort((a, b) => a.id.localeCompare(b.id))).toEqual(saved.map(agent => ({ kind: "agent", id: agent.id, name: agent.name, + href: `/${company!.issuePrefix}/agents/${agent.id}` })).sort((a, b) => a.id.localeCompare(b.id))); + const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType); expect(adapterTypes).toEqual(["paperclip_runner", "paperclip_runner", "paperclip_runner"]); diff --git a/server/src/__tests__/teams-catalog-service.test.ts b/server/src/__tests__/teams-catalog-service.test.ts index 087271c79ad..8675f630870 100644 --- a/server/src/__tests__/teams-catalog-service.test.ts +++ b/server/src/__tests__/teams-catalog-service.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { readFile } from "node:fs/promises"; import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter"; +import { executeRunnerApi } from "../services/native-runtime/runner-api-client.js"; import type { CatalogTeam } from "@paperclipai/shared"; +const companyPrefix = vi.hoisted(() => vi.fn()); +const testDb = { select: () => ({ from: () => ({ where: () => ({ limit: companyPrefix }) }) }) }; + const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), list: vi.fn(), @@ -64,6 +68,7 @@ function agentWithCatalogTeam(originHash: string | null, extra: Record { beforeEach(() => { vi.clearAllMocks(); + companyPrefix.mockResolvedValue([{ issuePrefix: "ACME" }]); mockAgentService.getById.mockResolvedValue({ id: "manager-1", companyId: "company-1", @@ -102,7 +107,7 @@ describe("teamsCatalogService", () => { }); it("builds an inline portability source with catalog skill keys and target-manager reparenting", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", { targetManagerAgentId: "manager-1", @@ -119,7 +124,7 @@ describe("teamsCatalogService", () => { it.each(["core-exec-team", "product-engineering", "product-design", "content-machine"])( "preserves %s role files and canonical skill references through catalog import", async (teamRef) => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const prepared = await svc.prepareCatalogTeamSource("company-1", teamRef); expect(prepared.errors).toEqual([]); for (const slug of prepared.team.agentSlugs) { @@ -153,7 +158,7 @@ describe("teamsCatalogService", () => { mockAgentService.list.mockResolvedValue([ { id: "manager-1", companyId: "company-1", name: "CEO" }, ]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const prepared = await svc.prepareCatalogTeamSource("company-1", "core-exec-team", { targetManagerSlug: "ceo", @@ -165,7 +170,7 @@ describe("teamsCatalogService", () => { }); it("preserves package-declared Paperclip sidecar permissions while adding generated catalog provenance", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering"); @@ -176,7 +181,7 @@ describe("teamsCatalogService", () => { }); it("preserves package sidecar permissions when generated target-manager metadata is merged onto the same root agent", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering", { targetManagerAgentId: "manager-1", @@ -191,7 +196,7 @@ describe("teamsCatalogService", () => { it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => { mockAgentService.list.mockResolvedValue([]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await expect( svc.prepareCatalogTeamSource("company-1", "core-exec-team", { @@ -201,7 +206,7 @@ describe("teamsCatalogService", () => { }); it("previews through company portability in agent-safe mode", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const preview = await svc.previewCatalogTeamImport("company-1", "content-machine"); @@ -223,7 +228,7 @@ describe("teamsCatalogService", () => { }); it("forces catalog previews to exclude company metadata even when requested", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.previewCatalogTeamImport("company-1", "content-machine", { include: { company: true, agents: false }, @@ -254,7 +259,7 @@ describe("teamsCatalogService", () => { warnings: [], errors: ["Safe import does not allow process adapter type."], }); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toMatchObject({ status: 422 }); @@ -264,7 +269,7 @@ describe("teamsCatalogService", () => { it("does not install catalog skills when bundle import fails", async () => { mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("import failed")); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toThrow("import failed"); @@ -272,9 +277,39 @@ describe("teamsCatalogService", () => { expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); + it("rejects a missing company before importing resources", async () => { + companyPrefix.mockResolvedValueOnce([]); + await expect(teamsCatalogService(testDb as any).installCatalogTeam("company-1", "core-exec-team")).rejects.toMatchObject({ status: 404 }); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); + + it("returns scoped saved-resource links while preserving warnings and skipped-resource semantics", async () => { + mockCompanyPortabilityService.importBundle.mockResolvedValueOnce({ + company: { id: "company-1", name: "Paperclip", action: "unchanged" }, + agents: [{ id: "saved-agent", name: "CTO 2", slug: "cto", action: "created" }, { id: null, name: "Skipped", slug: "skipped", action: "skipped" }], + projects: [{ id: "saved-project", name: "Engineering", slug: "engineering", action: "updated" }], + routines: [{ id: "saved-routine", title: "Weekly sync", slug: "weekly", action: "created" }], + envInputs: [], warnings: ["required skill unavailable"], + }); + const result = await teamsCatalogService(testDb as any).installCatalogTeam("company-1", "core-exec-team"); + expect(result.resourceLinks).toEqual([ + { kind: "agent", id: "saved-agent", name: "CTO 2", href: "/ACME/agents/saved-agent" }, + { kind: "project", id: "saved-project", name: "Engineering", href: "/ACME/projects/saved-project" }, + { kind: "routine", id: "saved-routine", name: "Weekly sync", href: "/ACME/routines/saved-routine" }, + ]); + const wire = await executeRunnerApi({ operationId: "POST /api/companies/{companyId}/teams/catalog/{catalogId}/install", + pathParams: { catalogId: "core-exec-team" }, body: {} }, + { companyId: "company-1", issueId: "issue-1", issueIdentifier: "ACME-1", runId: "run-1", workMode: "standard" }, + { apiUrl: "http://fixture.invalid", token: "private-test-token", fetch: async () => Response.json(result, { status: 201 }), + readFile: async () => { throw new Error("not used"); }, saveResponse: async () => { throw new Error("unexpected artifact"); } }); + expect(wire).toMatchObject({ ok: true, status: 201, data: { resourceLinks: result.resourceLinks, warnings: result.warnings } }); + expect(result.warnings).toContain("required skill unavailable"); + expect(result.portabilityImport.agents[1].id).toBeNull(); + }); + it("surfaces post-import catalog skill install failures as warnings", async () => { mockCompanySkillService.installFromCatalog.mockRejectedValueOnce(new Error("catalog unavailable")); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const result = await svc.installCatalogTeam("company-1", "core-exec-team"); @@ -287,7 +322,7 @@ describe("teamsCatalogService", () => { }); it("injects safe claude_local adapter defaults for every bundled agent when no overrides are supplied", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.installCatalogTeam("company-1", "core-exec-team"); @@ -303,7 +338,7 @@ describe("teamsCatalogService", () => { const previousDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = "opencode_local"; try { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.installCatalogTeam("company-1", "core-exec-team"); @@ -323,7 +358,7 @@ describe("teamsCatalogService", () => { }); it("supplies safe adapter defaults for product-design and product-engineering installs", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.installCatalogTeam("company-1", "product-design"); const [designInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!; @@ -341,7 +376,7 @@ describe("teamsCatalogService", () => { }); it("never sends a forbidden process adapter type from the default catalog path", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.installCatalogTeam("company-1", "core-exec-team"); @@ -353,7 +388,7 @@ describe("teamsCatalogService", () => { }); it("preserves an explicit caller adapter override for the affected slug", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const callerOverrides = { cto: { adapterType: "opencode_local", adapterConfig: { model: "anthropic/claude-opus-4" } }, @@ -375,7 +410,7 @@ describe("teamsCatalogService", () => { }); it("omits the default-adapter warning when every agent has an explicit override", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const result = await svc.installCatalogTeam("company-1", "core-exec-team", { adapterOverrides: { @@ -391,7 +426,7 @@ describe("teamsCatalogService", () => { }); it("passes install secretValues through to company portability import", async () => { - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); await svc.installCatalogTeam("company-1", "core-exec-team", { secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" }, @@ -430,7 +465,7 @@ describe("teamsCatalogService", () => { agentWithCatalogTeam("sha256:stale-hash"), { id: "no-provenance", companyId: "company-1", metadata: null }, ]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const installed = await svc.listInstalledCatalogTeams("company-1"); @@ -449,7 +484,7 @@ describe("teamsCatalogService", () => { it("marks a team up to date when the installed originHash matches the catalog hash", async () => { mockAgentService.list.mockResolvedValue([agentWithCatalogTeam(CORE_EXEC_TEAM_HASH)]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const installed = await svc.listInstalledCatalogTeams("company-1"); @@ -465,7 +500,7 @@ describe("teamsCatalogService", () => { metadata: { paperclip: { catalogTeam: { catalogId: "paperclipai:bundled:gone:removed", originHash: "sha256:x" } } }, }, ]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); const installed = await svc.listInstalledCatalogTeams("company-1"); @@ -476,7 +511,7 @@ describe("teamsCatalogService", () => { it("returns an empty list when no agents carry catalog-team provenance", async () => { mockAgentService.list.mockResolvedValue([{ id: "a", companyId: "company-1", metadata: {} }]); - const svc = teamsCatalogService({} as any); + const svc = teamsCatalogService(testDb as any); expect(await svc.listInstalledCatalogTeams("company-1")).toEqual([]); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 0ede98c42f8..4a9edbfd568 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3130,6 +3130,12 @@ for (const route of [ path: route[1], tags: ["teams"], summary: route[2], + ...(route[1].endsWith("/install") ? { + description: "Imports team resources; HTTP success does not prove every required skill is ready. Read warnings for post-import failures and skillPreparations for required sources; a preparation plan is not proof of installation. Report unresolved requirements as partial completion and preserve existing resources. Use the actual returned portabilityImport agents and projects for company-scoped resource links. Before repeating an uncertain install, inspect installed teams and saved resource identities rather than creating another copy. resourceLinks provides canonical company-scoped hrefs for actual saved agents, projects and routines, including reused resources; share these links with the installation outcome.", + responses: { 201: r.ok(z.object({ resourceLinks: z.array(z.object({ + kind: z.enum(["agent", "project", "routine"]), id: z.string(), name: z.string(), href: z.string(), + })) }).passthrough()), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, + } : {}), }); } diff --git a/server/src/services/teams-catalog.ts b/server/src/services/teams-catalog.ts index 93db66319c5..7a36f5167ea 100644 --- a/server/src/services/teams-catalog.ts +++ b/server/src/services/teams-catalog.ts @@ -1,10 +1,12 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import type { CatalogManifest, CatalogTeam, + CatalogTeamInstallResult as SharedCatalogTeamInstallResult, CatalogTeamFileKind, CatalogTeamKind, CatalogTeamSkillRequirement, @@ -105,6 +107,7 @@ export interface CatalogTeamImportPreviewResult { } export interface CatalogTeamInstallResult { + resourceLinks: SharedCatalogTeamInstallResult["resourceLinks"]; team: CatalogTeam; portabilityImport: CompanyPortabilityImportResult; skillPreparations: CatalogTeamSkillPreparation[]; @@ -904,6 +907,8 @@ export function teamsCatalogService(db: Db) { catalogRef: string, options: CatalogTeamImportOptions = {}, ): Promise { + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1); + if (!company) throw notFound("Company not found"); const prepared = await prepareCatalogTeamSource(companyId, catalogRef, options); if (prepared.errors.length > 0) { throw unprocessable(`Catalog team source preparation failed: ${prepared.errors.join("; ")}`); @@ -957,6 +962,14 @@ export function teamsCatalogService(db: Db) { return { team: prepared.team, portabilityImport: result, + resourceLinks: [ + ...result.agents.flatMap(agent => agent.id ? [{ kind: "agent" as const, id: agent.id, name: agent.name, + href: `/${encodeURIComponent(company.issuePrefix)}/agents/${encodeURIComponent(agent.id)}` }] : []), + ...result.projects.flatMap(project => project.id ? [{ kind: "project" as const, id: project.id, name: project.name, + href: `/${encodeURIComponent(company.issuePrefix)}/projects/${encodeURIComponent(project.id)}` }] : []), + ...(result.routines ?? []).flatMap(routine => routine.id ? [{ kind: "routine" as const, id: routine.id, name: routine.title, + href: `/${encodeURIComponent(company.issuePrefix)}/routines/${encodeURIComponent(routine.id)}` }] : []), + ], skillPreparations: prepared.skillPreparations, warnings: result.warnings, }; diff --git a/ui/storybook/stories/teams/team-catalog.stories.tsx b/ui/storybook/stories/teams/team-catalog.stories.tsx index 9b404a2d7a1..1ce1a2fd98e 100644 --- a/ui/storybook/stories/teams/team-catalog.stories.tsx +++ b/ui/storybook/stories/teams/team-catalog.stories.tsx @@ -360,6 +360,12 @@ export const InstallSuccess: Story = { team={baseTeam} result={{ team: baseTeam, + resourceLinks: [ + { kind: "agent", id: "a1", name: "CEO", href: "/STORY/agents/a1" }, + { kind: "agent", id: "a2", name: "CTO", href: "/STORY/agents/a2" }, + { kind: "agent", id: "a3", name: "CMO (from Core Exec Team)", href: "/STORY/agents/a3" }, + { kind: "project", id: "p1", name: "Launch", href: "/STORY/projects/p1" }, + ], portabilityImport: { company: { id: "company-storybook", name: "Paperclip", action: "unchanged" }, agents: [