diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index ecefbfba423..47cdbc01d77 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -208,6 +208,11 @@ async function api(baseUrl: string, pathname: string, init?: RequestInit): Pr return text ? JSON.parse(text) as T : (null as T); } +function isPortableAgent(agent: { metadata?: Record | null }) { + const marker = agent.metadata?.paperclipBuiltInAgent; + return typeof marker !== "object" || marker === null; +} + async function runCliJson( args: string[], opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean }, @@ -560,7 +565,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { expect(importedExisting.company.action).toBe("unchanged"); expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); - const twiceImportedAgents = await api>( + const twiceImportedAgents = await api | null }>>( apiBase, `/api/companies/${importedNew.company.id}/agents`, ); @@ -573,9 +578,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { `/api/companies/${importedNew.company.id}/issues`, ); const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title); + const twiceImportedPortableAgents = twiceImportedAgents.filter(isPortableAgent); - expect(twiceImportedAgents).toHaveLength(2); - expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2); + expect(twiceImportedPortableAgents).toHaveLength(2); + expect(new Set(twiceImportedPortableAgents.map((agent) => agent.name)).size).toBe(2); expect(twiceImportedProjects).toHaveLength(2); expect(twiceImportedMatchingIssues).toHaveLength(2); expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index f4283a0910f..001ea3fd1cb 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1959,6 +1959,123 @@ describe("company portability", () => { ]); }); + it("skips built-in managed agents and routines during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-1", + name: "ClaudeCoder", + status: "idle", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "claude_local", + adapterConfig: { promptTemplate: "You are ClaudeCoder." }, + runtimeConfig: { heartbeat: { intervalSec: 3600 } }, + budgetMonthlyCents: 0, + permissions: { canCreateAgents: false }, + metadata: null, + }, + { + id: "agent-built-in", + name: "Reflection Coach", + status: "paused", + role: "coach", + title: "Reflection Coach", + icon: "sparkles", + reportsTo: null, + capabilities: "Reviews trajectories", + adapterType: "codex_local", + adapterConfig: { promptTemplate: "You coach agents." }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + permissions: {}, + metadata: { + paperclipBuiltInAgent: { + key: "reflection-coach", + featureKeys: ["recent-agent-reflection"], + }, + }, + }, + ]); + routineSvc.list.mockResolvedValue([ + { + id: "routine-built-in", + companyId: "company-1", + projectId: null, + goalId: null, + parentIssueId: null, + title: "Review recent agent trajectories for coaching proposals", + description: "Review recent agent work and propose coaching follow-ups.", + assigneeAgentId: "agent-built-in", + priority: "medium", + status: "paused", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + originFingerprint: null, + triggers: [ + { + id: "trigger-built-in", + companyId: "company-1", + routineId: "routine-built-in", + kind: "schedule", + label: "Weekly review", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + nextRunAt: null, + lastFiredAt: null, + publicId: "public-built-in", + secretId: "secret-built-in", + signingMode: null, + replayWindowSec: null, + lastRotatedAt: null, + lastResult: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + lastRun: null, + activeIssue: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: false, + }, + }); + + expect(exported.files["agents/claudecoder/AGENTS.md"]).toBeDefined(); + expect(exported.files["agents/reflection-coach/AGENTS.md"]).toBeUndefined(); + expect(exported.files["tasks/review-recent-agent-trajectories-for-coaching-proposals/TASK.md"]).toBeUndefined(); + expect(exported.manifest.agents.map((agent) => agent.slug)).toEqual(["claudecoder"]); + expect(exported.manifest.issues).toEqual([]); + expect(exported.warnings).toContain("Skipped 1 built-in managed agent from export."); + expect(exported.warnings).toContain("Skipped 1 built-in managed routine from export."); + }); + it("imports recurring task packages as routines instead of one-time issues", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index f8e73d13322..91710f71d72 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1155,6 +1155,35 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/built-in-agents/{key}/status", + tags: ["agents"], + summary: "Get built-in agent bundle status", + request: { params: z.object({ companyId: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/built-in-agents/{key}/reconcile", + tags: ["agents"], + summary: "Reconcile built-in agent managed resources", + request: { + params: z.object({ companyId: z.string(), key: z.string() }), + body: jsonBody(builtInAgentEmptyMutationSchema), + }, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 422: r.unprocessable, + }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/built-in-agents/{key}/provision", diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b1c1c7216cb..41e0498e02f 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3,7 +3,8 @@ import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; -import type { Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { builtInManagedResources, type Db } from "@paperclipai/db"; import type { CompanyPortabilityAgentManifestEntry, CompanyPortabilityCollisionStrategy, @@ -77,6 +78,7 @@ import { readCatalogStringList, readPortableCatalogProvenance, } from "./catalog-provenance.js"; +import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js"; import { normalizePortablePath } from "./portable-path.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ @@ -3253,24 +3255,61 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; let companyLogoPath: string | null = null; + const managedResourceRows = typeof (db as { select?: unknown }).select === "function" + ? await db + .select({ + resourceKind: builtInManagedResources.resourceKind, + resourceId: builtInManagedResources.resourceId, + }) + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)) + : []; + const managedSkillIds = new Set( + managedResourceRows + .filter((row) => row.resourceKind === "skill") + .map((row) => row.resourceId), + ); + const managedRoutineIds = new Set( + managedResourceRows + .filter((row) => row.resourceKind === "routine") + .map((row) => row.resourceId), + ); + const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); - const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : []; + const builtInAgentRows = liveAgentRows.filter((agent) => readBuiltInAgentMarker(agent.metadata)); + const portableAgentRows = liveAgentRows.filter((agent) => !readBuiltInAgentMarker(agent.metadata)); + const companySkillRowsRaw = include.skills || include.agents ? await companySkills.listFull(companyId) : []; + const managedSkillRows = companySkillRowsRaw.filter((skill) => managedSkillIds.has(skill.id)); + const companySkillRows = companySkillRowsRaw.filter((skill) => !managedSkillIds.has(skill.id)); if (include.agents) { const skipped = allAgentRows.length - liveAgentRows.length; if (skipped > 0) { warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`); } + if (builtInAgentRows.length > 0) { + warnings.push(`Skipped ${builtInAgentRows.length} built-in managed agent${builtInAgentRows.length === 1 ? "" : "s"} from export.`); + } + } + if (include.skills && managedSkillRows.length > 0) { + warnings.push(`Skipped ${managedSkillRows.length} built-in managed skill${managedSkillRows.length === 1 ? "" : "s"} from export.`); } const agentByReference = new Map(); - for (const agent of liveAgentRows) { - agentByReference.set(agent.id, agent); - agentByReference.set(agent.name, agent); + const builtInAgentByReference = new Map(); + const addAgentReferences = (map: Map, agent: typeof liveAgentRows[number]) => { + map.set(agent.id, agent); + map.set(agent.name, agent); const normalizedName = normalizeAgentUrlKey(agent.name); if (normalizedName) { - agentByReference.set(normalizedName, agent); + map.set(normalizedName, agent); } + }; + for (const agent of portableAgentRows) { + addAgentReferences(agentByReference, agent); + } + for (const agent of builtInAgentRows) { + addAgentReferences(builtInAgentByReference, agent); } const selectedAgents = new Map(); @@ -3280,6 +3319,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const normalized = normalizeAgentUrlKey(trimmed) ?? trimmed; const match = agentByReference.get(trimmed) ?? agentByReference.get(normalized); if (!match) { + const builtInMatch = builtInAgentByReference.get(trimmed) ?? builtInAgentByReference.get(normalized); + if (builtInMatch) { + warnings.push(`Agent selector "${selector}" is a built-in managed agent and was skipped.`); + continue; + } warnings.push(`Agent selector "${selector}" was not found and was skipped.`); continue; } @@ -3287,7 +3331,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (include.agents && selectedAgents.size === 0) { - for (const agent of liveAgentRows) { + for (const agent of portableAgentRows) { selectedAgents.set(agent.id, agent); } } @@ -3308,7 +3352,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const routinesSvc = routineService(db); const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : []; const allProjects = allProjectsRaw.filter((project) => !project.archivedAt); - const allRoutines = include.issues ? await routinesSvc.list(companyId) : []; + const allRoutinesRaw = include.issues ? await routinesSvc.list(companyId) : []; + const builtInRoutineRows = allRoutinesRaw.filter((routine) => + managedRoutineIds.has(routine.id) || routine.originKind === "built_in_agent_bundle" + ); + const allRoutines = allRoutinesRaw.filter((routine) => + !managedRoutineIds.has(routine.id) && routine.originKind !== "built_in_agent_bundle" + ); + if (include.issues && builtInRoutineRows.length > 0) { + warnings.push(`Skipped ${builtInRoutineRows.length} built-in managed routine${builtInRoutineRows.length === 1 ? "" : "s"} from export.`); + } const projectById = new Map(allProjects.map((project) => [project.id, project])); const projectByReference = new Map(); for (const project of allProjects) { @@ -3330,6 +3383,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const selectedIssues = new Map>>(); const selectedRoutines = new Map(); const routineById = new Map(allRoutines.map((routine) => [routine.id, routine])); + const builtInRoutineById = new Map(builtInRoutineRows.map((routine) => [routine.id, routine])); const resolveIssueBySelector = async (selector: string) => { const trimmed = selector.trim(); if (!trimmed) return null; @@ -3340,6 +3394,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const selector of input.issues ?? []) { const issue = await resolveIssueBySelector(selector); if (!issue || issue.companyId !== companyId) { + if (builtInRoutineById.has(selector.trim())) { + warnings.push(`Routine selector "${selector}" is a built-in managed routine and was skipped.`); + continue; + } const routine = routineById.get(selector.trim()); if (routine) { selectedRoutines.set(routine.id, routine);