Exclude built-in resources from company exports

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:51:31 +00:00
1 parent 8162a52013
commit ed97b4130e
4 files changed
+221 -11

No files matched your search

@@ -208,6 +208,11 @@ async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Pr
return text ? JSON.parse(text) as T : (null as T);
}
function isPortableAgent(agent: { metadata?: Record<string, unknown> | null }) {
const marker = agent.metadata?.paperclipBuiltInAgent;
return typeof marker !== "object" || marker === null;
}
async function runCliJson<T>(
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<Array<{ id: string; name: string }>>(
const twiceImportedAgents = await api<Array<{ id: string; name: string; metadata?: Record<string, unknown> | 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);
@@ -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);
+29
View File
@@ -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",
+66 -8
View File
@@ -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<string, typeof liveAgentRows[number]>();
for (const agent of liveAgentRows) {
agentByReference.set(agent.id, agent);
agentByReference.set(agent.name, agent);
const builtInAgentByReference = new Map<string, typeof liveAgentRows[number]>();
const addAgentReferences = (map: Map<string, typeof liveAgentRows[number]>, 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<string, typeof liveAgentRows[number]>();
@@ -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<string, typeof allProjects[number]>();
for (const project of allProjects) {
@@ -3330,6 +3383,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const selectedIssues = new Map<string, Awaited<ReturnType<typeof issuesSvc.getById>>>();
const selectedRoutines = new Map<string, typeof allRoutines[number]>();
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);