feat: company portability — export/import companies and agents
Add company export, import preview, and import endpoints with manifest- based bundle format. Includes URL key utilities for agents and projects, collision detection/rename strategies, and secret requirement tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
22
packages/shared/src/agent-url-key.ts
Normal file
22
packages/shared/src/agent-url-key.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const AGENT_URL_KEY_DELIM_RE = /[^a-z0-9]+/g;
|
||||
const AGENT_URL_KEY_TRIM_RE = /^-+|-+$/g;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuidLike(value: string | null | undefined): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
return UUID_RE.test(value.trim());
|
||||
}
|
||||
|
||||
export function normalizeAgentUrlKey(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(AGENT_URL_KEY_DELIM_RE, "-")
|
||||
.replace(AGENT_URL_KEY_TRIM_RE, "");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function deriveAgentUrlKey(name: string | null | undefined, fallback?: string | null): string {
|
||||
return normalizeAgentUrlKey(name) ?? normalizeAgentUrlKey(fallback) ?? "agent";
|
||||
}
|
||||
@@ -101,6 +101,22 @@ export type {
|
||||
Invite,
|
||||
JoinRequest,
|
||||
InstanceUserRoleGrant,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilitySecretRequirement,
|
||||
CompanyPortabilityCompanyManifestEntry,
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilitySource,
|
||||
CompanyPortabilityImportTarget,
|
||||
CompanyPortabilityAgentSelection,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
CompanyPortabilityPreviewRequest,
|
||||
CompanyPortabilityPreviewAgentPlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
@@ -193,9 +209,25 @@ export {
|
||||
type ClaimJoinRequestApiKey,
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
portabilityIncludeSchema,
|
||||
portabilitySecretRequirementSchema,
|
||||
portabilityCompanyManifestEntrySchema,
|
||||
portabilityAgentManifestEntrySchema,
|
||||
portabilityManifestSchema,
|
||||
portabilitySourceSchema,
|
||||
portabilityTargetSchema,
|
||||
portabilityAgentSelectionSchema,
|
||||
portabilityCollisionStrategySchema,
|
||||
companyPortabilityExportSchema,
|
||||
companyPortabilityPreviewSchema,
|
||||
companyPortabilityImportSchema,
|
||||
type CompanyPortabilityExport,
|
||||
type CompanyPortabilityPreview,
|
||||
type CompanyPortabilityImport,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js";
|
||||
|
||||
export {
|
||||
paperclipConfigSchema,
|
||||
|
||||
16
packages/shared/src/project-url-key.ts
Normal file
16
packages/shared/src/project-url-key.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const PROJECT_URL_KEY_DELIM_RE = /[^a-z0-9]+/g;
|
||||
const PROJECT_URL_KEY_TRIM_RE = /^-+|-+$/g;
|
||||
|
||||
export function normalizeProjectUrlKey(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(PROJECT_URL_KEY_DELIM_RE, "-")
|
||||
.replace(PROJECT_URL_KEY_TRIM_RE, "");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function deriveProjectUrlKey(name: string | null | undefined, fallback?: string | null): string {
|
||||
return normalizeProjectUrlKey(name) ?? normalizeProjectUrlKey(fallback) ?? "project";
|
||||
}
|
||||
138
packages/shared/src/types/company-portability.ts
Normal file
138
packages/shared/src/types/company-portability.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
export interface CompanyPortabilityInclude {
|
||||
company: boolean;
|
||||
agents: boolean;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilitySecretRequirement {
|
||||
key: string;
|
||||
description: string | null;
|
||||
agentSlug: string | null;
|
||||
providerHint: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityCompanyManifestEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityAgentManifestEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
path: string;
|
||||
role: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
capabilities: string | null;
|
||||
reportsToSlug: string | null;
|
||||
adapterType: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
permissions: Record<string, unknown>;
|
||||
budgetMonthlyCents: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityManifest {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
source: {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
} | null;
|
||||
includes: CompanyPortabilityInclude;
|
||||
company: CompanyPortabilityCompanyManifestEntry | null;
|
||||
agents: CompanyPortabilityAgentManifestEntry[];
|
||||
requiredSecrets: CompanyPortabilitySecretRequirement[];
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityExportResult {
|
||||
manifest: CompanyPortabilityManifest;
|
||||
files: Record<string, string>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type CompanyPortabilitySource =
|
||||
| {
|
||||
type: "inline";
|
||||
manifest: CompanyPortabilityManifest;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
type: "url";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: "github";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CompanyPortabilityImportTarget =
|
||||
| {
|
||||
mode: "new_company";
|
||||
newCompanyName?: string | null;
|
||||
}
|
||||
| {
|
||||
mode: "existing_company";
|
||||
companyId: string;
|
||||
};
|
||||
|
||||
export type CompanyPortabilityAgentSelection = "all" | string[];
|
||||
|
||||
export type CompanyPortabilityCollisionStrategy = "rename" | "skip" | "replace";
|
||||
|
||||
export interface CompanyPortabilityPreviewRequest {
|
||||
source: CompanyPortabilitySource;
|
||||
include?: Partial<CompanyPortabilityInclude>;
|
||||
target: CompanyPortabilityImportTarget;
|
||||
agents?: CompanyPortabilityAgentSelection;
|
||||
collisionStrategy?: CompanyPortabilityCollisionStrategy;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityPreviewAgentPlan {
|
||||
slug: string;
|
||||
action: "create" | "update" | "skip";
|
||||
plannedName: string;
|
||||
existingAgentId: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityPreviewResult {
|
||||
include: CompanyPortabilityInclude;
|
||||
targetCompanyId: string | null;
|
||||
targetCompanyName: string | null;
|
||||
collisionStrategy: CompanyPortabilityCollisionStrategy;
|
||||
selectedAgentSlugs: string[];
|
||||
plan: {
|
||||
companyAction: "none" | "create" | "update";
|
||||
agentPlans: CompanyPortabilityPreviewAgentPlan[];
|
||||
};
|
||||
requiredSecrets: CompanyPortabilitySecretRequirement[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {}
|
||||
|
||||
export interface CompanyPortabilityImportResult {
|
||||
company: {
|
||||
id: string;
|
||||
name: string;
|
||||
action: "created" | "updated" | "unchanged";
|
||||
};
|
||||
agents: {
|
||||
slug: string;
|
||||
id: string | null;
|
||||
action: "created" | "updated" | "skipped";
|
||||
name: string;
|
||||
reason: string | null;
|
||||
}[];
|
||||
requiredSecrets: CompanyPortabilitySecretRequirement[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityExportRequest {
|
||||
include?: Partial<CompanyPortabilityInclude>;
|
||||
}
|
||||
@@ -52,3 +52,21 @@ export type {
|
||||
JoinRequest,
|
||||
InstanceUserRoleGrant,
|
||||
} from "./access.js";
|
||||
export type {
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilitySecretRequirement,
|
||||
CompanyPortabilityCompanyManifestEntry,
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilitySource,
|
||||
CompanyPortabilityImportTarget,
|
||||
CompanyPortabilityAgentSelection,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
CompanyPortabilityPreviewRequest,
|
||||
CompanyPortabilityPreviewAgentPlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
} from "./company-portability.js";
|
||||
|
||||
112
packages/shared/src/validators/company-portability.ts
Normal file
112
packages/shared/src/validators/company-portability.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const portabilityIncludeSchema = z
|
||||
.object({
|
||||
company: z.boolean().optional(),
|
||||
agents: z.boolean().optional(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const portabilitySecretRequirementSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
agentSlug: z.string().min(1).nullable(),
|
||||
providerHint: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const portabilityCompanyManifestEntrySchema = z.object({
|
||||
path: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
brandColor: z.string().nullable(),
|
||||
requireBoardApprovalForNewAgents: z.boolean(),
|
||||
});
|
||||
|
||||
export const portabilityAgentManifestEntrySchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
role: z.string().min(1),
|
||||
title: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
capabilities: z.string().nullable(),
|
||||
reportsToSlug: z.string().min(1).nullable(),
|
||||
adapterType: z.string().min(1),
|
||||
adapterConfig: z.record(z.unknown()),
|
||||
runtimeConfig: z.record(z.unknown()),
|
||||
permissions: z.record(z.unknown()),
|
||||
budgetMonthlyCents: z.number().int().nonnegative(),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
});
|
||||
|
||||
export const portabilityManifestSchema = z.object({
|
||||
schemaVersion: z.number().int().positive(),
|
||||
generatedAt: z.string().datetime(),
|
||||
source: z
|
||||
.object({
|
||||
companyId: z.string().uuid(),
|
||||
companyName: z.string().min(1),
|
||||
})
|
||||
.nullable(),
|
||||
includes: z.object({
|
||||
company: z.boolean(),
|
||||
agents: z.boolean(),
|
||||
}),
|
||||
company: portabilityCompanyManifestEntrySchema.nullable(),
|
||||
agents: z.array(portabilityAgentManifestEntrySchema),
|
||||
requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]),
|
||||
});
|
||||
|
||||
export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("inline"),
|
||||
manifest: portabilityManifestSchema,
|
||||
files: z.record(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("url"),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("github"),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const portabilityTargetSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("new_company"),
|
||||
newCompanyName: z.string().min(1).optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("existing_company"),
|
||||
companyId: z.string().uuid(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const portabilityAgentSelectionSchema = z.union([
|
||||
z.literal("all"),
|
||||
z.array(z.string().min(1)),
|
||||
]);
|
||||
|
||||
export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "replace"]);
|
||||
|
||||
export const companyPortabilityExportSchema = z.object({
|
||||
include: portabilityIncludeSchema.optional(),
|
||||
});
|
||||
|
||||
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
|
||||
|
||||
export const companyPortabilityPreviewSchema = z.object({
|
||||
source: portabilitySourceSchema,
|
||||
include: portabilityIncludeSchema.optional(),
|
||||
target: portabilityTargetSchema,
|
||||
agents: portabilityAgentSelectionSchema.optional(),
|
||||
collisionStrategy: portabilityCollisionStrategySchema.optional(),
|
||||
});
|
||||
|
||||
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
||||
|
||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema;
|
||||
|
||||
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
|
||||
@@ -112,3 +112,21 @@ export {
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
export {
|
||||
portabilityIncludeSchema,
|
||||
portabilitySecretRequirementSchema,
|
||||
portabilityCompanyManifestEntrySchema,
|
||||
portabilityAgentManifestEntrySchema,
|
||||
portabilityManifestSchema,
|
||||
portabilitySourceSchema,
|
||||
portabilityTargetSchema,
|
||||
portabilityAgentSelectionSchema,
|
||||
portabilityCollisionStrategySchema,
|
||||
companyPortabilityExportSchema,
|
||||
companyPortabilityPreviewSchema,
|
||||
companyPortabilityImportSchema,
|
||||
type CompanyPortabilityExport,
|
||||
type CompanyPortabilityPreview,
|
||||
type CompanyPortabilityImport,
|
||||
} from "./company-portability.js";
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
|
||||
import {
|
||||
companyPortabilityExportSchema,
|
||||
companyPortabilityImportSchema,
|
||||
companyPortabilityPreviewSchema,
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
} from "@paperclip/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, companyService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function companyRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = companyService(db);
|
||||
const portability = companyPortabilityService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
@@ -48,6 +55,50 @@ export function companyRoutes(db: Db) {
|
||||
res.json(company);
|
||||
});
|
||||
|
||||
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await portability.exportBundle(companyId, req.body);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
||||
if (req.body.target.mode === "existing_company") {
|
||||
assertCompanyAccess(req, req.body.target.companyId);
|
||||
} else {
|
||||
assertBoard(req);
|
||||
}
|
||||
const preview = await portability.previewImport(req.body);
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
|
||||
if (req.body.target.mode === "existing_company") {
|
||||
assertCompanyAccess(req, req.body.target.companyId);
|
||||
} else {
|
||||
assertBoard(req);
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
|
||||
await logActivity(db, {
|
||||
companyId: result.company.id,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "company.imported",
|
||||
entityType: "company",
|
||||
entityId: result.company.id,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: {
|
||||
include: req.body.include ?? null,
|
||||
agentCount: result.agents.length,
|
||||
warningCount: result.warnings.length,
|
||||
companyAction: result.company.action,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/", validate(createCompanySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
||||
|
||||
786
server/src/services/company-portability.ts
Normal file
786
server/src/services/company-portability.ts
Normal file
@@ -0,0 +1,786 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import type {
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
CompanyPortabilityExport,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityImport,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityPreview,
|
||||
CompanyPortabilityPreviewAgentPlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
} from "@paperclip/shared";
|
||||
import { normalizeAgentUrlKey, portabilityManifestSchema } from "@paperclip/shared";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { companyService } from "./companies.js";
|
||||
|
||||
const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
|
||||
company: true,
|
||||
agents: true,
|
||||
};
|
||||
|
||||
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
||||
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
type ResolvedSource = {
|
||||
manifest: CompanyPortabilityManifest;
|
||||
files: Record<string, string>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MarkdownDoc = {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type ImportPlanInternal = {
|
||||
preview: CompanyPortabilityPreviewResult;
|
||||
source: ResolvedSource;
|
||||
include: CompanyPortabilityInclude;
|
||||
collisionStrategy: CompanyPortabilityCollisionStrategy;
|
||||
selectedAgents: CompanyPortabilityAgentManifestEntry[];
|
||||
};
|
||||
|
||||
type AgentLike = {
|
||||
id: string;
|
||||
name: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toSafeSlug(input: string, fallback: string) {
|
||||
return normalizeAgentUrlKey(input) ?? fallback;
|
||||
}
|
||||
|
||||
function uniqueSlug(base: string, used: Set<string>) {
|
||||
if (!used.has(base)) {
|
||||
used.add(base);
|
||||
return base;
|
||||
}
|
||||
let idx = 2;
|
||||
while (true) {
|
||||
const candidate = `${base}-${idx}`;
|
||||
if (!used.has(candidate)) {
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueNameBySlug(baseName: string, existingSlugs: Set<string>) {
|
||||
const baseSlug = normalizeAgentUrlKey(baseName) ?? "agent";
|
||||
if (!existingSlugs.has(baseSlug)) return baseName;
|
||||
let idx = 2;
|
||||
while (true) {
|
||||
const candidateName = `${baseName} ${idx}`;
|
||||
const candidateSlug = normalizeAgentUrlKey(candidateName) ?? `agent-${idx}`;
|
||||
if (!existingSlugs.has(candidateSlug)) return candidateName;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPortabilityInclude {
|
||||
return {
|
||||
company: input?.company ?? DEFAULT_INCLUDE.company,
|
||||
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureMarkdownPath(pathValue: string) {
|
||||
const normalized = pathValue.replace(/\\/g, "/");
|
||||
if (!normalized.endsWith(".md")) {
|
||||
throw unprocessable(`Manifest file path must end in .md: ${pathValue}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePortableEnv(
|
||||
agentSlug: string,
|
||||
envValue: unknown,
|
||||
requiredSecrets: CompanyPortabilityManifest["requiredSecrets"],
|
||||
) {
|
||||
if (typeof envValue !== "object" || envValue === null || Array.isArray(envValue)) return {};
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, binding] of Object.entries(env)) {
|
||||
if (SENSITIVE_ENV_KEY_RE.test(key)) {
|
||||
requiredSecrets.push({
|
||||
key,
|
||||
description: `Set ${key} for agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
providerHint: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next[key] = binding;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizePortableConfig(
|
||||
value: unknown,
|
||||
agentSlug: string,
|
||||
requiredSecrets: CompanyPortabilityManifest["requiredSecrets"],
|
||||
): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
const input = value as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, entry] of Object.entries(input)) {
|
||||
if (key === "cwd" || key === "instructionsFilePath") continue;
|
||||
if (key === "env") {
|
||||
next[key] = normalizePortableEnv(agentSlug, entry, requiredSecrets);
|
||||
continue;
|
||||
}
|
||||
next[key] = entry;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (value === null) {
|
||||
lines.push(`${key}: null`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "boolean" || typeof value === "number") {
|
||||
lines.push(`${key}: ${String(value)}`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
continue;
|
||||
}
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
lines.push("---");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
|
||||
const cleanBody = body.replace(/\r\n/g, "\n").trim();
|
||||
if (!cleanBody) {
|
||||
return `${renderFrontmatter(frontmatter)}\n`;
|
||||
}
|
||||
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
||||
}
|
||||
|
||||
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: normalized.trim() };
|
||||
}
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) {
|
||||
return { frontmatter: {}, body: normalized.trim() };
|
||||
}
|
||||
const frontmatterRaw = normalized.slice(4, closing).trim();
|
||||
const body = normalized.slice(closing + 5).trim();
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
for (const line of frontmatterRaw.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx <= 0) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const rawValue = line.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
if (rawValue === "null") {
|
||||
frontmatter[key] = null;
|
||||
continue;
|
||||
}
|
||||
if (rawValue === "true" || rawValue === "false") {
|
||||
frontmatter[key] = rawValue === "true";
|
||||
continue;
|
||||
}
|
||||
if (/^-?\d+(\.\d+)?$/.test(rawValue)) {
|
||||
frontmatter[key] = Number(rawValue);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
frontmatter[key] = JSON.parse(rawValue);
|
||||
continue;
|
||||
} catch {
|
||||
frontmatter[key] = rawValue;
|
||||
}
|
||||
}
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
async function fetchJson(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchText(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) {
|
||||
const seen = new Set<string>();
|
||||
const out: CompanyPortabilityManifest["requiredSecrets"] = [];
|
||||
for (const value of values) {
|
||||
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseGitHubTreeUrl(rawUrl: string) {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.hostname !== "github.com") {
|
||||
throw unprocessable("GitHub source must use github.com URL");
|
||||
}
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw unprocessable("Invalid GitHub URL");
|
||||
}
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||
let ref = "main";
|
||||
let basePath = "";
|
||||
if (parts[2] === "tree") {
|
||||
ref = parts[3] ?? "main";
|
||||
basePath = parts.slice(4).join("/");
|
||||
}
|
||||
return { owner, repo, ref, basePath };
|
||||
}
|
||||
|
||||
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
|
||||
const normalizedFilePath = filePath.replace(/^\/+/, "");
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
|
||||
}
|
||||
|
||||
async function readAgentInstructions(agent: AgentLike): Promise<{ body: string; warning: string | null }> {
|
||||
const config = agent.adapterConfig as Record<string, unknown>;
|
||||
const instructionsFilePath = asString(config.instructionsFilePath);
|
||||
if (instructionsFilePath && path.isAbsolute(instructionsFilePath)) {
|
||||
try {
|
||||
const stat = await fs.stat(instructionsFilePath);
|
||||
if (stat.isFile() && stat.size <= 1024 * 1024) {
|
||||
const body = await Promise.race([
|
||||
fs.readFile(instructionsFilePath, "utf8"),
|
||||
new Promise<string>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("timed out reading instructions file")), 1500);
|
||||
}),
|
||||
]);
|
||||
return { body, warning: null };
|
||||
}
|
||||
} catch {
|
||||
// fall through to promptTemplate fallback
|
||||
}
|
||||
}
|
||||
const promptTemplate = asString(config.promptTemplate);
|
||||
if (promptTemplate) {
|
||||
return { body: promptTemplate, warning: null };
|
||||
}
|
||||
return {
|
||||
body: "_No AGENTS instructions were resolved from current agent config._",
|
||||
warning: `Agent ${agent.name} has no resolvable instructionsFilePath/promptTemplate; exported placeholder AGENTS.md.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function companyPortabilityService(db: Db) {
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
|
||||
if (source.type === "inline") {
|
||||
return {
|
||||
manifest: portabilityManifestSchema.parse(source.manifest),
|
||||
files: source.files,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === "url") {
|
||||
const manifestJson = await fetchJson(source.url);
|
||||
const manifest = portabilityManifestSchema.parse(manifestJson);
|
||||
const base = new URL(".", source.url);
|
||||
const files: Record<string, string> = {};
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (manifest.company?.path) {
|
||||
const companyPath = ensureMarkdownPath(manifest.company.path);
|
||||
files[companyPath] = await fetchText(new URL(companyPath, base).toString());
|
||||
}
|
||||
for (const agent of manifest.agents) {
|
||||
const filePath = ensureMarkdownPath(agent.path);
|
||||
files[filePath] = await fetchText(new URL(filePath, base).toString());
|
||||
}
|
||||
|
||||
return { manifest, files, warnings };
|
||||
}
|
||||
|
||||
const parsed = parseGitHubTreeUrl(source.url);
|
||||
let ref = parsed.ref;
|
||||
const manifestRelativePath = [parsed.basePath, "paperclip.manifest.json"].filter(Boolean).join("/");
|
||||
let manifest: CompanyPortabilityManifest | null = null;
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
manifest = portabilityManifestSchema.parse(
|
||||
await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (ref === "main") {
|
||||
ref = "master";
|
||||
warnings.push("GitHub ref main not found; falling back to master.");
|
||||
manifest = portabilityManifestSchema.parse(
|
||||
await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)),
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
if (manifest.company?.path) {
|
||||
files[manifest.company.path] = await fetchText(
|
||||
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, manifest.company.path].filter(Boolean).join("/")),
|
||||
);
|
||||
}
|
||||
for (const agent of manifest.agents) {
|
||||
files[agent.path] = await fetchText(
|
||||
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, agent.path].filter(Boolean).join("/")),
|
||||
);
|
||||
}
|
||||
return { manifest, files, warnings };
|
||||
}
|
||||
|
||||
async function exportBundle(
|
||||
companyId: string,
|
||||
input: CompanyPortabilityExport,
|
||||
): Promise<CompanyPortabilityExportResult> {
|
||||
const include = normalizeInclude(input.include);
|
||||
const company = await companies.getById(companyId);
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
const warnings: string[] = [];
|
||||
const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = [];
|
||||
|
||||
const manifest: CompanyPortabilityManifest = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: {
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
},
|
||||
includes: include,
|
||||
company: null,
|
||||
agents: [],
|
||||
requiredSecrets: [],
|
||||
};
|
||||
|
||||
if (include.company) {
|
||||
const companyPath = "company/COMPANY.md";
|
||||
files[companyPath] = buildMarkdown(
|
||||
{
|
||||
kind: "company",
|
||||
name: company.name,
|
||||
generatedAt: manifest.generatedAt,
|
||||
},
|
||||
company.description ?? "",
|
||||
);
|
||||
manifest.company = {
|
||||
path: companyPath,
|
||||
name: company.name,
|
||||
description: company.description ?? null,
|
||||
brandColor: company.brandColor ?? null,
|
||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents,
|
||||
};
|
||||
}
|
||||
|
||||
if (include.agents) {
|
||||
const agentRows = await agents.list(companyId);
|
||||
const usedSlugs = new Set<string>();
|
||||
const idToSlug = new Map<string, string>();
|
||||
|
||||
for (const agent of agentRows) {
|
||||
const baseSlug = toSafeSlug(agent.name, "agent");
|
||||
const slug = uniqueSlug(baseSlug, usedSlugs);
|
||||
idToSlug.set(agent.id, slug);
|
||||
}
|
||||
|
||||
for (const agent of agentRows) {
|
||||
const slug = idToSlug.get(agent.id)!;
|
||||
const instructions = await readAgentInstructions(agent);
|
||||
if (instructions.warning) warnings.push(instructions.warning);
|
||||
const agentPath = `agents/${slug}/AGENTS.md`;
|
||||
|
||||
const portableAdapterConfig = normalizePortableConfig(agent.adapterConfig, slug, requiredSecrets);
|
||||
const portableRuntimeConfig = normalizePortableConfig(agent.runtimeConfig, slug, requiredSecrets);
|
||||
files[agentPath] = buildMarkdown(
|
||||
{
|
||||
kind: "agent",
|
||||
slug,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
adapterType: agent.adapterType,
|
||||
exportedAt: manifest.generatedAt,
|
||||
},
|
||||
instructions.body,
|
||||
);
|
||||
|
||||
manifest.agents.push({
|
||||
slug,
|
||||
name: agent.name,
|
||||
path: agentPath,
|
||||
role: agent.role,
|
||||
title: agent.title ?? null,
|
||||
icon: agent.icon ?? null,
|
||||
capabilities: agent.capabilities ?? null,
|
||||
reportsToSlug: agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null,
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: portableAdapterConfig,
|
||||
runtimeConfig: portableRuntimeConfig,
|
||||
permissions: agent.permissions ?? {},
|
||||
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
|
||||
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets);
|
||||
return {
|
||||
manifest,
|
||||
files,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPreview(input: CompanyPortabilityPreview): Promise<ImportPlanInternal> {
|
||||
const include = normalizeInclude(input.include);
|
||||
const source = await resolveSource(input.source);
|
||||
const manifest = source.manifest;
|
||||
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
||||
const warnings = [...source.warnings];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (include.company && !manifest.company) {
|
||||
errors.push("Manifest does not include company metadata.");
|
||||
}
|
||||
|
||||
const selectedSlugs = input.agents && input.agents !== "all"
|
||||
? Array.from(new Set(input.agents))
|
||||
: manifest.agents.map((agent) => agent.slug);
|
||||
|
||||
const selectedAgents = manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug));
|
||||
const selectedMissing = selectedSlugs.filter((slug) => !manifest.agents.some((agent) => agent.slug === slug));
|
||||
for (const missing of selectedMissing) {
|
||||
errors.push(`Selected agent slug not found in manifest: ${missing}`);
|
||||
}
|
||||
|
||||
if (include.agents && selectedAgents.length === 0) {
|
||||
warnings.push("No agents selected for import.");
|
||||
}
|
||||
|
||||
for (const agent of selectedAgents) {
|
||||
const filePath = ensureMarkdownPath(agent.path);
|
||||
const markdown = source.files[filePath];
|
||||
if (typeof markdown !== "string") {
|
||||
errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
if (parsed.frontmatter.kind !== "agent") {
|
||||
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
|
||||
}
|
||||
}
|
||||
|
||||
let targetCompanyId: string | null = null;
|
||||
let targetCompanyName: string | null = null;
|
||||
|
||||
if (input.target.mode === "existing_company") {
|
||||
const targetCompany = await companies.getById(input.target.companyId);
|
||||
if (!targetCompany) throw notFound("Target company not found");
|
||||
targetCompanyId = targetCompany.id;
|
||||
targetCompanyName = targetCompany.name;
|
||||
}
|
||||
|
||||
const agentPlans: CompanyPortabilityPreviewAgentPlan[] = [];
|
||||
const existingSlugToAgent = new Map<string, { id: string; name: string }>();
|
||||
const existingSlugs = new Set<string>();
|
||||
|
||||
if (input.target.mode === "existing_company") {
|
||||
const existingAgents = await agents.list(input.target.companyId);
|
||||
for (const existing of existingAgents) {
|
||||
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
|
||||
if (!existingSlugToAgent.has(slug)) existingSlugToAgent.set(slug, existing);
|
||||
existingSlugs.add(slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifestAgent of selectedAgents) {
|
||||
const existing = existingSlugToAgent.get(manifestAgent.slug) ?? null;
|
||||
if (!existing) {
|
||||
agentPlans.push({
|
||||
slug: manifestAgent.slug,
|
||||
action: "create",
|
||||
plannedName: manifestAgent.name,
|
||||
existingAgentId: null,
|
||||
reason: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collisionStrategy === "replace") {
|
||||
agentPlans.push({
|
||||
slug: manifestAgent.slug,
|
||||
action: "update",
|
||||
plannedName: existing.name,
|
||||
existingAgentId: existing.id,
|
||||
reason: "Existing slug matched; replace strategy.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collisionStrategy === "skip") {
|
||||
agentPlans.push({
|
||||
slug: manifestAgent.slug,
|
||||
action: "skip",
|
||||
plannedName: existing.name,
|
||||
existingAgentId: existing.id,
|
||||
reason: "Existing slug matched; skip strategy.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const renamed = uniqueNameBySlug(manifestAgent.name, existingSlugs);
|
||||
existingSlugs.add(normalizeAgentUrlKey(renamed) ?? manifestAgent.slug);
|
||||
agentPlans.push({
|
||||
slug: manifestAgent.slug,
|
||||
action: "create",
|
||||
plannedName: renamed,
|
||||
existingAgentId: existing.id,
|
||||
reason: "Existing slug matched; rename strategy.",
|
||||
});
|
||||
}
|
||||
|
||||
const preview: CompanyPortabilityPreviewResult = {
|
||||
include,
|
||||
targetCompanyId,
|
||||
targetCompanyName,
|
||||
collisionStrategy,
|
||||
selectedAgentSlugs: selectedAgents.map((agent) => agent.slug),
|
||||
plan: {
|
||||
companyAction: input.target.mode === "new_company"
|
||||
? "create"
|
||||
: include.company
|
||||
? "update"
|
||||
: "none",
|
||||
agentPlans,
|
||||
},
|
||||
requiredSecrets: manifest.requiredSecrets ?? [],
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
|
||||
return {
|
||||
preview,
|
||||
source,
|
||||
include,
|
||||
collisionStrategy,
|
||||
selectedAgents,
|
||||
};
|
||||
}
|
||||
|
||||
async function previewImport(input: CompanyPortabilityPreview): Promise<CompanyPortabilityPreviewResult> {
|
||||
const plan = await buildPreview(input);
|
||||
return plan.preview;
|
||||
}
|
||||
|
||||
async function importBundle(
|
||||
input: CompanyPortabilityImport,
|
||||
actorUserId: string | null | undefined,
|
||||
): Promise<CompanyPortabilityImportResult> {
|
||||
const plan = await buildPreview(input);
|
||||
if (plan.preview.errors.length > 0) {
|
||||
throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
const sourceManifest = plan.source.manifest;
|
||||
const warnings = [...plan.preview.warnings];
|
||||
const include = plan.include;
|
||||
|
||||
let targetCompany: { id: string; name: string } | null = null;
|
||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||
|
||||
if (input.target.mode === "new_company") {
|
||||
const companyName =
|
||||
asString(input.target.newCompanyName) ??
|
||||
sourceManifest.company?.name ??
|
||||
sourceManifest.source?.companyName ??
|
||||
"Imported Company";
|
||||
const created = await companies.create({
|
||||
name: companyName,
|
||||
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
||||
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
||||
requireBoardApprovalForNewAgents: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
|
||||
: true,
|
||||
});
|
||||
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
|
||||
targetCompany = created;
|
||||
companyAction = "created";
|
||||
} else {
|
||||
targetCompany = await companies.getById(input.target.companyId);
|
||||
if (!targetCompany) throw notFound("Target company not found");
|
||||
if (include.company && sourceManifest.company) {
|
||||
const updated = await companies.update(targetCompany.id, {
|
||||
name: sourceManifest.company.name,
|
||||
description: sourceManifest.company.description,
|
||||
brandColor: sourceManifest.company.brandColor,
|
||||
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
||||
});
|
||||
targetCompany = updated ?? targetCompany;
|
||||
companyAction = "updated";
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetCompany) throw notFound("Target company not found");
|
||||
|
||||
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
||||
const importedSlugToAgentId = new Map<string, string>();
|
||||
const existingSlugToAgentId = new Map<string, string>();
|
||||
const existingAgents = await agents.list(targetCompany.id);
|
||||
for (const existing of existingAgents) {
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
|
||||
}
|
||||
|
||||
if (include.agents) {
|
||||
for (const planAgent of plan.preview.plan.agentPlans) {
|
||||
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
|
||||
if (!manifestAgent) continue;
|
||||
if (planAgent.action === "skip") {
|
||||
resultAgents.push({
|
||||
slug: planAgent.slug,
|
||||
id: planAgent.existingAgentId,
|
||||
action: "skipped",
|
||||
name: planAgent.plannedName,
|
||||
reason: planAgent.reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownRaw = plan.source.files[manifestAgent.path];
|
||||
if (!markdownRaw) {
|
||||
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`);
|
||||
}
|
||||
const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" };
|
||||
const adapterConfig = {
|
||||
...manifestAgent.adapterConfig,
|
||||
promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "",
|
||||
} as Record<string, unknown>;
|
||||
delete adapterConfig.instructionsFilePath;
|
||||
const patch = {
|
||||
name: planAgent.plannedName,
|
||||
role: manifestAgent.role,
|
||||
title: manifestAgent.title,
|
||||
icon: manifestAgent.icon,
|
||||
capabilities: manifestAgent.capabilities,
|
||||
reportsTo: null,
|
||||
adapterType: manifestAgent.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig: manifestAgent.runtimeConfig,
|
||||
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
||||
permissions: manifestAgent.permissions,
|
||||
metadata: manifestAgent.metadata,
|
||||
};
|
||||
|
||||
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
||||
const updated = await agents.update(planAgent.existingAgentId, patch);
|
||||
if (!updated) {
|
||||
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
|
||||
resultAgents.push({
|
||||
slug: planAgent.slug,
|
||||
id: null,
|
||||
action: "skipped",
|
||||
name: planAgent.plannedName,
|
||||
reason: "Existing target agent not found.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
importedSlugToAgentId.set(planAgent.slug, updated.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
|
||||
resultAgents.push({
|
||||
slug: planAgent.slug,
|
||||
id: updated.id,
|
||||
action: "updated",
|
||||
name: updated.name,
|
||||
reason: planAgent.reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await agents.create(targetCompany.id, patch);
|
||||
importedSlugToAgentId.set(planAgent.slug, created.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
||||
resultAgents.push({
|
||||
slug: planAgent.slug,
|
||||
id: created.id,
|
||||
action: "created",
|
||||
name: created.name,
|
||||
reason: planAgent.reason,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply reporting links once all imported agent ids are available.
|
||||
for (const manifestAgent of plan.selectedAgents) {
|
||||
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;
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
company: {
|
||||
id: targetCompany.id,
|
||||
name: targetCompany.name,
|
||||
action: companyAction,
|
||||
},
|
||||
agents: resultAgents,
|
||||
requiredSecrets: sourceManifest.requiredSecrets ?? [],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exportBundle,
|
||||
previewImport,
|
||||
importBundle,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
||||
Reference in New Issue
Block a user