mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Exclude built-in resources from company exports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user