mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Users choose a harness during agent setup. > - Claude, Codex, and OpenCode already receive their qualified Runner default. > - Pi has a qualified native integration but new Pi agents still use the direct adapter. > - This change gives new Pi agents the native default on supported targets. > - Saved agents keep their runner and compatible settings. Legacy remains available in Advanced. ## Linked Issues or Issue Description Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic default work: #15422. This PR is the separate Pi follow-up to the merged OpenCode change. **Current behavior** Choosing Pi for a new agent saves the direct Pi adapter. The native Pi integration remains behind the generic experimental flag. **Proposed behavior** Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64 with supported glibc. Keep unsupported targets and explicit Legacy selections on the direct adapter. Require an explicit provider/model before test or hire. Permit model-free inactive setup drafts. **Reason and benefit** Users choose Pi once and receive its qualified runner. Saved agents retain their identity and settings. Conversions reject unsupported legacy thinking and launch settings instead of dropping them. ## What Changed - Add Pi to the shared platform/default contract and server creation selection. - Admit native Pi without the generic experimental flag. - Preserve provider models, credentials, workspace settings, exact thinking, and saved native policies. - Add Pi runner controls, branding, setup resume, onboarding, built-in configuration, and import handling. - Add managed Pi OpenRouter routing and matching native provider API-key accounts. Keep subscription and custom gateway exclusions. Clear alternate credentials and reject custom Pi provider overrides. - Keep Pi draft models empty instead of applying Claude's default model. - Verify setup against the pinned Pi runtime. For native Pi on the official OpenRouter route, reverify the API key live without requiring a global Legacy Pi CLI. - Match Pi’s bounded controller close window to its native snapshot cleanup and lifetime verification, preserving explicit caller deadlines and the mandatory drain/suspension proof. - Bind Pi sidecars to the package that owns the pinned native assets, including source and vendored server layouts. Other providers retain their existing package authority. - Keep protocol-owned finish/block tools out of the ACPX forwarded catalog. The trusted bridge installs these tools itself; the complete Rust authorization catalog and terminal receipt ledger remain intact. This fixes actual Pi session startup and applies to other ACPX consumers. - Advance Copilot’s shared-source attestation to v58 because it pins the shared files touched by Pi. Preserve immutable v57 bytes and historical decoding; Pi’s own release identity is unchanged. - Add regression coverage and update the paired maps and developer documentation. ## Verification - Passed: full repository typecheck and full production build on final head656d304783, including the generated attestation repair. - Passed: focused shared/server/UI suites covering default selection, platform fallback, conversion preservation, inactive drafts, managed account compatibility, credential isolation, OpenRouter routing/model IDs, exact thinking controls, and saved-account selection. Relevant batches include 375 server/conversion tests, 267 account/routing tests, and 110 UI setup/account tests. - Passed: 179 attestation, historical-profile, qualified-profile, and native-execution contract tests after CI identified stale Copilot source hashes. The identity generator check passes; the only delta after the passing Pi browser run is this Copilot attestation/history repair. - Passed: 145 managed-account tests, including actual service preparation and API-key adoption/rejection for native Pi without the Legacy CLI; 6 pinned-installation probe tests. - Red then green: an actual task failed session.open because its 54-operation catalog included the bridge-owned terminal tools. The regression with those tools failed before the filter; all 399 Rust library tests pass after it. The full product retry exposed an additional package-authority mismatch. Four authority tests failed before that repair; 11 authority checks and 14 installation/asset checks pass afterward. Direct real Pi session initialization with the corrected package root and catalog succeeds. No model prompt was sent by that diagnostic. - Red then green: real Pi posted Hello from Pi but its controller closed before native cleanup could finish. The cleanup-budget test failed with the old default and passes with the bounded Pi window. All 46 selected suspension, drain, and package-authority tests pass after building their native fixture. - Passed: token gates, paired map checks, Rust formatting, and git diff --check. - Passed real browser acceptance on macOS ARM64, generic native runner flag disabled, sourcea433748a12(Pi code unchanged afterward): the UI selects Paperclip Runner by default, retains Pi and the selected model/account, and verifies the pinned runtime plus live OpenRouter key. A fresh UI-created task, assigned to the UI-created native Pi agent, posted exactly `Hello from Pi` and reached Done without retry or restart. The native run and workspace finalization succeeded; the response and Done persisted after Reload. - Evidence: 8 preserved browser attempts, including the final passing 18-step journey with all screenshots. Prior failures remain recorded. The earlier failed checkpoint run recovered on server restart; that recovery is separate from the fresh final task’s clean success. - Preserved browser failures: missing account control/inherited Claude draft model; incompatible managed-account/model-prefix handling; generic npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and wrong provider-package authority at actual native session.open; a real Hello from Pi comment followed by premature checkpoint cleanup timeout. Each failure has retained screenshots/logs and a corresponding focused repair. The fresh final task proves these repairs end to end. - The exact Runner Vitest shard was also run locally after the attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10 skipped, and two Codex retained-control settlement scenarios failed with `native_cleanup_maintenance_unproven` during terminal ACK/suspension replay. Both scenarios passed in an isolated rerun (2/2), and the same 19-test file passed in the preceding Linux CI run. The precise full-shard timing cause is not established. These Codex scenarios are outside the changed Pi paths; the original local failures are retained; no complete local Runner-shard pass is claimed. The corresponding Runner shard now passes on the final head in hosted Linux CI. All 60 final-head check runs are now terminal and clean (55 successful, 1 neutral, 4 skipped), and the legacy Snyk status is successful. - Local full repository tests were attempted but not completed: provisioning briefly confused a workspace symlink, then a later run was stopped before browser-driven repairs. No full local-suite pass is claimed. Final-head hosted CI is green and fresh Greptile review is 5/5 with all review threads resolved; earlier jobs canceled by runner shutdown are not counted as a pass. ## Risks - Pi cleanup has a finite 45-second default window, accounting for the native 30-second snapshot cleanup and 5-second lifetime fence. Other providers and explicit caller deadlines retain their prior behavior. - Pi requires explicit operator setup with `paperclipai runtime setup pi` on the execution host. This PR does not add automatic downloads. - Linux ARM64, Windows, musl, and unqualified targets retain Legacy. - Legacy thinking levels minimal, medium, and xhigh cannot be translated exactly. Native max thinking, warm lifecycle, or restricted permissions cannot be dropped during a return to Legacy. - Copilot’s bound profile advances to v58 with exact current launch admission; v57 remains decodable for recovery. No historical identity is rewritten. - Existing agents and immutable run descriptors are not migrated. Cloud Pi availability is unchanged. ## Model Used OpenAI GPT-6 through Codex with reasoning, code editing, command execution, and browser use. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
6527 lines
263 KiB
TypeScript
6527 lines
263 KiB
TypeScript
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
|
import { agentAppearanceSchema, agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
|
|
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import { execFile } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import {
|
|
builtInManagedResources,
|
|
issueRelations,
|
|
principalPermissionGrants,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
CompanyPortabilityAgentManifestEntry,
|
|
CompanyPortabilityBlobManifestEntry,
|
|
CompanyPortabilityEmbeddedAssetManifestEntry,
|
|
CompanyPortabilityCollisionStrategy,
|
|
CompanyPortabilityEnvInput,
|
|
CompanyPortabilityExport,
|
|
CompanyPortabilityFileEntry,
|
|
CompanyPortabilityExportPreviewResult,
|
|
CompanyPortabilityExportResult,
|
|
CompanyPortabilityImport,
|
|
CompanyPortabilityImportResult,
|
|
CompanyPortabilityInclude,
|
|
CompanyPortabilityManifest,
|
|
CompanyPortabilityIssueCommentManifestEntry,
|
|
CompanyPortabilityPreview,
|
|
CompanyPortabilityPreviewAgentPlan,
|
|
CompanyPortabilityPreviewResult,
|
|
CompanyPortabilityProjectManifestEntry,
|
|
CompanyPortabilityProjectWorkspaceManifestEntry,
|
|
CompanyPortabilityIssueRoutineManifestEntry,
|
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
|
CompanyPortabilityIssueDocumentManifestEntry,
|
|
CompanyPortabilityIssueWorkProductManifestEntry,
|
|
CompanyPortabilityIssueMonitorManifestEntry,
|
|
CompanyPortabilityIssueAttachmentManifestEntry,
|
|
CompanyPortabilityIssueManifestEntry,
|
|
CompanyPortabilitySidebarOrder,
|
|
CompanyPortabilitySkillManifestEntry,
|
|
CompanySkill,
|
|
AgentEnvConfig,
|
|
PermissionKey,
|
|
RoutineVariable,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
|
ISSUE_PRIORITIES,
|
|
ISSUE_STATUSES,
|
|
PROJECT_ICON_NAMES,
|
|
PROJECT_STATUSES,
|
|
ROUTINE_CATCH_UP_POLICIES,
|
|
ROUTINE_CONCURRENCY_POLICIES,
|
|
ROUTINE_STATUSES,
|
|
ROUTINE_TRIGGER_KINDS,
|
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
|
deriveProjectUrlKey,
|
|
envConfigSchema,
|
|
issueCommentAuthorTypeSchema,
|
|
issueCommentMetadataSchema,
|
|
issueCommentPresentationSchema,
|
|
normalizeAgentUrlKey,
|
|
PERMISSION_KEYS,
|
|
} from "@paperclipai/shared";
|
|
import { sha256HexOfBytes } from "@paperclipai/shared/portability-hash";
|
|
import {
|
|
readPaperclipSkillSyncPreference,
|
|
writePaperclipSkillSyncPreference,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
|
import { findServerAdapter } from "../adapters/index.js";
|
|
import { formatAttachmentSize, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
|
import { forbidden, notFound, unprocessable } from "../errors.js";
|
|
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { accessService } from "./access.js";
|
|
import { agentService } from "./agents.js";
|
|
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
|
|
import { assetService } from "./assets.js";
|
|
import { isRunnerApiResponseAsset } from "./asset-content-access.js";
|
|
import { generateReadme } from "./company-export-readme.js";
|
|
import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js";
|
|
import { companySkillService } from "./company-skills.js";
|
|
import { companyService } from "./companies.js";
|
|
import { validateCron } from "./cron.js";
|
|
import { documentService } from "./documents.js";
|
|
import { issueService } from "./issues.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { projectService } from "./projects.js";
|
|
import { workProductService } from "./work-products.js";
|
|
import { routineService } from "./routines.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
|
import {
|
|
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
|
|
readCatalogStringList,
|
|
readPortableCatalogProvenance,
|
|
} from "./catalog-provenance.js";
|
|
import { resolvePortableExportAgentSelection } from "./company-portability-agent-selection.js";
|
|
import { normalizePortablePath } from "./portable-path.js";
|
|
import type {
|
|
ImportIssueRow,
|
|
ImportIssueCommentRow,
|
|
ImportIssueDocumentRow,
|
|
ImportIssueWorkProductRow,
|
|
ImportIssueAttachmentRow,
|
|
} from "./import-write-types.js";
|
|
import {
|
|
PaperclipRunnerProviderProfileError,
|
|
resolvePaperclipRunnerProviderProfile,
|
|
validatePaperclipRunnerDotConfig,
|
|
validatePaperclipRunnerMuseConfig,
|
|
} from "./native-runtime/provider-profile.js";
|
|
import { managedAgentProfileService } from "./managed-agent-profiles.js";
|
|
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
|
|
|
|
const EXPORT_READ_CONCURRENCY = 8;
|
|
const EXPORT_ISSUE_READ_CONCURRENCY = 2;
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: readonly T[],
|
|
concurrency: number,
|
|
mapper: (item: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(items.length);
|
|
let nextIndex = 0;
|
|
|
|
async function worker() {
|
|
while (nextIndex < items.length) {
|
|
const index = nextIndex;
|
|
nextIndex += 1;
|
|
results[index] = await mapper(items[index]!);
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
|
|
);
|
|
return results;
|
|
}
|
|
|
|
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
|
|
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
ceo: "Chief Executive", cto: "Technology", cmo: "Marketing",
|
|
cfo: "Finance", coo: "Operations", vp: "VP", manager: "Manager",
|
|
engineer: "Engineer", agent: "Agent",
|
|
};
|
|
const bySlug = new Map(agents.map((a) => [a.slug, a]));
|
|
const childrenOf = new Map<string | null, typeof agents>();
|
|
for (const a of agents) {
|
|
const parent = a.reportsToSlug ?? null;
|
|
const list = childrenOf.get(parent) ?? [];
|
|
list.push(a);
|
|
childrenOf.set(parent, list);
|
|
}
|
|
const build = (parentSlug: string | null): OrgNode[] => {
|
|
const members = childrenOf.get(parentSlug) ?? [];
|
|
return members.map((m) => ({
|
|
id: m.slug,
|
|
name: m.name,
|
|
role: ROLE_LABELS[m.role] ?? m.role,
|
|
status: "active",
|
|
reports: build(m.slug),
|
|
}));
|
|
};
|
|
// Find roots: agents whose reportsToSlug is null or points to a non-existent slug
|
|
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
|
|
const rootSlugs = new Set(roots.map((r) => r.slug));
|
|
// Start from null parent, but also include orphans
|
|
const tree = build(null);
|
|
for (const root of roots) {
|
|
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
|
|
// Orphan root (parent slug doesn't exist)
|
|
tree.push({
|
|
id: root.slug,
|
|
name: root.name,
|
|
role: ROLE_LABELS[root.role] ?? root.role,
|
|
status: "active",
|
|
reports: build(root.slug),
|
|
});
|
|
}
|
|
}
|
|
return tree;
|
|
}
|
|
|
|
const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
|
|
company: true,
|
|
agents: true,
|
|
projects: false,
|
|
issues: false,
|
|
skills: false,
|
|
};
|
|
|
|
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
|
// The bundle shape this build reads and writes. Bundles began declaring
|
|
// their schemaVersion in the .paperclip.yaml extension at 6; undeclared
|
|
// bundles are read as 5, the last unstamped shape. 7 adds preserved task
|
|
// timestamps and parent links; 5/6 bundles still import, with those fields
|
|
// falling back to import-time defaults.
|
|
const BUNDLE_SCHEMA_VERSION = 7;
|
|
const UNSTAMPED_BUNDLE_SCHEMA_VERSION = 5;
|
|
const DEFAULT_IMPORTED_LABEL_COLOR = "#6366f1";
|
|
// Blob entries are content-addressed by sha256; the store itself is
|
|
// type-agnostic, so blob files always travel as opaque octet streams while
|
|
// each attachment entry carries the real content type.
|
|
const PORTABLE_BLOB_CONTENT_TYPE = "application/octet-stream";
|
|
const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
|
|
const execFileAsync = promisify(execFile);
|
|
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
|
|
|
|
function resolveImportMode(options?: ImportBehaviorOptions): ImportMode {
|
|
return options?.mode ?? "board_full";
|
|
}
|
|
|
|
/**
|
|
* Reject an inline import whose received file set is smaller than the count the
|
|
* client declared. The bundle manifest and per-entry blob hashes seal each
|
|
* file's contents, but nothing else proves the *set* of files is whole: a
|
|
* truncated or proxy-re-framed body can parse into valid JSON with entries
|
|
* silently dropped. `expectedFileCount` is the client's assertion of how many
|
|
* files it sent, so a shortfall means the payload is incomplete and must fail
|
|
* closed instead of importing a fragment. A larger-than-declared set is not a
|
|
* truncation symptom and is left alone; the count is optional, so older callers
|
|
* that omit it are unaffected.
|
|
*/
|
|
function assertInlineSourceComplete(source: CompanyPortabilityImport["source"]) {
|
|
if (source.type !== "inline") return;
|
|
const expected = source.expectedFileCount;
|
|
if (expected == null) return;
|
|
const received = Object.keys(source.files).length;
|
|
if (received < expected) {
|
|
throw unprocessable(
|
|
`Import payload is incomplete: the request declared ${expected} file(s) but only ${received} arrived. `
|
|
+ "The upload was likely truncated; retry the import.",
|
|
{ code: "import_payload_incomplete", expectedFileCount: expected, receivedFileCount: received },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suffix a manifest-derived company name with " (2)", " (3)", … when it
|
|
* collides case-insensitively with an existing company, so repeat imports of
|
|
* the same package do not produce several identically named companies that
|
|
* only differ by issue prefix. Explicit user-typed names bypass this — they
|
|
* are the caller's deliberate choice.
|
|
*/
|
|
export function dedupeImportedCompanyName(baseName: string, existingNames: string[]): string {
|
|
const normalized = new Set(existingNames.map((name) => name.trim().toLowerCase()));
|
|
if (!normalized.has(baseName.trim().toLowerCase())) return baseName;
|
|
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
|
const candidate = `${baseName} (${suffix})`;
|
|
if (!normalized.has(candidate.toLowerCase())) return candidate;
|
|
}
|
|
// Pathological: thousands of identically named companies. Give up on the
|
|
// suffix rather than fail the import — names carry no uniqueness invariant.
|
|
return baseName;
|
|
}
|
|
|
|
function resolveSkillConflictStrategy(mode: ImportMode, collisionStrategy: CompanyPortabilityCollisionStrategy) {
|
|
if (mode === "board_full") return collisionStrategy;
|
|
return collisionStrategy === "skip" ? "skip" as const : "rename" as const;
|
|
}
|
|
|
|
function collectAgentSafeImportPolicyErrors(
|
|
manifest: CompanyPortabilityManifest,
|
|
include: CompanyPortabilityInclude,
|
|
) {
|
|
const errors: string[] = [];
|
|
if (include.projects) {
|
|
for (const project of manifest.projects) {
|
|
if (project.executionWorkspacePolicy !== null) {
|
|
errors.push(`Safe import does not allow project ${project.slug} executionWorkspacePolicy.`);
|
|
}
|
|
for (const workspace of project.workspaces) {
|
|
if (workspace.setupCommand) {
|
|
errors.push(`Safe import does not allow project ${project.slug} workspace ${workspace.key} setupCommand.`);
|
|
}
|
|
if (workspace.cleanupCommand) {
|
|
errors.push(`Safe import does not allow project ${project.slug} workspace ${workspace.key} cleanupCommand.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (include.issues) {
|
|
for (const issue of manifest.issues) {
|
|
if (issue.executionWorkspaceSettings !== null) {
|
|
errors.push(`Safe import does not allow task ${issue.slug} executionWorkspaceSettings.`);
|
|
}
|
|
if (issue.assigneeAdapterOverrides !== null) {
|
|
errors.push(`Safe import does not allow task ${issue.slug} assigneeAdapterOverrides.`);
|
|
}
|
|
const triggers = issue.routine?.triggers ?? [];
|
|
for (const trigger of triggers) {
|
|
if (trigger.kind !== "schedule") {
|
|
errors.push(`Safe import does not allow routine task ${issue.slug} ${trigger.kind} triggers.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function classifyPortableFileKind(pathValue: string): CompanyPortabilityExportPreviewResult["fileInventory"][number]["kind"] {
|
|
const normalized = normalizePortablePath(pathValue);
|
|
if (normalized === "COMPANY.md") return "company";
|
|
if (normalized === ".paperclip.yaml" || normalized === ".paperclip.yml") return "extension";
|
|
if (normalized === "README.md") return "readme";
|
|
if (normalized.startsWith("agents/")) return "agent";
|
|
if (normalized.startsWith("skills/")) return "skill";
|
|
if (normalized.startsWith("projects/")) return "project";
|
|
if (normalized.startsWith("tasks/")) return "issue";
|
|
return "other";
|
|
}
|
|
|
|
function normalizeSkillSlug(value: string | null | undefined) {
|
|
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
|
}
|
|
|
|
function normalizeSkillKey(value: string | null | undefined) {
|
|
if (!value) return null;
|
|
const segments = value
|
|
.split("/")
|
|
.map((segment) => normalizeSkillSlug(segment))
|
|
.filter((segment): segment is string => Boolean(segment));
|
|
return segments.length > 0 ? segments.join("/") : null;
|
|
}
|
|
|
|
function readSkillKey(frontmatter: Record<string, unknown>) {
|
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
|
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
|
|
return normalizeSkillKey(
|
|
asString(frontmatter.key)
|
|
?? asString(frontmatter.skillKey)
|
|
?? asString(metadata?.skillKey)
|
|
?? asString(metadata?.canonicalKey)
|
|
?? asString(metadata?.paperclipSkillKey)
|
|
?? asString(paperclip?.skillKey)
|
|
?? asString(paperclip?.key),
|
|
);
|
|
}
|
|
|
|
function deriveManifestSkillKey(
|
|
frontmatter: Record<string, unknown>,
|
|
fallbackSlug: string,
|
|
metadata: Record<string, unknown> | null,
|
|
sourceType: string,
|
|
sourceLocator: string | null,
|
|
) {
|
|
const explicit = readSkillKey(frontmatter);
|
|
if (explicit) return explicit;
|
|
const slug = normalizeSkillSlug(asString(frontmatter.slug) ?? fallbackSlug) ?? "skill";
|
|
const sourceKind = asString(metadata?.sourceKind);
|
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
|
if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
|
|
return `${owner}/${repo}/${slug}`;
|
|
}
|
|
if (sourceKind === "paperclip_bundled") {
|
|
return `paperclipai/paperclip/${slug}`;
|
|
}
|
|
if (sourceType === "url" || sourceKind === "url") {
|
|
try {
|
|
const host = normalizeSkillSlug(sourceLocator ? new URL(sourceLocator).host : null) ?? "url";
|
|
return `url/${host}/${slug}`;
|
|
} catch {
|
|
return `url/unknown/${slug}`;
|
|
}
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function hashSkillValue(value: string) {
|
|
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
}
|
|
|
|
function normalizeExportPathSegment(value: string | null | undefined, preserveCase = false) {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const normalized = trimmed
|
|
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!normalized) return null;
|
|
return preserveCase ? normalized : normalized.toLowerCase();
|
|
}
|
|
|
|
function readSkillSourceKind(skill: CompanySkill) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
return asString(metadata?.sourceKind);
|
|
}
|
|
|
|
function buildPortableCatalogProvenance(skill: CompanySkill) {
|
|
if (skill.sourceType !== "catalog") return null;
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
const provenance: Record<string, unknown> = {
|
|
skillKey: skill.key,
|
|
};
|
|
|
|
const sourceRef = asString(skill.sourceRef) ?? asString(metadata?.originHash);
|
|
if (sourceRef) provenance.sourceRef = sourceRef;
|
|
|
|
for (const key of PORTABLE_CATALOG_PROVENANCE_STRING_KEYS) {
|
|
if (key === "sourceRef") continue;
|
|
const value = asString(metadata?.[key]);
|
|
if (value) provenance[key] = value;
|
|
}
|
|
|
|
const auditCodes = readCatalogStringList(metadata?.auditCodes);
|
|
if (auditCodes) provenance.auditCodes = auditCodes;
|
|
|
|
return Object.keys(provenance).length > 1 ? provenance : null;
|
|
}
|
|
|
|
function deriveLocalExportNamespace(skill: CompanySkill, slug: string) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
const candidates = [
|
|
asString(metadata?.projectName),
|
|
asString(metadata?.workspaceName),
|
|
];
|
|
|
|
if (skill.sourceLocator) {
|
|
const basename = path.basename(skill.sourceLocator);
|
|
candidates.push(basename.toLowerCase() === "skill.md" ? path.basename(path.dirname(skill.sourceLocator)) : basename);
|
|
}
|
|
|
|
for (const value of candidates) {
|
|
const normalized = normalizeSkillSlug(value);
|
|
if (normalized && normalized !== slug) return normalized;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function derivePrimarySkillExportDir(
|
|
skill: CompanySkill,
|
|
slug: string,
|
|
companyIssuePrefix: string | null | undefined,
|
|
) {
|
|
const normalizedKey = normalizeSkillKey(skill.key);
|
|
const keySegments = normalizedKey?.split("/") ?? [];
|
|
const primaryNamespace = keySegments[0] ?? null;
|
|
|
|
if (primaryNamespace === "company") {
|
|
const companySegment = normalizeExportPathSegment(companyIssuePrefix, true)
|
|
?? normalizeExportPathSegment(keySegments[1], true)
|
|
?? "company";
|
|
return `skills/company/${companySegment}/${slug}`;
|
|
}
|
|
|
|
if (primaryNamespace === "local") {
|
|
const localNamespace = deriveLocalExportNamespace(skill, slug);
|
|
return localNamespace
|
|
? `skills/local/${localNamespace}/${slug}`
|
|
: `skills/local/${slug}`;
|
|
}
|
|
|
|
if (primaryNamespace === "url") {
|
|
let derivedHost: string | null = keySegments[1] ?? null;
|
|
if (!derivedHost) {
|
|
try {
|
|
derivedHost = normalizeSkillSlug(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
|
} catch {
|
|
derivedHost = null;
|
|
}
|
|
}
|
|
const host = derivedHost ?? "url";
|
|
return `skills/url/${host}/${slug}`;
|
|
}
|
|
|
|
if (keySegments.length > 1) {
|
|
return `skills/${keySegments.join("/")}`;
|
|
}
|
|
|
|
return `skills/${slug}`;
|
|
}
|
|
|
|
function appendSkillExportDirSuffix(packageDir: string, suffix: string) {
|
|
const lastSeparator = packageDir.lastIndexOf("/");
|
|
if (lastSeparator < 0) return `${packageDir}--${suffix}`;
|
|
return `${packageDir.slice(0, lastSeparator + 1)}${packageDir.slice(lastSeparator + 1)}--${suffix}`;
|
|
}
|
|
|
|
function deriveSkillExportDirCandidates(
|
|
skill: CompanySkill,
|
|
slug: string,
|
|
companyIssuePrefix: string | null | undefined,
|
|
) {
|
|
const primaryDir = derivePrimarySkillExportDir(skill, slug, companyIssuePrefix);
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
const sourceKind = readSkillSourceKind(skill);
|
|
const suffixes = new Set<string>();
|
|
const pushSuffix = (value: string | null | undefined, preserveCase = false) => {
|
|
const normalized = normalizeExportPathSegment(value, preserveCase);
|
|
if (normalized && normalized !== slug) {
|
|
suffixes.add(normalized);
|
|
}
|
|
};
|
|
|
|
if (sourceKind === "paperclip_bundled") {
|
|
pushSuffix("paperclip");
|
|
}
|
|
|
|
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
|
pushSuffix(asString(metadata?.repo));
|
|
pushSuffix(asString(metadata?.owner));
|
|
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github");
|
|
} else if (skill.sourceType === "url") {
|
|
try {
|
|
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
|
} catch {
|
|
// Ignore URL parse failures and fall through to generic suffixes.
|
|
}
|
|
pushSuffix("url");
|
|
} else if (skill.sourceType === "local_path") {
|
|
pushSuffix(asString(metadata?.projectName));
|
|
pushSuffix(asString(metadata?.workspaceName));
|
|
pushSuffix(deriveLocalExportNamespace(skill, slug));
|
|
if (sourceKind === "managed_local") pushSuffix("company");
|
|
if (sourceKind === "project_scan") pushSuffix("project");
|
|
pushSuffix("local");
|
|
} else {
|
|
pushSuffix(sourceKind);
|
|
pushSuffix("skill");
|
|
}
|
|
|
|
return [primaryDir, ...Array.from(suffixes, (suffix) => appendSkillExportDirSuffix(primaryDir, suffix))];
|
|
}
|
|
|
|
function buildSkillExportDirMap(skills: CompanySkill[], companyIssuePrefix: string | null | undefined) {
|
|
const usedDirs = new Set<string>();
|
|
const keyToDir = new Map<string, string>();
|
|
const orderedSkills = [...skills].sort((left, right) => left.key.localeCompare(right.key));
|
|
for (const skill of orderedSkills) {
|
|
const slug = normalizeSkillSlug(skill.slug) ?? "skill";
|
|
const candidates = deriveSkillExportDirCandidates(skill, slug, companyIssuePrefix);
|
|
|
|
let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null;
|
|
if (!packageDir) {
|
|
packageDir = appendSkillExportDirSuffix(candidates[0] ?? `skills/${slug}`, hashSkillValue(skill.key));
|
|
while (usedDirs.has(packageDir)) {
|
|
packageDir = appendSkillExportDirSuffix(
|
|
candidates[0] ?? `skills/${slug}`,
|
|
hashSkillValue(`${skill.key}:${packageDir}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
usedDirs.add(packageDir);
|
|
keyToDir.set(skill.key, packageDir);
|
|
}
|
|
|
|
return keyToDir;
|
|
}
|
|
|
|
function isSensitiveEnvKey(key: string) {
|
|
const normalized = key.trim().toLowerCase();
|
|
return (
|
|
normalized === "key" ||
|
|
normalized.endsWith("_key") ||
|
|
normalized.endsWith("-key") ||
|
|
normalized === "token" ||
|
|
normalized.endsWith("_token") ||
|
|
normalized.endsWith("-token") ||
|
|
normalized.includes("apikey") ||
|
|
normalized.includes("api_key") ||
|
|
normalized.includes("api-key") ||
|
|
normalized.includes("access_token") ||
|
|
normalized.includes("access-token") ||
|
|
normalized.includes("auth") ||
|
|
normalized.includes("auth_token") ||
|
|
normalized.includes("auth-token") ||
|
|
normalized.includes("authorization") ||
|
|
normalized.includes("bearer") ||
|
|
normalized.includes("secret") ||
|
|
normalized.includes("passwd") ||
|
|
normalized.includes("password") ||
|
|
normalized.includes("credential") ||
|
|
normalized.includes("jwt") ||
|
|
normalized.includes("privatekey") ||
|
|
normalized.includes("private_key") ||
|
|
normalized.includes("private-key") ||
|
|
normalized.includes("cookie") ||
|
|
normalized.includes("connectionstring")
|
|
);
|
|
}
|
|
|
|
function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
|
const parsed = envConfigSchema.safeParse(value);
|
|
return parsed.success ? parsed.data : null;
|
|
}
|
|
|
|
function normalizeProjectIconName(value: string | null | undefined): string | null {
|
|
return value && PROJECT_ICON_NAMES.includes(value as typeof PROJECT_ICON_NAMES[number]) ? value : null;
|
|
}
|
|
|
|
function extractPortableScopedEnvInputs(
|
|
scope: {
|
|
label: string;
|
|
warningPrefix: string;
|
|
agentSlug: string | null;
|
|
projectSlug: string | null;
|
|
},
|
|
envValue: unknown,
|
|
warnings: string[],
|
|
): CompanyPortabilityEnvInput[] {
|
|
if (!isPlainRecord(envValue)) return [];
|
|
const env = envValue as Record<string, unknown>;
|
|
const inputs: CompanyPortabilityEnvInput[] = [];
|
|
|
|
for (const [key, binding] of Object.entries(env)) {
|
|
if (key.toUpperCase() === "PATH") {
|
|
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
|
|
continue;
|
|
}
|
|
|
|
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
|
inputs.push({
|
|
key,
|
|
description: `Provide ${key} for ${scope.label}`,
|
|
agentSlug: scope.agentSlug,
|
|
projectSlug: scope.projectSlug,
|
|
kind: "secret",
|
|
requirement: "optional",
|
|
defaultValue: "",
|
|
portability: "portable",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isPlainRecord(binding) && binding.type === "plain") {
|
|
const defaultValue = asString(binding.value);
|
|
const isSensitive = isSensitiveEnvKey(key);
|
|
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
|
? "system_dependent"
|
|
: "portable";
|
|
if (portability === "system_dependent") {
|
|
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
|
}
|
|
inputs.push({
|
|
key,
|
|
description: `Optional default for ${key} on ${scope.label}`,
|
|
agentSlug: scope.agentSlug,
|
|
projectSlug: scope.projectSlug,
|
|
kind: isSensitive ? "secret" : "plain",
|
|
requirement: "optional",
|
|
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
|
portability,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (typeof binding === "string") {
|
|
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
|
if (portability === "system_dependent") {
|
|
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
|
}
|
|
inputs.push({
|
|
key,
|
|
description: `Optional default for ${key} on ${scope.label}`,
|
|
agentSlug: scope.agentSlug,
|
|
projectSlug: scope.projectSlug,
|
|
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
|
requirement: "optional",
|
|
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
|
|
portability,
|
|
});
|
|
}
|
|
}
|
|
|
|
return inputs;
|
|
}
|
|
|
|
type ResolvedSource = {
|
|
manifest: CompanyPortabilityManifest;
|
|
files: Record<string, CompanyPortabilityFileEntry>;
|
|
warnings: string[];
|
|
};
|
|
|
|
type MarkdownDoc = {
|
|
frontmatter: Record<string, unknown>;
|
|
body: string;
|
|
};
|
|
|
|
type CompanyPackageIncludeEntry = {
|
|
path: string;
|
|
};
|
|
|
|
type PaperclipExtensionDoc = {
|
|
schema?: string;
|
|
company?: Record<string, unknown> | null;
|
|
agents?: Record<string, Record<string, unknown>> | null;
|
|
projects?: Record<string, Record<string, unknown>> | null;
|
|
tasks?: Record<string, Record<string, unknown>> | null;
|
|
routines?: Record<string, Record<string, unknown>> | null;
|
|
};
|
|
|
|
type ProjectLike = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
leadAgentId: string | null;
|
|
targetDate: string | null;
|
|
color: string | null;
|
|
icon: string | null;
|
|
appearance?: import("@paperclipai/shared").AgentAppearance | null;
|
|
status: string;
|
|
env: Record<string, unknown> | null;
|
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
|
workspaces?: Array<{
|
|
id: string;
|
|
name: string;
|
|
sourceType: string;
|
|
cwd: string | null;
|
|
repoUrl: string | null;
|
|
repoRef: string | null;
|
|
defaultRef: string | null;
|
|
visibility: string;
|
|
setupCommand: string | null;
|
|
cleanupCommand: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
isPrimary: boolean;
|
|
}>;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type IssueLike = {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
projectId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
assigneeAgentId: string | null;
|
|
status: string;
|
|
priority: string;
|
|
labelIds?: string[];
|
|
billingCode: string | null;
|
|
executionWorkspaceSettings: Record<string, unknown> | null;
|
|
assigneeAdapterOverrides: Record<string, unknown> | null;
|
|
};
|
|
|
|
type RoutineLike = NonNullable<Awaited<ReturnType<ReturnType<typeof routineService>["getDetail"]>>>;
|
|
|
|
type ImportPlanInternal = {
|
|
preview: CompanyPortabilityPreviewResult;
|
|
source: ResolvedSource;
|
|
include: CompanyPortabilityInclude;
|
|
collisionStrategy: CompanyPortabilityCollisionStrategy;
|
|
selectedAgents: CompanyPortabilityAgentManifestEntry[];
|
|
};
|
|
|
|
type ImportMode = "board_full" | "agent_safe";
|
|
|
|
type ImportBehaviorOptions = {
|
|
mode?: ImportMode;
|
|
sourceCompanyId?: string | null;
|
|
pauseAutomations?: boolean;
|
|
};
|
|
|
|
type AgentLike = {
|
|
id: string;
|
|
name: string;
|
|
adapterConfig: Record<string, unknown>;
|
|
};
|
|
|
|
type EnvInputRecord = {
|
|
kind: "secret" | "plain";
|
|
requirement: "required" | "optional";
|
|
default?: string | null;
|
|
description?: string | null;
|
|
portability?: "portable" | "system_dependent";
|
|
};
|
|
|
|
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
|
"image/gif": ".gif",
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/svg+xml": ".svg",
|
|
"image/webp": ".webp",
|
|
};
|
|
|
|
const COMPANY_LOGO_FILE_NAME = "company-logo";
|
|
|
|
const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [
|
|
{ path: ["heartbeat", "cooldownSec"], value: 10 },
|
|
{ path: ["heartbeat", "intervalSec"], value: 3600 },
|
|
{ path: ["heartbeat", "wakeOnOnDemand"], value: true },
|
|
{ path: ["heartbeat", "wakeOnAssignment"], value: true },
|
|
{ path: ["heartbeat", "wakeOnAutomation"], value: true },
|
|
{ path: ["heartbeat", "wakeOnDemand"], value: true },
|
|
{ path: ["heartbeat", "maxConcurrentRuns"], value: AGENT_DEFAULT_MAX_CONCURRENT_RUNS },
|
|
];
|
|
|
|
const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; value: unknown }>> = {
|
|
codex_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
gemini_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
kimi_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
opencode_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
cursor: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
claude_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
{ path: ["maxTurnsPerRun"], value: 1000 },
|
|
],
|
|
openclaw_gateway: [
|
|
{ path: ["timeoutSec"], value: 120 },
|
|
{ path: ["waitTimeoutMs"], value: 120000 },
|
|
{ path: ["sessionKeyStrategy"], value: "fixed" },
|
|
{ path: ["sessionKey"], value: "paperclip" },
|
|
{ path: ["role"], value: "operator" },
|
|
{ path: ["scopes"], value: ["operator.admin"] },
|
|
],
|
|
};
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function asString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function asBoolean(value: unknown): boolean | null {
|
|
return typeof value === "boolean" ? value : null;
|
|
}
|
|
|
|
type PortableAgentPermissionGrant = CompanyPortabilityAgentManifestEntry["permissionGrants"][number];
|
|
|
|
const VALID_PERMISSION_KEYS = new Set<PermissionKey>(PERMISSION_KEYS);
|
|
|
|
function normalizePortablePermissionGrants(value: unknown): PortableAgentPermissionGrant[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.flatMap((entry): PortableAgentPermissionGrant[] => {
|
|
if (!isPlainRecord(entry)) return [];
|
|
const permissionKey = asString(entry.permissionKey);
|
|
if (!permissionKey || !VALID_PERMISSION_KEYS.has(permissionKey as PermissionKey)) return [];
|
|
return [{
|
|
permissionKey: permissionKey as PermissionKey,
|
|
scope: isPlainRecord(entry.scope) ? entry.scope : null,
|
|
}];
|
|
});
|
|
}
|
|
|
|
function asInteger(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
}
|
|
|
|
function hasOwn(record: Record<string, unknown>, key: string) {
|
|
return Object.prototype.hasOwnProperty.call(record, key);
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const entries = value.filter((entry): entry is string => typeof entry === "string");
|
|
return entries.length === value.length ? entries : null;
|
|
}
|
|
|
|
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
|
|
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
|
|
if (explicit.success) return explicit.data;
|
|
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
|
|
}
|
|
|
|
function readPortableIssueComments(
|
|
value: unknown,
|
|
warnings: string[],
|
|
sourceLabel: string,
|
|
): CompanyPortabilityIssueCommentManifestEntry[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) {
|
|
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
|
|
return [];
|
|
}
|
|
|
|
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
|
|
for (const [index, entry] of value.entries()) {
|
|
if (!isPlainRecord(entry)) {
|
|
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
|
|
continue;
|
|
}
|
|
const body = asString(entry.body);
|
|
if (!body) {
|
|
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
|
|
continue;
|
|
}
|
|
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
|
|
if (presentation && !presentation.success) {
|
|
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
|
|
continue;
|
|
}
|
|
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
|
|
if (metadata && !metadata.success) {
|
|
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
|
|
continue;
|
|
}
|
|
const createdAt = asString(entry.createdAt);
|
|
comments.push({
|
|
body,
|
|
authorType: derivePortableCommentAuthorType(entry),
|
|
authorAgentSlug: asString(entry.authorAgentSlug),
|
|
authorUserId: asString(entry.authorUserId),
|
|
presentation: presentation ? presentation.data : null,
|
|
metadata: metadata ? metadata.data : null,
|
|
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
|
|
});
|
|
}
|
|
return comments;
|
|
}
|
|
|
|
function normalizePortableLabelDefinitions(value: unknown): Array<{ name: string; color: string }> {
|
|
const entries: Array<{ name: string; color: string }> = [];
|
|
const seen = new Set<string>();
|
|
const append = (nameValue: unknown, colorValue: unknown) => {
|
|
const name = asString(nameValue);
|
|
if (!name || seen.has(name)) return;
|
|
seen.add(name);
|
|
entries.push({ name, color: asString(colorValue) ?? DEFAULT_IMPORTED_LABEL_COLOR });
|
|
};
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
append(entry.name, entry.color);
|
|
}
|
|
} else if (isPlainRecord(value)) {
|
|
// Tolerate a name -> { color } (or name -> color) map from hand-edited bundles.
|
|
for (const [name, entry] of Object.entries(value)) {
|
|
append(name, isPlainRecord(entry) ? entry.color : entry);
|
|
}
|
|
}
|
|
return entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
|
|
function readPortableIssueLabelNames(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const names: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
const name = asString(entry);
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
names.push(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Read a preserved issue timestamp from the extension, validated the same way
|
|
* comment createdAt is (Date.parse). Invalid values are ignored with a
|
|
* warning — never a hard failure — so a hand-edited bundle still imports.
|
|
*/
|
|
function readPortableIssueTimestamp(
|
|
value: unknown,
|
|
warnings: string[],
|
|
sourceLabel: string,
|
|
fieldLabel: string,
|
|
): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
const raw = asString(value);
|
|
if (raw && !Number.isNaN(Date.parse(raw))) return raw;
|
|
warnings.push(`${sourceLabel} ${fieldLabel} was ignored because it is not a valid timestamp.`);
|
|
return null;
|
|
}
|
|
|
|
/** Render a stored timestamp as a portable ISO string, or omit it when unset/invalid. */
|
|
function toPortableTimestamp(value: Date | string | null | undefined): string | undefined {
|
|
if (value == null) return undefined;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
}
|
|
|
|
/** Convert a manifest timestamp (already validated at parse time) to a Date, or null. */
|
|
function portableManifestDate(value: string | null | undefined): Date | null {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function readPortableIssueBlockedBy(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const slugs: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
const slug = asString(entry);
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
slugs.push(slug);
|
|
}
|
|
return slugs;
|
|
}
|
|
|
|
function normalizePortableIssueDocuments(
|
|
value: unknown,
|
|
warnings: string[],
|
|
sourceLabel: string,
|
|
): CompanyPortabilityIssueDocumentManifestEntry[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) {
|
|
warnings.push(`${sourceLabel} documents were ignored because they are not an array.`);
|
|
return [];
|
|
}
|
|
const documents: CompanyPortabilityIssueDocumentManifestEntry[] = [];
|
|
const seenKeys = new Set<string>();
|
|
for (const [index, entry] of value.entries()) {
|
|
if (!isPlainRecord(entry)) {
|
|
warnings.push(`${sourceLabel} document ${index + 1} was ignored because it is not an object.`);
|
|
continue;
|
|
}
|
|
const key = asString(entry.key);
|
|
const documentPath = asString(entry.path);
|
|
if (!key || !documentPath) {
|
|
warnings.push(`${sourceLabel} document ${index + 1} was ignored because it is missing a key or path.`);
|
|
continue;
|
|
}
|
|
if (seenKeys.has(key)) continue;
|
|
seenKeys.add(key);
|
|
documents.push({
|
|
key,
|
|
title: asString(entry.title),
|
|
format: asString(entry.format) ?? "markdown",
|
|
path: normalizePortablePath(documentPath),
|
|
});
|
|
}
|
|
return documents;
|
|
}
|
|
|
|
function normalizePortableIssueWorkProducts(value: unknown): CompanyPortabilityIssueWorkProductManifestEntry[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const workProducts: CompanyPortabilityIssueWorkProductManifestEntry[] = [];
|
|
for (const entry of value) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
const type = asString(entry.type);
|
|
const provider = asString(entry.provider);
|
|
const title = asString(entry.title);
|
|
if (!type || !provider || !title) continue;
|
|
workProducts.push({
|
|
type,
|
|
provider,
|
|
externalId: asString(entry.externalId),
|
|
title,
|
|
url: asString(entry.url),
|
|
status: asString(entry.status) ?? "active",
|
|
reviewState: asString(entry.reviewState) ?? "none",
|
|
isPrimary: asBoolean(entry.isPrimary) ?? false,
|
|
healthStatus: asString(entry.healthStatus) ?? "unknown",
|
|
summary: asString(entry.summary),
|
|
metadata: isPlainRecord(entry.metadata) ? entry.metadata : null,
|
|
});
|
|
}
|
|
return workProducts;
|
|
}
|
|
|
|
function normalizePortableIssueMonitor(value: unknown): CompanyPortabilityIssueMonitorManifestEntry | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const monitor = {
|
|
notes: asString(value.notes),
|
|
scheduledBy: asString(value.scheduledBy),
|
|
hadSchedule: asBoolean(value.hadSchedule) ?? false,
|
|
};
|
|
return monitor.notes !== null || monitor.scheduledBy !== null || monitor.hadSchedule ? monitor : null;
|
|
}
|
|
|
|
function portableBlobPath(sha256: string) {
|
|
return `blobs/${sha256}`;
|
|
}
|
|
|
|
// Markdown can embed company asset images by their serving URL. The uuid is
|
|
// the asset row id; export ships the referenced bytes as blobs and import
|
|
// rewrites each reference to the asset id it minted.
|
|
const EMBEDDED_ASSET_URL_PATTERN = /\/api\/assets\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/content/gi;
|
|
|
|
function collectEmbeddedAssetIds(text: string): string[] {
|
|
const ids: string[] = [];
|
|
for (const match of text.matchAll(EMBEDDED_ASSET_URL_PATTERN)) {
|
|
ids.push(match[1]!.toLowerCase());
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
export function rewriteEmbeddedAssetUrls(text: string, assetIdMap: Map<string, string>): string {
|
|
if (assetIdMap.size === 0) return text;
|
|
return text.replace(EMBEDDED_ASSET_URL_PATTERN, (match, assetId: string) => {
|
|
const mapped = assetIdMap.get(assetId.toLowerCase());
|
|
return mapped ? `/api/assets/${mapped}/content` : match;
|
|
});
|
|
}
|
|
|
|
function normalizePortableEmbeddedAssets(value: unknown): CompanyPortabilityEmbeddedAssetManifestEntry[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const entries: CompanyPortabilityEmbeddedAssetManifestEntry[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
const assetId = asString(entry.assetId)?.toLowerCase() ?? null;
|
|
const sha256 = asString(entry.sha256)?.toLowerCase() ?? null;
|
|
if (!assetId || !sha256 || seen.has(assetId)) continue;
|
|
seen.add(assetId);
|
|
const ownedBy = Array.isArray(entry.ownedBy)
|
|
? Array.from(new Set(entry.ownedBy.flatMap((owner) => {
|
|
const normalized = asString(owner);
|
|
return normalized ? [normalized] : [];
|
|
})))
|
|
: [];
|
|
entries.push({
|
|
assetId,
|
|
sha256,
|
|
contentType: asString(entry.contentType) ?? PORTABLE_BLOB_CONTENT_TYPE,
|
|
originalFilename: asString(entry.originalFilename),
|
|
ownedBy: ownedBy.length > 0 ? ownedBy : undefined,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function normalizePortableBlobIndex(value: unknown): CompanyPortabilityBlobManifestEntry[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const blobs: CompanyPortabilityBlobManifestEntry[] = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
const sha256 = asString(entry.sha256)?.toLowerCase() ?? null;
|
|
const byteSize = asInteger(entry.byteSize);
|
|
if (!sha256 || seen.has(sha256) || byteSize === null || byteSize < 0) continue;
|
|
seen.add(sha256);
|
|
blobs.push({
|
|
sha256,
|
|
byteSize,
|
|
contentType: asString(entry.contentType) ?? PORTABLE_BLOB_CONTENT_TYPE,
|
|
});
|
|
}
|
|
return blobs;
|
|
}
|
|
|
|
function normalizePortableIssueAttachments(
|
|
value: unknown,
|
|
warnings: string[],
|
|
sourceLabel: string,
|
|
): CompanyPortabilityIssueAttachmentManifestEntry[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) {
|
|
warnings.push(`${sourceLabel} attachments were ignored because they are not an array.`);
|
|
return [];
|
|
}
|
|
const attachments: CompanyPortabilityIssueAttachmentManifestEntry[] = [];
|
|
for (const [index, entry] of value.entries()) {
|
|
if (!isPlainRecord(entry)) {
|
|
warnings.push(`${sourceLabel} attachment ${index + 1} was ignored because it is not an object.`);
|
|
continue;
|
|
}
|
|
const sha256 = asString(entry.sha256)?.toLowerCase() ?? null;
|
|
if (!sha256) {
|
|
warnings.push(`${sourceLabel} attachment ${index + 1} was ignored because it has no sha256.`);
|
|
continue;
|
|
}
|
|
const byteSize = asInteger(entry.byteSize);
|
|
const commentIndex = asInteger(entry.commentIndex);
|
|
attachments.push({
|
|
sha256,
|
|
contentType: asString(entry.contentType) ?? PORTABLE_BLOB_CONTENT_TYPE,
|
|
originalFilename: asString(entry.originalFilename),
|
|
byteSize: byteSize !== null && byteSize >= 0 ? byteSize : 0,
|
|
commentIndex: commentIndex !== null && commentIndex >= 0 ? commentIndex : null,
|
|
});
|
|
}
|
|
return attachments;
|
|
}
|
|
|
|
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
|
|
const extraArgs = readStringArray(adapterConfig.extraArgs);
|
|
if (extraArgs) {
|
|
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
|
|
return;
|
|
}
|
|
|
|
const legacyArgs = readStringArray(adapterConfig.args);
|
|
if (legacyArgs && legacyArgs.length > 0) {
|
|
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
|
|
return;
|
|
}
|
|
|
|
if (legacyArgs?.includes(arg)) return;
|
|
adapterConfig.extraArgs = [arg];
|
|
}
|
|
|
|
function applyImportAdapterRunDefaults(
|
|
adapterType: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
) {
|
|
const next = { ...adapterConfig };
|
|
if (adapterType === "codex_local") {
|
|
appendCodexImportArg(next, "--skip-git-repo-check");
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const kind = asString(value.kind);
|
|
if (!kind) return null;
|
|
return {
|
|
kind,
|
|
label: asString(value.label),
|
|
enabled: asBoolean(value.enabled) ?? true,
|
|
cronExpression: asString(value.cronExpression),
|
|
timezone: asString(value.timezone),
|
|
signingMode: asString(value.signingMode),
|
|
replayWindowSec: asInteger(value.replayWindowSec),
|
|
};
|
|
}
|
|
|
|
function normalizeRoutineVariableExtension(value: unknown): RoutineVariable | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const name = asString(value.name);
|
|
if (!name) return null;
|
|
const type = asString(value.type) ?? "text";
|
|
if (!["text", "textarea", "number", "boolean", "select"].includes(type)) return null;
|
|
const options = Array.isArray(value.options)
|
|
? value.options.map((entry) => asString(entry)).filter((entry): entry is string => Boolean(entry))
|
|
: [];
|
|
const defaultValue =
|
|
typeof value.defaultValue === "string" || typeof value.defaultValue === "number" || typeof value.defaultValue === "boolean"
|
|
? value.defaultValue
|
|
: null;
|
|
return {
|
|
name,
|
|
label: asString(value.label),
|
|
type: type as RoutineVariable["type"],
|
|
defaultValue,
|
|
required: asBoolean(value.required) ?? true,
|
|
options,
|
|
};
|
|
}
|
|
|
|
function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const triggers = Array.isArray(value.triggers)
|
|
? value.triggers
|
|
.map((entry) => normalizeRoutineTriggerExtension(entry))
|
|
.filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null)
|
|
: [];
|
|
const variables = Array.isArray(value.variables)
|
|
? value.variables
|
|
.map((entry) => normalizeRoutineVariableExtension(entry))
|
|
.filter((entry): entry is RoutineVariable => entry !== null)
|
|
: null;
|
|
const routine = {
|
|
concurrencyPolicy: asString(value.concurrencyPolicy),
|
|
catchUpPolicy: asString(value.catchUpPolicy),
|
|
variables,
|
|
triggers,
|
|
};
|
|
return stripEmptyValues(routine) ? routine : null;
|
|
}
|
|
|
|
function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPortabilityIssueRoutineManifestEntry {
|
|
return {
|
|
concurrencyPolicy: routine.concurrencyPolicy,
|
|
catchUpPolicy: routine.catchUpPolicy,
|
|
variables: routine.variables,
|
|
triggers: routine.triggers.map((trigger) => ({
|
|
kind: trigger.kind,
|
|
label: trigger.label ?? null,
|
|
enabled: Boolean(trigger.enabled),
|
|
cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : null,
|
|
timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : null,
|
|
signingMode: trigger.kind === "webhook" ? trigger.signingMode ?? null : null,
|
|
replayWindowSec: trigger.kind === "webhook" ? trigger.replayWindowSec ?? null : null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function containsAbsolutePathFragment(value: string) {
|
|
return /(^|\s)(\/[^/\s]|[A-Za-z]:[\\/])/.test(value);
|
|
}
|
|
|
|
function containsSystemDependentPathValue(value: unknown): boolean {
|
|
if (typeof value === "string") {
|
|
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || containsAbsolutePathFragment(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some((entry) => containsSystemDependentPathValue(entry));
|
|
}
|
|
if (isPlainRecord(value)) {
|
|
return Object.values(value).some((entry) => containsSystemDependentPathValue(entry));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function clonePortableRecord(value: unknown) {
|
|
if (!isPlainRecord(value)) return null;
|
|
return structuredClone(value) as Record<string, unknown>;
|
|
}
|
|
|
|
function parseFiniteNumberLike(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value !== "string") return null;
|
|
const parsed = Number(value.trim());
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function sanitizeImportedAgentRuntimeConfig(runtimeConfig: unknown) {
|
|
const next = clonePortableRecord(runtimeConfig) ?? {};
|
|
delete next.modelProfiles;
|
|
const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {};
|
|
heartbeat.enabled = false;
|
|
if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) {
|
|
heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
|
|
}
|
|
next.heartbeat = heartbeat;
|
|
if (isPlainRecord(next.debug)) {
|
|
const debug = { ...next.debug };
|
|
// Company imports are available below the instance-admin trust boundary.
|
|
// Never let a portable bundle enable persistent capture of raw provider
|
|
// traffic; an administrator can opt in afterward through the guarded
|
|
// agent configuration route.
|
|
delete debug.providerTrace;
|
|
if (Object.keys(debug).length === 0) delete next.debug;
|
|
else next.debug = debug;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function sanitizeImportedIssueAssigneeAdapterOverrides(value: unknown) {
|
|
const next = clonePortableRecord(value);
|
|
if (!next) return null;
|
|
delete next.modelProfile;
|
|
return Object.keys(next).length > 0 ? next : null;
|
|
}
|
|
|
|
function normalizePortableProjectWorkspaceExtension(
|
|
workspaceKey: string,
|
|
value: unknown,
|
|
): CompanyPortabilityProjectWorkspaceManifestEntry | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const normalizedKey = normalizeAgentUrlKey(workspaceKey) ?? workspaceKey.trim();
|
|
if (!normalizedKey) return null;
|
|
return {
|
|
key: normalizedKey,
|
|
name: asString(value.name) ?? normalizedKey,
|
|
sourceType: asString(value.sourceType),
|
|
repoUrl: asString(value.repoUrl),
|
|
repoRef: asString(value.repoRef),
|
|
defaultRef: asString(value.defaultRef),
|
|
visibility: asString(value.visibility),
|
|
setupCommand: asString(value.setupCommand),
|
|
cleanupCommand: asString(value.cleanupCommand),
|
|
metadata: isPlainRecord(value.metadata) ? value.metadata : null,
|
|
isPrimary: asBoolean(value.isPrimary) ?? false,
|
|
};
|
|
}
|
|
|
|
function derivePortableProjectWorkspaceKey(
|
|
workspace: NonNullable<ProjectLike["workspaces"]>[number],
|
|
usedKeys: Set<string>,
|
|
) {
|
|
const baseKey =
|
|
normalizeAgentUrlKey(workspace.name)
|
|
?? normalizeAgentUrlKey(asString(workspace.repoUrl)?.split("/").pop()?.replace(/\.git$/i, "") ?? "")
|
|
?? "workspace";
|
|
return uniqueSlug(baseKey, usedKeys);
|
|
}
|
|
|
|
function exportPortableProjectExecutionWorkspacePolicy(
|
|
projectSlug: string,
|
|
policy: unknown,
|
|
workspaceKeyById: Map<string, string>,
|
|
warnings: string[],
|
|
) {
|
|
const next = clonePortableRecord(policy);
|
|
if (!next) return null;
|
|
const defaultWorkspaceId = asString(next.defaultProjectWorkspaceId);
|
|
if (defaultWorkspaceId) {
|
|
const defaultWorkspaceKey = workspaceKeyById.get(defaultWorkspaceId);
|
|
if (defaultWorkspaceKey) {
|
|
next.defaultProjectWorkspaceKey = defaultWorkspaceKey;
|
|
} else {
|
|
warnings.push(`Project ${projectSlug} default workspace ${defaultWorkspaceId} was omitted from export because that workspace is not portable.`);
|
|
}
|
|
delete next.defaultProjectWorkspaceId;
|
|
}
|
|
const cleaned = stripEmptyValues(next);
|
|
return isPlainRecord(cleaned) ? cleaned : null;
|
|
}
|
|
|
|
function importPortableProjectExecutionWorkspacePolicy(
|
|
projectSlug: string,
|
|
policy: Record<string, unknown> | null | undefined,
|
|
workspaceIdByKey: Map<string, string>,
|
|
warnings: string[],
|
|
) {
|
|
const next = clonePortableRecord(policy);
|
|
if (!next) return null;
|
|
const defaultWorkspaceKey = asString(next.defaultProjectWorkspaceKey);
|
|
if (defaultWorkspaceKey) {
|
|
const defaultWorkspaceId = workspaceIdByKey.get(defaultWorkspaceKey);
|
|
if (defaultWorkspaceId) {
|
|
next.defaultProjectWorkspaceId = defaultWorkspaceId;
|
|
} else {
|
|
warnings.push(`Project ${projectSlug} references missing workspace key ${defaultWorkspaceKey}; imported execution workspace policy without a default workspace.`);
|
|
}
|
|
}
|
|
delete next.defaultProjectWorkspaceKey;
|
|
const cleaned = stripEmptyValues(next);
|
|
return isPlainRecord(cleaned) ? cleaned : null;
|
|
}
|
|
|
|
function stripPortableProjectExecutionWorkspaceRefs(policy: Record<string, unknown> | null | undefined) {
|
|
const next = clonePortableRecord(policy);
|
|
if (!next) return null;
|
|
delete next.defaultProjectWorkspaceId;
|
|
delete next.defaultProjectWorkspaceKey;
|
|
const cleaned = stripEmptyValues(next);
|
|
return isPlainRecord(cleaned) ? cleaned : null;
|
|
}
|
|
|
|
async function readGitOutput(cwd: string, args: string[]) {
|
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
|
const trimmed = stdout.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
async function inferPortableWorkspaceGitMetadata(workspace: NonNullable<ProjectLike["workspaces"]>[number]) {
|
|
const cwd = asString(workspace.cwd);
|
|
if (!cwd) {
|
|
return {
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
defaultRef: null,
|
|
};
|
|
}
|
|
|
|
let repoUrl: string | null = null;
|
|
try {
|
|
repoUrl = await readGitOutput(cwd, ["remote", "get-url", "origin"]);
|
|
} catch {
|
|
try {
|
|
const firstRemote = await readGitOutput(cwd, ["remote"]);
|
|
const remoteName = firstRemote?.split("\n").map((entry) => entry.trim()).find(Boolean) ?? null;
|
|
if (remoteName) {
|
|
repoUrl = await readGitOutput(cwd, ["remote", "get-url", remoteName]);
|
|
}
|
|
} catch {
|
|
repoUrl = null;
|
|
}
|
|
}
|
|
|
|
let repoRef: string | null = null;
|
|
try {
|
|
repoRef = await readGitOutput(cwd, ["branch", "--show-current"]);
|
|
} catch {
|
|
repoRef = null;
|
|
}
|
|
|
|
let defaultRef: string | null = null;
|
|
try {
|
|
const remoteHead = await readGitOutput(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
defaultRef = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead;
|
|
} catch {
|
|
defaultRef = null;
|
|
}
|
|
|
|
return {
|
|
repoUrl,
|
|
repoRef,
|
|
defaultRef,
|
|
};
|
|
}
|
|
|
|
async function buildPortableProjectWorkspaces(
|
|
projectSlug: string,
|
|
workspaces: ProjectLike["workspaces"] | undefined,
|
|
warnings: string[],
|
|
) {
|
|
const exportedWorkspaces: Record<string, Record<string, unknown>> = {};
|
|
const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = [];
|
|
const workspaceKeyById = new Map<string, string>();
|
|
const workspaceKeyBySignature = new Map<string, string>();
|
|
const manifestWorkspaceByKey = new Map<string, CompanyPortabilityProjectWorkspaceManifestEntry>();
|
|
const usedKeys = new Set<string>();
|
|
|
|
for (const workspace of workspaces ?? []) {
|
|
const inferredGitMetadata =
|
|
!asString(workspace.repoUrl) || !asString(workspace.repoRef) || !asString(workspace.defaultRef)
|
|
? await inferPortableWorkspaceGitMetadata(workspace)
|
|
: { repoUrl: null, repoRef: null, defaultRef: null };
|
|
const repoUrl = asString(workspace.repoUrl) ?? inferredGitMetadata.repoUrl;
|
|
if (!repoUrl) {
|
|
warnings.push(`Project ${projectSlug} workspace ${workspace.name} was omitted from export because it does not have a portable repoUrl.`);
|
|
continue;
|
|
}
|
|
const repoRef = asString(workspace.repoRef) ?? inferredGitMetadata.repoRef;
|
|
const defaultRef = asString(workspace.defaultRef) ?? inferredGitMetadata.defaultRef ?? repoRef;
|
|
const workspaceSignature = JSON.stringify({
|
|
name: workspace.name,
|
|
repoUrl,
|
|
repoRef,
|
|
defaultRef,
|
|
});
|
|
const existingWorkspaceKey = workspaceKeyBySignature.get(workspaceSignature);
|
|
if (existingWorkspaceKey) {
|
|
workspaceKeyById.set(workspace.id, existingWorkspaceKey);
|
|
const existingManifestWorkspace = manifestWorkspaceByKey.get(existingWorkspaceKey);
|
|
if (existingManifestWorkspace && workspace.isPrimary) {
|
|
existingManifestWorkspace.isPrimary = true;
|
|
const existingExtensionWorkspace = exportedWorkspaces[existingWorkspaceKey];
|
|
if (isPlainRecord(existingExtensionWorkspace)) existingExtensionWorkspace.isPrimary = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const workspaceKey = derivePortableProjectWorkspaceKey(workspace, usedKeys);
|
|
workspaceKeyById.set(workspace.id, workspaceKey);
|
|
workspaceKeyBySignature.set(workspaceSignature, workspaceKey);
|
|
|
|
let setupCommand = asString(workspace.setupCommand);
|
|
if (setupCommand && containsAbsolutePathFragment(setupCommand)) {
|
|
warnings.push(`Project ${projectSlug} workspace ${workspaceKey} setupCommand was omitted from export because it is system-dependent.`);
|
|
setupCommand = null;
|
|
}
|
|
|
|
let cleanupCommand = asString(workspace.cleanupCommand);
|
|
if (cleanupCommand && containsAbsolutePathFragment(cleanupCommand)) {
|
|
warnings.push(`Project ${projectSlug} workspace ${workspaceKey} cleanupCommand was omitted from export because it is system-dependent.`);
|
|
cleanupCommand = null;
|
|
}
|
|
|
|
const metadata = isPlainRecord(workspace.metadata) && !containsSystemDependentPathValue(workspace.metadata)
|
|
? workspace.metadata
|
|
: null;
|
|
if (isPlainRecord(workspace.metadata) && metadata == null) {
|
|
warnings.push(`Project ${projectSlug} workspace ${workspaceKey} metadata was omitted from export because it contains system-dependent paths.`);
|
|
}
|
|
|
|
const portableWorkspace = stripEmptyValues({
|
|
name: workspace.name,
|
|
sourceType: workspace.sourceType,
|
|
repoUrl,
|
|
repoRef,
|
|
defaultRef,
|
|
visibility: asString(workspace.visibility),
|
|
setupCommand,
|
|
cleanupCommand,
|
|
metadata,
|
|
isPrimary: workspace.isPrimary ? true : undefined,
|
|
});
|
|
if (!isPlainRecord(portableWorkspace)) continue;
|
|
|
|
exportedWorkspaces[workspaceKey] = portableWorkspace;
|
|
const manifestWorkspace = {
|
|
key: workspaceKey,
|
|
name: workspace.name,
|
|
sourceType: asString(workspace.sourceType),
|
|
repoUrl,
|
|
repoRef,
|
|
defaultRef,
|
|
visibility: asString(workspace.visibility),
|
|
setupCommand,
|
|
cleanupCommand,
|
|
metadata,
|
|
isPrimary: workspace.isPrimary,
|
|
};
|
|
manifestWorkspaces.push(manifestWorkspace);
|
|
manifestWorkspaceByKey.set(workspaceKey, manifestWorkspace);
|
|
}
|
|
|
|
return {
|
|
extension: Object.keys(exportedWorkspaces).length > 0 ? exportedWorkspaces : undefined,
|
|
manifest: manifestWorkspaces,
|
|
workspaceKeyById,
|
|
};
|
|
}
|
|
|
|
const WEEKDAY_TO_CRON: Record<string, string> = {
|
|
sunday: "0",
|
|
monday: "1",
|
|
tuesday: "2",
|
|
wednesday: "3",
|
|
thursday: "4",
|
|
friday: "5",
|
|
saturday: "6",
|
|
};
|
|
|
|
function readZonedDateParts(startsAt: string, timeZone: string) {
|
|
try {
|
|
const date = new Date(startsAt);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
timeZone,
|
|
hour12: false,
|
|
weekday: "long",
|
|
month: "numeric",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
const parts = Object.fromEntries(
|
|
formatter
|
|
.formatToParts(date)
|
|
.filter((entry) => entry.type !== "literal")
|
|
.map((entry) => [entry.type, entry.value]),
|
|
) as Record<string, string>;
|
|
const weekday = WEEKDAY_TO_CRON[parts.weekday?.toLowerCase() ?? ""];
|
|
const month = Number(parts.month);
|
|
const day = Number(parts.day);
|
|
const hour = Number(parts.hour);
|
|
const minute = Number(parts.minute);
|
|
if (!weekday || !Number.isFinite(month) || !Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(minute)) {
|
|
return null;
|
|
}
|
|
return { weekday, month, day, hour, minute };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeCronList(values: string[]) {
|
|
return Array.from(new Set(values)).sort((left, right) => Number(left) - Number(right)).join(",");
|
|
}
|
|
|
|
function buildLegacyRoutineTriggerFromRecurrence(
|
|
issue: Pick<CompanyPortabilityIssueManifestEntry, "slug" | "legacyRecurrence">,
|
|
scheduleValue: unknown,
|
|
) {
|
|
const warnings: string[] = [];
|
|
const errors: string[] = [];
|
|
if (!issue.legacyRecurrence || !isPlainRecord(issue.legacyRecurrence)) {
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
|
|
const schedule = isPlainRecord(scheduleValue) ? scheduleValue : null;
|
|
const frequency = asString(issue.legacyRecurrence.frequency);
|
|
const interval = asInteger(issue.legacyRecurrence.interval) ?? 1;
|
|
if (!frequency) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy recurrence without frequency; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
if (interval < 1) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid interval; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
|
|
const timezone = asString(schedule?.timezone) ?? "UTC";
|
|
const startsAt = asString(schedule?.startsAt);
|
|
const zonedStartsAt = startsAt ? readZonedDateParts(startsAt, timezone) : null;
|
|
if (startsAt && !zonedStartsAt) {
|
|
errors.push(`Recurring task ${issue.slug} has an invalid legacy startsAt/timezone combination; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
|
|
const time = isPlainRecord(issue.legacyRecurrence.time) ? issue.legacyRecurrence.time : null;
|
|
const hour = asInteger(time?.hour) ?? zonedStartsAt?.hour ?? 0;
|
|
const minute = asInteger(time?.minute) ?? zonedStartsAt?.minute ?? 0;
|
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid time; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
|
|
if (issue.legacyRecurrence.until != null || issue.legacyRecurrence.count != null) {
|
|
warnings.push(`Recurring task ${issue.slug} uses legacy recurrence end bounds; Paperclip will import the routine trigger without those limits.`);
|
|
}
|
|
|
|
let cronExpression: string | null = null;
|
|
|
|
if (frequency === "hourly") {
|
|
const hourField = interval === 1
|
|
? "*"
|
|
: zonedStartsAt
|
|
? `${zonedStartsAt.hour}-23/${interval}`
|
|
: `*/${interval}`;
|
|
cronExpression = `${minute} ${hourField} * * *`;
|
|
} else if (frequency === "daily") {
|
|
if (Array.isArray(issue.legacyRecurrence.weekdays) || Array.isArray(issue.legacyRecurrence.monthDays) || Array.isArray(issue.legacyRecurrence.months)) {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported legacy daily recurrence constraints; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
const dayField = interval === 1 ? "*" : `*/${interval}`;
|
|
cronExpression = `${minute} ${hour} ${dayField} * *`;
|
|
} else if (frequency === "weekly") {
|
|
if (interval !== 1) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
const weekdays = Array.isArray(issue.legacyRecurrence.weekdays)
|
|
? issue.legacyRecurrence.weekdays
|
|
.map((entry) => asString(entry))
|
|
.filter((entry): entry is string => Boolean(entry))
|
|
: [];
|
|
const cronWeekdays = weekdays
|
|
.map((entry) => WEEKDAY_TO_CRON[entry.toLowerCase()])
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
if (cronWeekdays.length === 0 && zonedStartsAt?.weekday) {
|
|
cronWeekdays.push(zonedStartsAt.weekday);
|
|
}
|
|
if (cronWeekdays.length === 0) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence without weekdays; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
cronExpression = `${minute} ${hour} * * ${normalizeCronList(cronWeekdays)}`;
|
|
} else if (frequency === "monthly") {
|
|
if (interval !== 1) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
if (Array.isArray(issue.legacyRecurrence.ordinalWeekdays) && issue.legacyRecurrence.ordinalWeekdays.length > 0) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy ordinal monthly recurrence; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
const monthDays = Array.isArray(issue.legacyRecurrence.monthDays)
|
|
? issue.legacyRecurrence.monthDays
|
|
.map((entry) => asInteger(entry))
|
|
.filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31)
|
|
: [];
|
|
if (monthDays.length === 0 && zonedStartsAt?.day) {
|
|
monthDays.push(zonedStartsAt.day);
|
|
}
|
|
if (monthDays.length === 0) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence without monthDays; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
const months = Array.isArray(issue.legacyRecurrence.months)
|
|
? issue.legacyRecurrence.months
|
|
.map((entry) => asInteger(entry))
|
|
.filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12)
|
|
: [];
|
|
const monthField = months.length > 0 ? normalizeCronList(months.map(String)) : "*";
|
|
cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${monthField} *`;
|
|
} else if (frequency === "yearly") {
|
|
if (interval !== 1) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
const months = Array.isArray(issue.legacyRecurrence.months)
|
|
? issue.legacyRecurrence.months
|
|
.map((entry) => asInteger(entry))
|
|
.filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12)
|
|
: [];
|
|
if (months.length === 0 && zonedStartsAt?.month) {
|
|
months.push(zonedStartsAt.month);
|
|
}
|
|
const monthDays = Array.isArray(issue.legacyRecurrence.monthDays)
|
|
? issue.legacyRecurrence.monthDays
|
|
.map((entry) => asInteger(entry))
|
|
.filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31)
|
|
: [];
|
|
if (monthDays.length === 0 && zonedStartsAt?.day) {
|
|
monthDays.push(zonedStartsAt.day);
|
|
}
|
|
if (months.length === 0 || monthDays.length === 0) {
|
|
errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence without month/monthDay anchors; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${normalizeCronList(months.map(String))} *`;
|
|
} else {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported legacy recurrence frequency "${frequency}"; add .paperclip.yaml routines.${issue.slug}.triggers.`);
|
|
return { trigger: null, warnings, errors };
|
|
}
|
|
|
|
return {
|
|
trigger: {
|
|
kind: "schedule",
|
|
label: "Migrated legacy recurrence",
|
|
enabled: true,
|
|
cronExpression,
|
|
timezone,
|
|
signingMode: null,
|
|
replayWindowSec: null,
|
|
} satisfies CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
|
warnings,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function resolvePortableRoutineDefinition(
|
|
issue: Pick<CompanyPortabilityIssueManifestEntry, "slug" | "recurring" | "routine" | "legacyRecurrence">,
|
|
scheduleValue: unknown,
|
|
) {
|
|
const warnings: string[] = [];
|
|
const errors: string[] = [];
|
|
if (!issue.recurring) {
|
|
return { routine: null, warnings, errors };
|
|
}
|
|
|
|
const routine = issue.routine
|
|
? {
|
|
concurrencyPolicy: issue.routine.concurrencyPolicy,
|
|
catchUpPolicy: issue.routine.catchUpPolicy,
|
|
variables: issue.routine.variables ?? null,
|
|
triggers: [...issue.routine.triggers],
|
|
}
|
|
: {
|
|
concurrencyPolicy: null,
|
|
catchUpPolicy: null,
|
|
variables: null,
|
|
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
|
|
};
|
|
|
|
if (routine.concurrencyPolicy && !ROUTINE_CONCURRENCY_POLICIES.includes(routine.concurrencyPolicy as any)) {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported routine concurrencyPolicy "${routine.concurrencyPolicy}".`);
|
|
}
|
|
if (routine.catchUpPolicy && !ROUTINE_CATCH_UP_POLICIES.includes(routine.catchUpPolicy as any)) {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported routine catchUpPolicy "${routine.catchUpPolicy}".`);
|
|
}
|
|
|
|
for (const trigger of routine.triggers) {
|
|
if (!ROUTINE_TRIGGER_KINDS.includes(trigger.kind as any)) {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported trigger kind "${trigger.kind}".`);
|
|
continue;
|
|
}
|
|
if (trigger.kind === "schedule") {
|
|
if (!trigger.cronExpression || !trigger.timezone) {
|
|
errors.push(`Recurring task ${issue.slug} has a schedule trigger missing cronExpression/timezone.`);
|
|
continue;
|
|
}
|
|
const cronError = validateCron(trigger.cronExpression);
|
|
if (cronError) {
|
|
errors.push(`Recurring task ${issue.slug} has an invalid schedule trigger: ${cronError}`);
|
|
}
|
|
continue;
|
|
}
|
|
if (trigger.kind === "webhook" && trigger.signingMode && !ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any)) {
|
|
errors.push(`Recurring task ${issue.slug} uses unsupported webhook signingMode "${trigger.signingMode}".`);
|
|
}
|
|
}
|
|
|
|
if (routine.triggers.length === 0 && issue.legacyRecurrence) {
|
|
const migrated = buildLegacyRoutineTriggerFromRecurrence(issue, scheduleValue);
|
|
warnings.push(...migrated.warnings);
|
|
errors.push(...migrated.errors);
|
|
if (migrated.trigger) {
|
|
routine.triggers.push(migrated.trigger);
|
|
}
|
|
}
|
|
|
|
return { routine, warnings, errors };
|
|
}
|
|
|
|
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 uniqueProjectName(baseName: string, existingProjectSlugs: Set<string>) {
|
|
const baseSlug = deriveProjectUrlKey(baseName, baseName);
|
|
if (!existingProjectSlugs.has(baseSlug)) return baseName;
|
|
let idx = 2;
|
|
while (true) {
|
|
const candidateName = `${baseName} ${idx}`;
|
|
const candidateSlug = deriveProjectUrlKey(candidateName, candidateName);
|
|
if (!existingProjectSlugs.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,
|
|
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
|
|
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
|
|
skills: input?.skills ?? DEFAULT_INCLUDE.skills,
|
|
};
|
|
}
|
|
|
|
function resolvePortablePath(fromPath: string, targetPath: string) {
|
|
const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/"));
|
|
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
|
|
}
|
|
|
|
function isPortableBinaryFile(
|
|
value: CompanyPortabilityFileEntry,
|
|
): value is Extract<CompanyPortabilityFileEntry, { encoding: "base64" }> {
|
|
return typeof value === "object" && value !== null && value.encoding === "base64" && typeof value.data === "string";
|
|
}
|
|
|
|
function readPortableTextFile(
|
|
files: Record<string, CompanyPortabilityFileEntry>,
|
|
filePath: string,
|
|
) {
|
|
const value = files[filePath];
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function inferContentTypeFromPath(filePath: string) {
|
|
const extension = path.posix.extname(filePath).toLowerCase();
|
|
switch (extension) {
|
|
case ".gif":
|
|
return "image/gif";
|
|
case ".jpeg":
|
|
case ".jpg":
|
|
return "image/jpeg";
|
|
case ".png":
|
|
return "image/png";
|
|
case ".svg":
|
|
return "image/svg+xml";
|
|
case ".webp":
|
|
return "image/webp";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveCompanyLogoExtension(contentType: string | null | undefined, originalFilename: string | null | undefined) {
|
|
const fromContentType = contentType ? COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType.toLowerCase()] : null;
|
|
if (fromContentType) return fromContentType;
|
|
|
|
const extension = originalFilename ? path.extname(originalFilename).toLowerCase() : "";
|
|
return extension || ".png";
|
|
}
|
|
|
|
function portableBinaryFileToBuffer(entry: Extract<CompanyPortabilityFileEntry, { encoding: "base64" }>) {
|
|
return Buffer.from(entry.data, "base64");
|
|
}
|
|
|
|
function portableFileToBuffer(entry: CompanyPortabilityFileEntry, filePath: string) {
|
|
if (typeof entry === "string") {
|
|
return Buffer.from(entry, "utf8");
|
|
}
|
|
if (isPortableBinaryFile(entry)) {
|
|
return portableBinaryFileToBuffer(entry);
|
|
}
|
|
throw unprocessable(`Unsupported file entry encoding for ${filePath}`);
|
|
}
|
|
|
|
function bufferToPortableBinaryFile(buffer: Buffer, contentType: string | null): CompanyPortabilityFileEntry {
|
|
return {
|
|
encoding: "base64",
|
|
data: buffer.toString("base64"),
|
|
contentType,
|
|
};
|
|
}
|
|
|
|
async function streamToBuffer(stream: NodeJS.ReadableStream) {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of stream) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function normalizeFileMap(
|
|
files: Record<string, CompanyPortabilityFileEntry>,
|
|
rootPath?: string | null,
|
|
): Record<string, CompanyPortabilityFileEntry> {
|
|
const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null;
|
|
const out: Record<string, CompanyPortabilityFileEntry> = {};
|
|
for (const [rawPath, content] of Object.entries(files)) {
|
|
let nextPath = normalizePortablePath(rawPath);
|
|
if (normalizedRoot && nextPath === normalizedRoot) {
|
|
continue;
|
|
}
|
|
if (normalizedRoot && nextPath.startsWith(`${normalizedRoot}/`)) {
|
|
nextPath = nextPath.slice(normalizedRoot.length + 1);
|
|
}
|
|
if (!nextPath) continue;
|
|
out[nextPath] = content;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function pickTextFiles(files: Record<string, CompanyPortabilityFileEntry>) {
|
|
const out: Record<string, string> = {};
|
|
for (const [filePath, content] of Object.entries(files)) {
|
|
if (typeof content === "string") {
|
|
out[filePath] = content;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function collectSelectedExportSlugs(selectedFiles: Set<string>) {
|
|
const agents = new Set<string>();
|
|
const projects = new Set<string>();
|
|
const tasks = new Set<string>();
|
|
for (const filePath of selectedFiles) {
|
|
const agentMatch = filePath.match(/^agents\/([^/]+)\//);
|
|
if (agentMatch) agents.add(agentMatch[1]!);
|
|
const projectMatch = filePath.match(/^projects\/([^/]+)\//);
|
|
if (projectMatch) projects.add(projectMatch[1]!);
|
|
const taskMatch = filePath.match(/^tasks\/([^/]+)\//);
|
|
if (taskMatch) tasks.add(taskMatch[1]!);
|
|
}
|
|
return { agents, projects, tasks, routines: new Set(tasks) };
|
|
}
|
|
|
|
function normalizePortableSlugList(value: unknown) {
|
|
if (!Array.isArray(value)) return [];
|
|
const seen = new Set<string>();
|
|
const normalized: string[] = [];
|
|
for (const entry of value) {
|
|
if (typeof entry !== "string") continue;
|
|
const trimmed = entry.trim();
|
|
if (!trimmed || seen.has(trimmed)) continue;
|
|
seen.add(trimmed);
|
|
normalized.push(trimmed);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizePortableSidebarOrder(value: unknown): CompanyPortabilitySidebarOrder | null {
|
|
if (!isPlainRecord(value)) return null;
|
|
const sidebar = {
|
|
agents: normalizePortableSlugList(value.agents),
|
|
projects: normalizePortableSlugList(value.projects),
|
|
};
|
|
return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : null;
|
|
}
|
|
|
|
function sortAgentsBySidebarOrder<T extends { id: string; name: string; reportsTo: string | null }>(agents: T[]) {
|
|
if (agents.length === 0) return [];
|
|
|
|
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
const childrenOf = new Map<string | null, T[]>();
|
|
for (const agent of agents) {
|
|
const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null;
|
|
const siblings = childrenOf.get(parentId) ?? [];
|
|
siblings.push(agent);
|
|
childrenOf.set(parentId, siblings);
|
|
}
|
|
|
|
for (const siblings of childrenOf.values()) {
|
|
siblings.sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
|
|
const sorted: T[] = [];
|
|
const queue = [...(childrenOf.get(null) ?? [])];
|
|
while (queue.length > 0) {
|
|
const agent = queue.shift();
|
|
if (!agent) continue;
|
|
sorted.push(agent);
|
|
const children = childrenOf.get(agent.id);
|
|
if (children) queue.push(...children);
|
|
}
|
|
|
|
return sorted;
|
|
}
|
|
|
|
function filterPortableExtensionYaml(
|
|
yaml: string,
|
|
selectedFiles: Set<string>,
|
|
remainingFiles: Record<string, CompanyPortabilityFileEntry>,
|
|
extensionPath: string,
|
|
) {
|
|
const selected = collectSelectedExportSlugs(selectedFiles);
|
|
const parsed = parseYamlFile(yaml);
|
|
for (const section of ["agents", "projects", "tasks", "routines"] as const) {
|
|
const sectionValue = parsed[section];
|
|
if (!isPlainRecord(sectionValue)) continue;
|
|
const sectionSlugs = selected[section];
|
|
const filteredEntries = Object.fromEntries(
|
|
Object.entries(sectionValue).filter(([slug]) => sectionSlugs.has(slug)),
|
|
);
|
|
if (Object.keys(filteredEntries).length > 0) {
|
|
parsed[section] = filteredEntries;
|
|
} else {
|
|
delete parsed[section];
|
|
}
|
|
}
|
|
|
|
const companySection = parsed.company;
|
|
if (isPlainRecord(companySection)) {
|
|
const logoPath = asString(companySection.logoPath) ?? asString(companySection.logo);
|
|
if (logoPath && !selectedFiles.has(logoPath)) {
|
|
delete companySection.logoPath;
|
|
delete companySection.logo;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(parsed.labels)) {
|
|
const referencedLabelNames = new Set<string>();
|
|
const tasksSection = parsed.tasks;
|
|
if (isPlainRecord(tasksSection)) {
|
|
for (const entry of Object.values(tasksSection)) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
for (const name of readPortableIssueLabelNames(entry.labels)) {
|
|
referencedLabelNames.add(name);
|
|
}
|
|
}
|
|
}
|
|
const filteredLabels = parsed.labels.filter(
|
|
(entry) => isPlainRecord(entry) && typeof entry.name === "string" && referencedLabelNames.has(entry.name.trim()),
|
|
);
|
|
if (filteredLabels.length > 0) {
|
|
parsed.labels = filteredLabels;
|
|
} else {
|
|
delete parsed.labels;
|
|
}
|
|
}
|
|
|
|
// Embedded-asset entries stay only while some remaining text file (or a
|
|
// remaining task's comments, which live in this yaml) still references
|
|
// their assetId; blobs owned solely by pruned entries drop below.
|
|
const keptEmbeddedAssetShas = new Set<string>();
|
|
if (Array.isArray(parsed.embeddedAssets)) {
|
|
const referencedAssetIds = new Set<string>();
|
|
for (const [filePath, content] of Object.entries(remainingFiles)) {
|
|
if (filePath === extensionPath || typeof content !== "string") continue;
|
|
for (const assetId of collectEmbeddedAssetIds(content)) {
|
|
referencedAssetIds.add(assetId);
|
|
}
|
|
}
|
|
const tasksSection = parsed.tasks;
|
|
if (isPlainRecord(tasksSection)) {
|
|
for (const entry of Object.values(tasksSection)) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
for (const assetId of collectEmbeddedAssetIds(JSON.stringify(entry.comments ?? []))) {
|
|
referencedAssetIds.add(assetId);
|
|
}
|
|
}
|
|
}
|
|
const filteredEmbeddedAssets = parsed.embeddedAssets.filter((entry) => {
|
|
if (!isPlainRecord(entry)) return false;
|
|
const assetId = asString(entry.assetId)?.toLowerCase();
|
|
if (!assetId || !referencedAssetIds.has(assetId)) return false;
|
|
const sha256 = asString(entry.sha256)?.toLowerCase();
|
|
if (sha256) keptEmbeddedAssetShas.add(sha256);
|
|
return true;
|
|
});
|
|
if (filteredEmbeddedAssets.length > 0) {
|
|
parsed.embeddedAssets = filteredEmbeddedAssets;
|
|
} else {
|
|
delete parsed.embeddedAssets;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(parsed.blobs)) {
|
|
const referencedBlobShas = new Set<string>(keptEmbeddedAssetShas);
|
|
const tasksSection = parsed.tasks;
|
|
if (isPlainRecord(tasksSection)) {
|
|
for (const entry of Object.values(tasksSection)) {
|
|
if (!isPlainRecord(entry)) continue;
|
|
for (const attachment of normalizePortableIssueAttachments(entry.attachments, [], "")) {
|
|
referencedBlobShas.add(attachment.sha256);
|
|
}
|
|
}
|
|
}
|
|
const filteredBlobs = parsed.blobs.filter(
|
|
(entry) => isPlainRecord(entry) && typeof entry.sha256 === "string" && referencedBlobShas.has(entry.sha256.trim().toLowerCase()),
|
|
);
|
|
if (filteredBlobs.length > 0) {
|
|
parsed.blobs = filteredBlobs;
|
|
} else {
|
|
delete parsed.blobs;
|
|
}
|
|
}
|
|
|
|
const sidebarOrder = normalizePortableSidebarOrder(parsed.sidebar);
|
|
if (sidebarOrder) {
|
|
const filteredSidebar = stripEmptyValues({
|
|
agents: sidebarOrder.agents.filter((slug) => selected.agents.has(slug)),
|
|
projects: sidebarOrder.projects.filter((slug) => selected.projects.has(slug)),
|
|
});
|
|
if (isPlainRecord(filteredSidebar)) {
|
|
parsed.sidebar = filteredSidebar;
|
|
} else {
|
|
delete parsed.sidebar;
|
|
}
|
|
} else {
|
|
delete parsed.sidebar;
|
|
}
|
|
|
|
return buildYamlFile(parsed, { preserveEmptyStrings: true });
|
|
}
|
|
|
|
function filterExportFiles(
|
|
files: Record<string, CompanyPortabilityFileEntry>,
|
|
selectedFilesInput: string[] | undefined,
|
|
paperclipExtensionPath: string,
|
|
) {
|
|
if (!selectedFilesInput || selectedFilesInput.length === 0) {
|
|
return files;
|
|
}
|
|
|
|
const selectedFiles = new Set(
|
|
selectedFilesInput
|
|
.map((entry) => normalizePortablePath(entry))
|
|
.filter((entry) => entry.length > 0),
|
|
);
|
|
const filtered: Record<string, CompanyPortabilityFileEntry> = {};
|
|
for (const [filePath, content] of Object.entries(files)) {
|
|
if (!selectedFiles.has(filePath)) continue;
|
|
filtered[filePath] = content;
|
|
}
|
|
|
|
const extensionEntry = filtered[paperclipExtensionPath];
|
|
if (selectedFiles.has(paperclipExtensionPath) && typeof extensionEntry === "string") {
|
|
filtered[paperclipExtensionPath] = filterPortableExtensionYaml(
|
|
extensionEntry,
|
|
selectedFiles,
|
|
filtered,
|
|
paperclipExtensionPath,
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
function findPaperclipExtensionPath(files: Record<string, CompanyPortabilityFileEntry>) {
|
|
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
|
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
|
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
|
}
|
|
|
|
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 normalizePortableConfig(
|
|
value: unknown,
|
|
): 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" ||
|
|
key === "instructionsBundleMode" ||
|
|
key === "instructionsRootPath" ||
|
|
key === "instructionsEntryFile" ||
|
|
key === "promptTemplate" ||
|
|
key === "bootstrapPromptTemplate" || // deprecated — kept for backward compat
|
|
key === "paperclipSkillSync"
|
|
) continue;
|
|
if (key === "env") continue;
|
|
next[key] = entry;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function isAbsoluteCommand(value: string) {
|
|
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
function extractPortableEnvInputs(
|
|
agentSlug: string,
|
|
envValue: unknown,
|
|
warnings: string[],
|
|
): CompanyPortabilityEnvInput[] {
|
|
return extractPortableScopedEnvInputs(
|
|
{
|
|
label: `agent ${agentSlug}`,
|
|
warningPrefix: `Agent ${agentSlug}`,
|
|
agentSlug,
|
|
projectSlug: null,
|
|
},
|
|
envValue,
|
|
warnings,
|
|
);
|
|
}
|
|
|
|
function extractPortableProjectEnvInputs(
|
|
projectSlug: string,
|
|
envValue: unknown,
|
|
warnings: string[],
|
|
): CompanyPortabilityEnvInput[] {
|
|
return extractPortableScopedEnvInputs(
|
|
{
|
|
label: `project ${projectSlug}`,
|
|
warningPrefix: `Project ${projectSlug}`,
|
|
agentSlug: null,
|
|
projectSlug,
|
|
},
|
|
envValue,
|
|
warnings,
|
|
);
|
|
}
|
|
|
|
function jsonEqual(left: unknown, right: unknown): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
function isPathDefault(pathSegments: string[], value: unknown, rules: Array<{ path: string[]; value: unknown }>) {
|
|
return rules.some((rule) => jsonEqual(rule.path, pathSegments) && jsonEqual(rule.value, value));
|
|
}
|
|
|
|
function pruneDefaultLikeValue(
|
|
value: unknown,
|
|
opts: {
|
|
dropFalseBooleans: boolean;
|
|
path?: string[];
|
|
defaultRules?: Array<{ path: string[]; value: unknown }>;
|
|
},
|
|
): unknown {
|
|
const pathSegments = opts.path ?? [];
|
|
if (opts.defaultRules && isPathDefault(pathSegments, value, opts.defaultRules)) {
|
|
return undefined;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => pruneDefaultLikeValue(entry, { ...opts, path: pathSegments }));
|
|
}
|
|
if (isPlainRecord(value)) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const next = pruneDefaultLikeValue(entry, {
|
|
...opts,
|
|
path: [...pathSegments, key],
|
|
});
|
|
if (next === undefined) continue;
|
|
out[key] = next;
|
|
}
|
|
return out;
|
|
}
|
|
if (value === undefined) return undefined;
|
|
if (opts.dropFalseBooleans && value === false) return undefined;
|
|
return value;
|
|
}
|
|
|
|
function renderYamlScalar(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isEmptyObject(value: unknown): boolean {
|
|
return isPlainRecord(value) && Object.keys(value).length === 0;
|
|
}
|
|
|
|
function isEmptyArray(value: unknown): boolean {
|
|
return Array.isArray(value) && value.length === 0;
|
|
}
|
|
|
|
function stripEmptyValues(value: unknown, opts?: { preserveEmptyStrings?: boolean }): unknown {
|
|
if (Array.isArray(value)) {
|
|
const next = value
|
|
.map((entry) => stripEmptyValues(entry, opts))
|
|
.filter((entry) => entry !== undefined);
|
|
return next.length > 0 ? next : undefined;
|
|
}
|
|
if (isPlainRecord(value)) {
|
|
const next: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const cleaned = stripEmptyValues(entry, opts);
|
|
if (cleaned === undefined) continue;
|
|
next[key] = cleaned;
|
|
}
|
|
return Object.keys(next).length > 0 ? next : undefined;
|
|
}
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
(!opts?.preserveEmptyStrings && value === "") ||
|
|
isEmptyArray(value) ||
|
|
isEmptyObject(value)
|
|
) {
|
|
return undefined;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const YAML_KEY_PRIORITY = [
|
|
"name",
|
|
"description",
|
|
"title",
|
|
"schema",
|
|
"kind",
|
|
"slug",
|
|
"reportsTo",
|
|
"reportsToExistingAgentId",
|
|
"reportsToExistingAgentSlug",
|
|
"skills",
|
|
"owner",
|
|
"assignee",
|
|
"project",
|
|
"schedule",
|
|
"version",
|
|
"license",
|
|
"authors",
|
|
"homepage",
|
|
"tags",
|
|
"includes",
|
|
"requirements",
|
|
"role",
|
|
"icon",
|
|
"capabilities",
|
|
"logoPath",
|
|
"adapter",
|
|
"runtime",
|
|
"permissions",
|
|
"permissionGrants",
|
|
"budgetMonthlyCents",
|
|
"metadata",
|
|
] as const;
|
|
|
|
const YAML_KEY_PRIORITY_INDEX = new Map<string, number>(
|
|
YAML_KEY_PRIORITY.map((key, index) => [key, index]),
|
|
);
|
|
|
|
function compareYamlKeys(left: string, right: string) {
|
|
const leftPriority = YAML_KEY_PRIORITY_INDEX.get(left);
|
|
const rightPriority = YAML_KEY_PRIORITY_INDEX.get(right);
|
|
if (leftPriority !== undefined || rightPriority !== undefined) {
|
|
if (leftPriority === undefined) return 1;
|
|
if (rightPriority === undefined) return -1;
|
|
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
|
}
|
|
return left.localeCompare(right);
|
|
}
|
|
|
|
function orderedYamlEntries(value: Record<string, unknown>) {
|
|
return Object.entries(value).sort(([leftKey], [rightKey]) => compareYamlKeys(leftKey, rightKey));
|
|
}
|
|
|
|
function isYamlScalarValue(value: unknown) {
|
|
return (
|
|
value === null ||
|
|
typeof value === "string" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "number" ||
|
|
(Array.isArray(value) && value.length === 0) ||
|
|
isEmptyObject(value)
|
|
);
|
|
}
|
|
|
|
type YamlRenderFrame =
|
|
| { kind: "line"; text: string }
|
|
| { kind: "value"; value: unknown; indentLevel: number };
|
|
|
|
export function renderYamlBlock(value: unknown, indentLevel: number): string[] {
|
|
const lines: string[] = [];
|
|
const stack: YamlRenderFrame[] = [{ kind: "value", value, indentLevel }];
|
|
|
|
while (stack.length > 0) {
|
|
const frame = stack.pop()!;
|
|
if (frame.kind === "line") {
|
|
lines.push(frame.text);
|
|
continue;
|
|
}
|
|
|
|
const indent = " ".repeat(frame.indentLevel);
|
|
const current = frame.value;
|
|
|
|
if (Array.isArray(current)) {
|
|
if (current.length === 0) {
|
|
lines.push(`${indent}[]`);
|
|
continue;
|
|
}
|
|
|
|
for (let index = current.length - 1; index >= 0; index -= 1) {
|
|
const entry = current[index];
|
|
if (isYamlScalarValue(entry)) {
|
|
stack.push({ kind: "line", text: `${indent}- ${renderYamlScalar(entry)}` });
|
|
continue;
|
|
}
|
|
stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 });
|
|
stack.push({ kind: "line", text: `${indent}-` });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isPlainRecord(current)) {
|
|
const entries = orderedYamlEntries(current);
|
|
if (entries.length === 0) {
|
|
lines.push(`${indent}{}`);
|
|
continue;
|
|
}
|
|
|
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
const [key, entry] = entries[index]!;
|
|
if (isYamlScalarValue(entry)) {
|
|
stack.push({ kind: "line", text: `${indent}${key}: ${renderYamlScalar(entry)}` });
|
|
continue;
|
|
}
|
|
stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 });
|
|
stack.push({ kind: "line", text: `${indent}${key}:` });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
lines.push(`${indent}${renderYamlScalar(current)}`);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
export function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
|
const lines: string[] = ["---"];
|
|
for (const [key, value] of orderedYamlEntries(frontmatter)) {
|
|
// Skip null/undefined values — don't export empty fields
|
|
if (value === null || value === undefined) continue;
|
|
if (isYamlScalarValue(value)) {
|
|
lines.push(`${key}: ${renderYamlScalar(value)}`);
|
|
continue;
|
|
}
|
|
lines.push(`${key}:`);
|
|
// Append each rendered line without a spread. A spread of a large array as
|
|
// function arguments overflows the argument limit and throws RangeError.
|
|
for (const line of renderYamlBlock(value, 1)) lines.push(line);
|
|
}
|
|
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 normalizeSelectedFiles(selectedFiles?: string[]) {
|
|
if (!selectedFiles) return null;
|
|
return new Set(
|
|
selectedFiles
|
|
.map((entry) => normalizePortablePath(entry))
|
|
.filter((entry) => entry.length > 0),
|
|
);
|
|
}
|
|
|
|
function filterCompanyMarkdownIncludes(
|
|
companyPath: string,
|
|
markdown: string,
|
|
selectedFiles: Set<string>,
|
|
) {
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
const includeEntries = readIncludeEntries(parsed.frontmatter);
|
|
const filteredIncludes = includeEntries.filter((entry) =>
|
|
selectedFiles.has(resolvePortablePath(companyPath, entry.path)),
|
|
);
|
|
const nextFrontmatter: Record<string, unknown> = { ...parsed.frontmatter };
|
|
if (filteredIncludes.length > 0) {
|
|
nextFrontmatter.includes = filteredIncludes.map((entry) => entry.path);
|
|
} else {
|
|
delete nextFrontmatter.includes;
|
|
}
|
|
return buildMarkdown(nextFrontmatter, parsed.body);
|
|
}
|
|
|
|
function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: string[]): ResolvedSource {
|
|
const normalizedSelection = normalizeSelectedFiles(selectedFiles);
|
|
if (!normalizedSelection) return source;
|
|
|
|
const companyPath = source.manifest.company
|
|
? ensureMarkdownPath(source.manifest.company.path)
|
|
: Object.keys(source.files).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md") ?? null;
|
|
if (!companyPath) {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyMarkdown = source.files[companyPath];
|
|
if (typeof companyMarkdown !== "string") {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const effectiveFiles: Record<string, CompanyPortabilityFileEntry> = {};
|
|
for (const [filePath, content] of Object.entries(source.files)) {
|
|
const normalizedPath = normalizePortablePath(filePath);
|
|
if (!normalizedSelection.has(normalizedPath)) continue;
|
|
effectiveFiles[normalizedPath] = content;
|
|
}
|
|
|
|
effectiveFiles[companyPath] = filterCompanyMarkdownIncludes(
|
|
companyPath,
|
|
companyMarkdown,
|
|
normalizedSelection,
|
|
);
|
|
|
|
const filtered = buildManifestFromPackageFiles(effectiveFiles, {
|
|
sourceLabel: source.manifest.source,
|
|
});
|
|
|
|
if (!normalizedSelection.has(companyPath)) {
|
|
filtered.manifest.company = null;
|
|
}
|
|
|
|
filtered.manifest.includes = {
|
|
company: filtered.manifest.company !== null,
|
|
agents: filtered.manifest.agents.length > 0,
|
|
projects: filtered.manifest.projects.length > 0,
|
|
issues: filtered.manifest.issues.length > 0,
|
|
skills: filtered.manifest.skills.length > 0,
|
|
};
|
|
|
|
return filtered;
|
|
}
|
|
|
|
async function resolveBundledSkillsCommit() {
|
|
if (!bundledSkillsCommitPromise) {
|
|
bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
})
|
|
.then(({ stdout }) => stdout.trim() || null)
|
|
.catch(() => null);
|
|
}
|
|
return bundledSkillsCommitPromise;
|
|
}
|
|
|
|
async function buildSkillSourceEntry(skill: CompanySkill) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
if (asString(metadata?.sourceKind) === "paperclip_bundled") {
|
|
const commit = await resolveBundledSkillsCommit();
|
|
return {
|
|
kind: "github-dir",
|
|
repo: "paperclipai/paperclip",
|
|
path: `skills/${skill.slug}`,
|
|
commit,
|
|
trackingRef: "master",
|
|
url: `https://github.com/paperclipai/paperclip/tree/master/skills/${skill.slug}`,
|
|
};
|
|
}
|
|
|
|
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
|
const owner = asString(metadata?.owner);
|
|
const repo = asString(metadata?.repo);
|
|
const repoSkillDir = asString(metadata?.repoSkillDir);
|
|
if (!owner || !repo || !repoSkillDir) return null;
|
|
return {
|
|
kind: "github-dir",
|
|
repo: `${owner}/${repo}`,
|
|
path: repoSkillDir,
|
|
commit: skill.sourceRef ?? null,
|
|
trackingRef: asString(metadata?.trackingRef),
|
|
url: skill.sourceLocator,
|
|
};
|
|
}
|
|
|
|
if (skill.sourceType === "url" && skill.sourceLocator) {
|
|
return {
|
|
kind: "url",
|
|
url: skill.sourceLocator,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkills: boolean) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
// Bundled Paperclip skills ship with every build and may contain executable
|
|
// scripts that import policy rejects when expanded; the target re-resolves
|
|
// them from its own catalog via the pinned reference stub instead.
|
|
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
|
|
if (expandReferencedSkills) return false;
|
|
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url";
|
|
}
|
|
|
|
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
|
const frontmatter: Record<string, unknown> = {
|
|
key: skill.key,
|
|
slug: skill.slug,
|
|
name: skill.name,
|
|
description: skill.description ?? null,
|
|
};
|
|
if (sourceEntry) {
|
|
frontmatter.metadata = {
|
|
sources: [sourceEntry],
|
|
};
|
|
}
|
|
return buildMarkdown(frontmatter, "");
|
|
}
|
|
|
|
async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
const metadata = isPlainRecord(parsed.frontmatter.metadata)
|
|
? { ...parsed.frontmatter.metadata }
|
|
: {};
|
|
const existingSources = Array.isArray(metadata.sources)
|
|
? metadata.sources.filter((entry) => isPlainRecord(entry))
|
|
: [];
|
|
if (sourceEntry) {
|
|
metadata.sources = [...existingSources, sourceEntry];
|
|
}
|
|
const catalogProvenance = buildPortableCatalogProvenance(skill);
|
|
metadata.skillKey = skill.key;
|
|
metadata.paperclipSkillKey = skill.key;
|
|
metadata.paperclip = {
|
|
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
|
|
skillKey: skill.key,
|
|
slug: skill.slug,
|
|
...(catalogProvenance ? { catalog: catalogProvenance } : {}),
|
|
};
|
|
const frontmatter = {
|
|
...parsed.frontmatter,
|
|
key: skill.key,
|
|
slug: skill.slug,
|
|
metadata,
|
|
};
|
|
return buildMarkdown(frontmatter, parsed.body);
|
|
}
|
|
|
|
|
|
function parseYamlScalar(rawValue: string): unknown {
|
|
const trimmed = rawValue.trim();
|
|
if (trimmed === "") return "";
|
|
if (trimmed === "null" || trimmed === "~") return null;
|
|
if (trimmed === "true") return true;
|
|
if (trimmed === "false") return false;
|
|
if (trimmed === "[]") return [];
|
|
if (trimmed === "{}") return {};
|
|
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
if (
|
|
trimmed.startsWith("\"") ||
|
|
trimmed.startsWith("[") ||
|
|
trimmed.startsWith("{")
|
|
) {
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function prepareYamlLines(raw: string) {
|
|
return raw
|
|
.split("\n")
|
|
.map((line) => ({
|
|
indent: line.match(/^ */)?.[0].length ?? 0,
|
|
content: line.trim(),
|
|
}))
|
|
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
|
}
|
|
|
|
function parseYamlBlock(
|
|
lines: Array<{ indent: number; content: string }>,
|
|
startIndex: number,
|
|
indentLevel: number,
|
|
): { value: unknown; nextIndex: number } {
|
|
let index = startIndex;
|
|
while (index < lines.length && lines[index]!.content.length === 0) {
|
|
index += 1;
|
|
}
|
|
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
|
return { value: {}, nextIndex: index };
|
|
}
|
|
|
|
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
|
|
if (isArray) {
|
|
const values: unknown[] = [];
|
|
while (index < lines.length) {
|
|
const line = lines[index]!;
|
|
if (line.indent < indentLevel) break;
|
|
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
|
const remainder = line.content.slice(1).trim();
|
|
index += 1;
|
|
if (!remainder) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
values.push(nested.value);
|
|
index = nested.nextIndex;
|
|
continue;
|
|
}
|
|
const inlineObjectSeparator = remainder.indexOf(":");
|
|
if (
|
|
inlineObjectSeparator > 0 &&
|
|
!remainder.startsWith("\"") &&
|
|
!remainder.startsWith("{") &&
|
|
!remainder.startsWith("[")
|
|
) {
|
|
const key = remainder.slice(0, inlineObjectSeparator).trim();
|
|
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
|
|
const nextObject: Record<string, unknown> = {
|
|
[key]: parseYamlScalar(rawValue),
|
|
};
|
|
if (index < lines.length && lines[index]!.indent > indentLevel) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
if (isPlainRecord(nested.value)) {
|
|
Object.assign(nextObject, nested.value);
|
|
}
|
|
index = nested.nextIndex;
|
|
}
|
|
values.push(nextObject);
|
|
continue;
|
|
}
|
|
values.push(parseYamlScalar(remainder));
|
|
}
|
|
return { value: values, nextIndex: index };
|
|
}
|
|
|
|
const record: Record<string, unknown> = {};
|
|
while (index < lines.length) {
|
|
const line = lines[index]!;
|
|
if (line.indent < indentLevel) break;
|
|
if (line.indent !== indentLevel) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const separatorIndex = line.content.indexOf(":");
|
|
if (separatorIndex <= 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const key = line.content.slice(0, separatorIndex).trim();
|
|
const remainder = line.content.slice(separatorIndex + 1).trim();
|
|
index += 1;
|
|
if (!remainder) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
record[key] = nested.value;
|
|
index = nested.nextIndex;
|
|
continue;
|
|
}
|
|
record[key] = parseYamlScalar(remainder);
|
|
}
|
|
|
|
return { value: record, nextIndex: index };
|
|
}
|
|
|
|
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
|
const prepared = prepareYamlLines(raw);
|
|
if (prepared.length === 0) return {};
|
|
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
|
return isPlainRecord(parsed.value) ? parsed.value : {};
|
|
}
|
|
|
|
function parseYamlFile(raw: string): Record<string, unknown> {
|
|
return parseYamlFrontmatter(raw);
|
|
}
|
|
|
|
function buildYamlFile(value: Record<string, unknown>, opts?: { preserveEmptyStrings?: boolean }) {
|
|
const cleaned = stripEmptyValues(value, opts);
|
|
if (!isPlainRecord(cleaned)) return "{}\n";
|
|
return renderYamlBlock(cleaned, 0).join("\n") + "\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();
|
|
return {
|
|
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
|
body,
|
|
};
|
|
}
|
|
|
|
async function fetchText(url: string) {
|
|
const response = await ghFetch(url);
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function fetchOptionalText(url: string) {
|
|
const response = await ghFetch(url);
|
|
if (response.status === 404) return null;
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function fetchBinary(url: string) {
|
|
const response = await ghFetch(url);
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
const response = await ghFetch(url, {
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
|
const seen = new Set<string>();
|
|
const out: CompanyPortabilityManifest["envInputs"] = [];
|
|
for (const value of values) {
|
|
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
|
|
const env: Record<string, Record<string, unknown>> = {};
|
|
for (const input of inputs) {
|
|
const entry: Record<string, unknown> = {
|
|
kind: input.kind,
|
|
requirement: input.requirement,
|
|
};
|
|
if (input.defaultValue !== null) entry.default = input.defaultValue;
|
|
if (input.description) entry.description = input.description;
|
|
if (input.portability === "system_dependent") entry.portability = "system_dependent";
|
|
env[input.key] = entry;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function envInputScopedKey(input: CompanyPortabilityEnvInput) {
|
|
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
|
|
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
|
|
return input.key;
|
|
}
|
|
|
|
function envInputValue(input: CompanyPortabilityEnvInput, values: Record<string, string> | null | undefined) {
|
|
if (!values) return null;
|
|
const scopedKey = envInputScopedKey(input);
|
|
if (Object.prototype.hasOwnProperty.call(values, scopedKey)) return values[scopedKey];
|
|
if (Object.prototype.hasOwnProperty.call(values, input.key)) return values[input.key];
|
|
return null;
|
|
}
|
|
|
|
function importSecretLabel(input: CompanyPortabilityEnvInput) {
|
|
const scope = input.agentSlug
|
|
? `agent ${input.agentSlug}`
|
|
: input.projectSlug
|
|
? `project ${input.projectSlug}`
|
|
: "company import";
|
|
return `${scope} ${input.key}`;
|
|
}
|
|
|
|
function importSecretKey(input: CompanyPortabilityEnvInput, suffix: string) {
|
|
const scope = input.agentSlug
|
|
? `agent-${input.agentSlug}`
|
|
: input.projectSlug
|
|
? `project-${input.projectSlug}`
|
|
: "company";
|
|
return `import-${scope}-${input.key}-${suffix}`;
|
|
}
|
|
|
|
function writeManifestEnvBinding(
|
|
manifest: CompanyPortabilityManifest,
|
|
input: CompanyPortabilityEnvInput,
|
|
binding: AgentEnvConfig[string],
|
|
) {
|
|
if (input.agentSlug) {
|
|
const agent = manifest.agents.find((entry) => entry.slug === input.agentSlug);
|
|
if (!agent) return;
|
|
const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {};
|
|
const env = isPlainRecord(adapterConfig.env) ? { ...adapterConfig.env } : {};
|
|
env[input.key] = binding;
|
|
agent.adapterConfig = { ...adapterConfig, env };
|
|
return;
|
|
}
|
|
|
|
if (input.projectSlug) {
|
|
const project = manifest.projects.find((entry) => entry.slug === input.projectSlug);
|
|
if (!project) return;
|
|
project.env = {
|
|
...(project.env ?? {}),
|
|
[input.key]: binding,
|
|
};
|
|
}
|
|
}
|
|
|
|
function readCompanyApprovalDefault(_frontmatter: Record<string, unknown>) {
|
|
return false;
|
|
}
|
|
|
|
function readIncludeEntries(frontmatter: Record<string, unknown>): CompanyPackageIncludeEntry[] {
|
|
const includes = frontmatter.includes;
|
|
if (!Array.isArray(includes)) return [];
|
|
return includes.flatMap((entry) => {
|
|
if (typeof entry === "string") {
|
|
return [{ path: entry }];
|
|
}
|
|
if (isPlainRecord(entry)) {
|
|
const pathValue = asString(entry.path);
|
|
return pathValue ? [{ path: pathValue }] : [];
|
|
}
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function readAgentEnvInputs(
|
|
extension: Record<string, unknown>,
|
|
agentSlug: string,
|
|
): CompanyPortabilityManifest["envInputs"] {
|
|
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
|
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
|
if (!env) return [];
|
|
|
|
return Object.entries(env).flatMap(([key, value]) => {
|
|
if (!isPlainRecord(value)) return [];
|
|
const record = value as EnvInputRecord;
|
|
return [{
|
|
key,
|
|
description: asString(record.description) ?? null,
|
|
agentSlug,
|
|
projectSlug: null,
|
|
kind: record.kind === "plain" ? "plain" : "secret",
|
|
requirement: record.requirement === "required" ? "required" : "optional",
|
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
|
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
|
}];
|
|
});
|
|
}
|
|
|
|
function readProjectEnvInputs(
|
|
extension: Record<string, unknown>,
|
|
projectSlug: string,
|
|
): CompanyPortabilityManifest["envInputs"] {
|
|
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
|
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
|
if (!env) return [];
|
|
|
|
return Object.entries(env).flatMap(([key, value]) => {
|
|
if (!isPlainRecord(value)) return [];
|
|
const record = value as EnvInputRecord;
|
|
return [{
|
|
key,
|
|
description: asString(record.description) ?? null,
|
|
agentSlug: null,
|
|
projectSlug,
|
|
kind: record.kind === "plain" ? "plain" : "secret",
|
|
requirement: record.requirement === "required" ? "required" : "optional",
|
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
|
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
|
}];
|
|
});
|
|
}
|
|
|
|
function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
|
|
const skills = frontmatter.skills;
|
|
if (!Array.isArray(skills)) return [];
|
|
return Array.from(new Set(
|
|
skills
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => normalizeSkillKey(entry) ?? entry.trim())
|
|
.filter(Boolean),
|
|
));
|
|
}
|
|
|
|
/** Uploaded avatars belong to one company/agent; portable packages retain the preset fallback. */
|
|
function portableAgentAppearance(value: unknown, warnings: string[], slug: string) {
|
|
const appearance = agentAppearanceSchema.parse(value);
|
|
if (appearance.customAvatarAssetId) {
|
|
delete appearance.customAvatarAssetId;
|
|
warnings.push(`Agent ${slug} uploaded avatar was omitted because image assets are instance-local. Upload it again after import.`);
|
|
}
|
|
return appearance;
|
|
}
|
|
|
|
function buildManifestFromPackageFiles(
|
|
files: Record<string, CompanyPortabilityFileEntry>,
|
|
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
|
): ResolvedSource {
|
|
const normalizedFiles = normalizeFileMap(files);
|
|
const companyPath = typeof normalizedFiles["COMPANY.md"] === "string"
|
|
? normalizedFiles["COMPANY.md"]
|
|
: undefined;
|
|
const resolvedCompanyPath = companyPath !== undefined
|
|
? "COMPANY.md"
|
|
: Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md");
|
|
if (!resolvedCompanyPath) {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyMarkdown = readPortableTextFile(normalizedFiles, resolvedCompanyPath);
|
|
if (typeof companyMarkdown !== "string") {
|
|
throw unprocessable(`Company package file is not readable as text: ${resolvedCompanyPath}`);
|
|
}
|
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
|
const companyFrontmatter = companyDoc.frontmatter;
|
|
const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles);
|
|
const paperclipExtension = paperclipExtensionPath
|
|
? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "")
|
|
: {};
|
|
const declaredSchemaVersion = asInteger(paperclipExtension.schemaVersion);
|
|
const bundleSchemaVersion = declaredSchemaVersion !== null && declaredSchemaVersion > 0
|
|
? declaredSchemaVersion
|
|
: UNSTAMPED_BUNDLE_SCHEMA_VERSION;
|
|
if (bundleSchemaVersion > BUNDLE_SCHEMA_VERSION) {
|
|
throw unprocessable(`Company package declares schemaVersion ${bundleSchemaVersion}, which was produced by a newer Paperclip; this board reads up to schemaVersion ${BUNDLE_SCHEMA_VERSION}.`);
|
|
}
|
|
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
|
const paperclipSidebar = normalizePortableSidebarOrder(paperclipExtension.sidebar);
|
|
const paperclipLabels = normalizePortableLabelDefinitions(paperclipExtension.labels);
|
|
const paperclipBlobs = normalizePortableBlobIndex(paperclipExtension.blobs);
|
|
const paperclipEmbeddedAssets = normalizePortableEmbeddedAssets(paperclipExtension.embeddedAssets);
|
|
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
|
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
|
|
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
|
|
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
|
|
const companyName =
|
|
asString(companyFrontmatter.name)
|
|
?? opts?.sourceLabel?.companyName
|
|
?? "Imported Company";
|
|
const companySlug =
|
|
asString(companyFrontmatter.slug)
|
|
?? normalizeAgentUrlKey(companyName)
|
|
?? "company";
|
|
|
|
const includeEntries = readIncludeEntries(companyFrontmatter);
|
|
const referencedAgentPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md");
|
|
const referencedProjectPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md");
|
|
const referencedTaskPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/TASK.md") || entry === "TASK.md");
|
|
const referencedSkillPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md");
|
|
const discoveredAgentPaths = Object.keys(normalizedFiles).filter(
|
|
(entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md",
|
|
);
|
|
const discoveredProjectPaths = Object.keys(normalizedFiles).filter(
|
|
(entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md",
|
|
);
|
|
const discoveredTaskPaths = Object.keys(normalizedFiles).filter(
|
|
(entry) => entry.endsWith("/TASK.md") || entry === "TASK.md",
|
|
);
|
|
const discoveredSkillPaths = Object.keys(normalizedFiles).filter(
|
|
(entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md",
|
|
);
|
|
const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort();
|
|
const projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort();
|
|
const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort();
|
|
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
|
|
|
|
const manifest: CompanyPortabilityManifest = {
|
|
schemaVersion: bundleSchemaVersion,
|
|
generatedAt: new Date().toISOString(),
|
|
source: opts?.sourceLabel ?? null,
|
|
includes: {
|
|
company: true,
|
|
agents: true,
|
|
projects: projectPaths.length > 0,
|
|
issues: taskPaths.length > 0,
|
|
skills: skillPaths.length > 0,
|
|
},
|
|
company: {
|
|
path: resolvedCompanyPath,
|
|
name: companyName,
|
|
description: asString(companyFrontmatter.description),
|
|
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
|
requireBoardApprovalForNewAgents:
|
|
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
|
? paperclipCompany.requireBoardApprovalForNewAgents
|
|
: readCompanyApprovalDefault(companyFrontmatter),
|
|
feedbackDataSharingEnabled:
|
|
typeof paperclipCompany.feedbackDataSharingEnabled === "boolean"
|
|
? paperclipCompany.feedbackDataSharingEnabled
|
|
: false,
|
|
feedbackDataSharingConsentAt:
|
|
typeof paperclipCompany.feedbackDataSharingConsentAt === "string"
|
|
? paperclipCompany.feedbackDataSharingConsentAt
|
|
: null,
|
|
feedbackDataSharingConsentByUserId:
|
|
asString(paperclipCompany.feedbackDataSharingConsentByUserId),
|
|
feedbackDataSharingTermsVersion:
|
|
asString(paperclipCompany.feedbackDataSharingTermsVersion),
|
|
},
|
|
sidebar: paperclipSidebar,
|
|
labels: paperclipLabels,
|
|
blobs: paperclipBlobs,
|
|
embeddedAssets: paperclipEmbeddedAssets,
|
|
agents: [],
|
|
skills: [],
|
|
projects: [],
|
|
issues: [],
|
|
envInputs: [],
|
|
};
|
|
|
|
const warnings: string[] = [];
|
|
if (manifest.company?.logoPath && !normalizedFiles[manifest.company.logoPath]) {
|
|
warnings.push(`Referenced company logo file is missing from package: ${manifest.company.logoPath}`);
|
|
}
|
|
for (const agentPath of agentPaths) {
|
|
const markdownRaw = readPortableTextFile(normalizedFiles, agentPath);
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced agent file is missing from package: ${agentPath}`);
|
|
continue;
|
|
}
|
|
const agentDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = agentDoc.frontmatter;
|
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(agentPath))) ?? "agent";
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {};
|
|
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
|
|
const runner = extensionAdapter?.runner;
|
|
if (runner !== undefined && (typeof runner !== "string" || !["auto", "paperclip", "legacy"].includes(runner))) {
|
|
throw unprocessable(`Invalid runner choice for agent ${slug}. Use auto, paperclip, or legacy.`, {
|
|
code: "agent_runner_choice_invalid", field: `agents.${slug}.adapter.runner`,
|
|
});
|
|
}
|
|
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
|
|
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
|
|
const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants);
|
|
const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null;
|
|
const adapterConfig = isPlainRecord(extensionAdapter?.config)
|
|
? extensionAdapter.config
|
|
: {};
|
|
const runtimeConfig = extensionRuntime ?? {};
|
|
const title = asString(frontmatter.title);
|
|
|
|
manifest.agents.push({
|
|
slug,
|
|
name: asString(frontmatter.name) ?? title ?? slug,
|
|
path: agentPath,
|
|
skills: readAgentSkillRefs(frontmatter),
|
|
role: asString(extension.role) ?? asString(frontmatter.role) ?? "agent",
|
|
title,
|
|
icon: asString(extension.icon),
|
|
appearance: extension.appearance == null ? undefined : portableAgentAppearance(extension.appearance, warnings, slug),
|
|
capabilities: asString(extension.capabilities),
|
|
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
|
|
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
|
|
reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug),
|
|
runner: runner as AgentRunnerChoice | undefined,
|
|
adapterType: asString(extensionAdapter?.type) ?? "process",
|
|
adapterConfig,
|
|
runtimeConfig,
|
|
permissions: extensionPermissions ?? {},
|
|
permissionGrants: extensionPermissionGrants,
|
|
budgetMonthlyCents:
|
|
typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents)
|
|
? Math.max(0, Math.floor(extension.budgetMonthlyCents))
|
|
: 0,
|
|
metadata: extensionMetadata,
|
|
});
|
|
|
|
manifest.envInputs.push(...readAgentEnvInputs(extension, slug));
|
|
|
|
if (frontmatter.kind && frontmatter.kind !== "agent") {
|
|
warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
for (const skillPath of skillPaths) {
|
|
const markdownRaw = readPortableTextFile(normalizedFiles, skillPath);
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced skill file is missing from package: ${skillPath}`);
|
|
continue;
|
|
}
|
|
const skillDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = skillDoc.frontmatter;
|
|
const skillDir = path.posix.dirname(skillPath);
|
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill";
|
|
const slug = asString(frontmatter.slug) ?? normalizeAgentUrlKey(asString(frontmatter.name) ?? "") ?? fallbackSlug;
|
|
const inventory = Object.keys(normalizedFiles)
|
|
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
|
.map((entry) => ({
|
|
path: entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
|
kind: entry === skillPath
|
|
? "skill"
|
|
: entry.startsWith(`${skillDir}/references/`)
|
|
? "reference"
|
|
: entry.startsWith(`${skillDir}/scripts/`)
|
|
? "script"
|
|
: entry.startsWith(`${skillDir}/assets/`)
|
|
? "asset"
|
|
: entry.endsWith(".md")
|
|
? "markdown"
|
|
: "other",
|
|
}));
|
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
|
const sources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
|
|
const primarySource = sources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
|
|
const sourceKind = asString(primarySource?.kind);
|
|
let sourceType = "catalog";
|
|
let sourceLocator: string | null = null;
|
|
let sourceRef: string | null = null;
|
|
let normalizedMetadata: Record<string, unknown> | null = null;
|
|
|
|
if (sourceKind === "github-dir" || sourceKind === "github-file") {
|
|
const repo = asString(primarySource?.repo);
|
|
const repoPath = asString(primarySource?.path);
|
|
const commit = asString(primarySource?.commit);
|
|
const trackingRef = asString(primarySource?.trackingRef);
|
|
const sourceHostname = asString(primarySource?.hostname) || "github.com";
|
|
const [owner, repoName] = (repo ?? "").split("/");
|
|
const canonicalKey = readSkillKey(frontmatter);
|
|
const normalizedSourceKind = owner === "paperclipai"
|
|
&& repoName === "paperclip"
|
|
&& canonicalKey?.startsWith("paperclipai/paperclip/")
|
|
? "paperclip_bundled"
|
|
: "github";
|
|
sourceType = "github";
|
|
sourceLocator = asString(primarySource?.url)
|
|
?? (repo ? `https://${sourceHostname}/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null);
|
|
sourceRef = commit;
|
|
normalizedMetadata = owner && repoName
|
|
? {
|
|
sourceKind: normalizedSourceKind,
|
|
...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}),
|
|
owner,
|
|
repo: repoName,
|
|
ref: commit,
|
|
trackingRef,
|
|
repoSkillDir: repoPath ?? `skills/${slug}`,
|
|
}
|
|
: null;
|
|
} else if (sourceKind === "url") {
|
|
sourceType = "url";
|
|
sourceLocator = asString(primarySource?.url) ?? asString(primarySource?.rawUrl);
|
|
normalizedMetadata = {
|
|
sourceKind: "url",
|
|
};
|
|
} else {
|
|
const catalogProvenance = readPortableCatalogProvenance(metadata);
|
|
if (catalogProvenance) {
|
|
sourceType = "catalog";
|
|
sourceRef = catalogProvenance.sourceRef;
|
|
normalizedMetadata = catalogProvenance.metadata;
|
|
} else if (metadata) {
|
|
normalizedMetadata = {
|
|
sourceKind: "catalog",
|
|
};
|
|
}
|
|
}
|
|
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
|
|
|
|
manifest.skills.push({
|
|
key,
|
|
slug,
|
|
name: asString(frontmatter.name) ?? slug,
|
|
path: skillPath,
|
|
description: asString(frontmatter.description),
|
|
sourceType,
|
|
sourceLocator,
|
|
sourceRef,
|
|
trustLevel: null,
|
|
compatibility: "compatible",
|
|
metadata: normalizedMetadata,
|
|
fileInventory: inventory,
|
|
});
|
|
}
|
|
|
|
for (const projectPath of projectPaths) {
|
|
const markdownRaw = readPortableTextFile(normalizedFiles, projectPath);
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced project file is missing from package: ${projectPath}`);
|
|
continue;
|
|
}
|
|
const projectDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = projectDoc.frontmatter;
|
|
const fallbackSlug = deriveProjectUrlKey(
|
|
asString(frontmatter.name) ?? path.posix.basename(path.posix.dirname(projectPath)) ?? "project",
|
|
projectPath,
|
|
);
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipProjects[slug]) ? paperclipProjects[slug] : {};
|
|
const workspaceExtensions = isPlainRecord(extension.workspaces) ? extension.workspaces : {};
|
|
const workspaces = Object.entries(workspaceExtensions)
|
|
.map(([workspaceKey, entry]) => normalizePortableProjectWorkspaceExtension(workspaceKey, entry))
|
|
.filter((entry): entry is CompanyPortabilityProjectWorkspaceManifestEntry => entry !== null);
|
|
manifest.projects.push({
|
|
slug,
|
|
name: asString(frontmatter.name) ?? slug,
|
|
path: projectPath,
|
|
description: asString(frontmatter.description),
|
|
ownerAgentSlug: asString(frontmatter.owner),
|
|
leadAgentSlug: asString(extension.leadAgentSlug),
|
|
targetDate: asString(extension.targetDate),
|
|
color: asString(extension.color),
|
|
icon: asString(extension.icon),
|
|
status: asString(extension.status),
|
|
env: normalizePortableProjectEnv(extension.env),
|
|
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
|
? extension.executionWorkspacePolicy
|
|
: null,
|
|
workspaces,
|
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
|
});
|
|
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
|
|
if (frontmatter.kind && frontmatter.kind !== "project") {
|
|
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
for (const taskPath of taskPaths) {
|
|
const markdownRaw = readPortableTextFile(normalizedFiles, taskPath);
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced task file is missing from package: ${taskPath}`);
|
|
continue;
|
|
}
|
|
const taskDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = taskDoc.frontmatter;
|
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(taskPath))) ?? "task";
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipTasks[slug]) ? paperclipTasks[slug] : {};
|
|
const routineExtension = normalizeRoutineExtension(paperclipRoutines[slug]);
|
|
const routineExtensionRaw = isPlainRecord(paperclipRoutines[slug]) ? paperclipRoutines[slug] : {};
|
|
const schedule = isPlainRecord(frontmatter.schedule) ? frontmatter.schedule : null;
|
|
const legacyRecurrence = schedule && isPlainRecord(schedule.recurrence)
|
|
? schedule.recurrence
|
|
: isPlainRecord(extension.recurrence)
|
|
? extension.recurrence
|
|
: null;
|
|
const recurring =
|
|
asBoolean(frontmatter.recurring) === true
|
|
|| routineExtension !== null
|
|
|| legacyRecurrence !== null;
|
|
manifest.issues.push({
|
|
slug,
|
|
identifier: asString(extension.identifier),
|
|
title: asString(frontmatter.name) ?? asString(frontmatter.title) ?? slug,
|
|
path: taskPath,
|
|
projectSlug: asString(frontmatter.project),
|
|
projectWorkspaceKey: asString(extension.projectWorkspaceKey),
|
|
assigneeAgentSlug: asString(frontmatter.assignee),
|
|
description: taskDoc.body || asString(frontmatter.description),
|
|
recurring,
|
|
routine: routineExtension,
|
|
legacyRecurrence,
|
|
status: asString(extension.status) ?? asString(routineExtensionRaw.status),
|
|
priority: asString(extension.priority) ?? asString(routineExtensionRaw.priority),
|
|
labelIds: Array.isArray(extension.labelIds)
|
|
? extension.labelIds.filter((entry): entry is string => typeof entry === "string")
|
|
: [],
|
|
labelNames: readPortableIssueLabelNames(extension.labels),
|
|
billingCode: asString(extension.billingCode),
|
|
executionWorkspaceSettings: isPlainRecord(extension.executionWorkspaceSettings)
|
|
? extension.executionWorkspaceSettings
|
|
: null,
|
|
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
|
|
? extension.assigneeAdapterOverrides
|
|
: null,
|
|
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
|
|
blockedBy: readPortableIssueBlockedBy(extension.blockedBy),
|
|
documents: normalizePortableIssueDocuments(extension.documents, warnings, `Task ${slug}`),
|
|
workProducts: normalizePortableIssueWorkProducts(extension.workProducts),
|
|
monitor: normalizePortableIssueMonitor(extension.monitor),
|
|
attachments: normalizePortableIssueAttachments(extension.attachments, warnings, `Task ${slug}`),
|
|
parentSlug: asString(extension.parent),
|
|
createdAt: readPortableIssueTimestamp(extension.createdAt, warnings, `Task ${slug}`, "createdAt"),
|
|
updatedAt: readPortableIssueTimestamp(extension.updatedAt, warnings, `Task ${slug}`, "updatedAt"),
|
|
startedAt: readPortableIssueTimestamp(extension.startedAt, warnings, `Task ${slug}`, "startedAt"),
|
|
completedAt: readPortableIssueTimestamp(extension.completedAt, warnings, `Task ${slug}`, "completedAt"),
|
|
cancelledAt: readPortableIssueTimestamp(extension.cancelledAt, warnings, `Task ${slug}`, "cancelledAt"),
|
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
|
});
|
|
if (frontmatter.kind && frontmatter.kind !== "task") {
|
|
warnings.push(`Task markdown ${taskPath} does not declare kind: task in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
if (bundleSchemaVersion < BUNDLE_SCHEMA_VERSION && manifest.issues.length > 0) {
|
|
const predated = bundleSchemaVersion < 6
|
|
? "label, blocker, document, work product, monitor, attachment, embedded image, task timestamp, and parent link transfer"
|
|
: "task timestamp and parent link transfer";
|
|
warnings.push(`This package declares schemaVersion ${bundleSchemaVersion} and predates ${predated}; that task data imports only if the bundle carries it.`);
|
|
}
|
|
|
|
manifest.envInputs = dedupeEnvInputs(manifest.envInputs);
|
|
return {
|
|
manifest,
|
|
files: normalizedFiles,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
|
|
function normalizeGitHubSourcePath(value: string | null | undefined) {
|
|
if (!value) return "";
|
|
return value.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
export function parseGitHubSourceUrl(rawUrl: string) {
|
|
const url = new URL(rawUrl);
|
|
if (url.protocol !== "https:") {
|
|
throw unprocessable("GitHub source URL must use HTTPS");
|
|
}
|
|
const hostname = url.hostname;
|
|
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, "");
|
|
const queryRef = url.searchParams.get("ref")?.trim();
|
|
const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path"));
|
|
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
|
|
if (queryRef || queryPath || queryCompanyPath) {
|
|
const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md";
|
|
let basePath = queryPath;
|
|
if (!basePath && companyPath !== "COMPANY.md") {
|
|
basePath = path.posix.dirname(companyPath);
|
|
if (basePath === ".") basePath = "";
|
|
}
|
|
return {
|
|
hostname,
|
|
owner,
|
|
repo,
|
|
ref: queryRef || "main",
|
|
basePath,
|
|
companyPath,
|
|
};
|
|
}
|
|
let ref = "main";
|
|
let basePath = "";
|
|
let companyPath = "COMPANY.md";
|
|
if (parts[2] === "tree") {
|
|
ref = parts[3] ?? "main";
|
|
basePath = parts.slice(4).join("/");
|
|
} else if (parts[2] === "blob") {
|
|
ref = parts[3] ?? "main";
|
|
const blobPath = parts.slice(4).join("/");
|
|
if (!blobPath) {
|
|
throw unprocessable("Invalid GitHub blob URL");
|
|
}
|
|
companyPath = blobPath;
|
|
basePath = path.posix.dirname(blobPath);
|
|
if (basePath === ".") basePath = "";
|
|
}
|
|
return { hostname, owner, repo, ref, basePath, companyPath };
|
|
}
|
|
|
|
|
|
export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|
const companies = companyService(db);
|
|
const agents = agentService(db);
|
|
const agentsLifecycle = createAgentLifecycle(db);
|
|
const assetRecords = assetService(db);
|
|
const instructions = agentInstructionsService(db);
|
|
const access = accessService(db);
|
|
const projects = projectService(db);
|
|
const issues = issueService(db);
|
|
const companySkills = companySkillService(db);
|
|
const secrets = secretService(db);
|
|
const documentsSvc = documentService(db);
|
|
const workProductsSvc = workProductService(db);
|
|
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
|
const defaultSecretProvider = getConfiguredSecretProvider();
|
|
|
|
async function applyImportedAgentPermissionGrants(
|
|
companyId: string,
|
|
agentId: string,
|
|
permissionGrants: PortableAgentPermissionGrant[],
|
|
grantedByUserId: string | null,
|
|
) {
|
|
if (permissionGrants.length === 0) return;
|
|
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
|
|
for (const grant of permissionGrants) {
|
|
await access.setPrincipalPermission(
|
|
companyId,
|
|
"agent",
|
|
agentId,
|
|
grant.permissionKey,
|
|
true,
|
|
grantedByUserId,
|
|
grant.scope ?? null,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertKnownImportAdapterType(type: string | null | undefined): string {
|
|
const adapterType = typeof type === "string" ? type.trim() : "";
|
|
if (!adapterType) {
|
|
throw unprocessable("Adapter type is required");
|
|
}
|
|
if (!findServerAdapter(adapterType)) {
|
|
throw unprocessable(`Unknown adapter type: ${adapterType}`);
|
|
}
|
|
return adapterType;
|
|
}
|
|
|
|
async function assertImportAdapterConfigConstraints(
|
|
companyId: string,
|
|
adapterType: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
) {
|
|
if (adapterType === "paperclip_runner") {
|
|
let profile;
|
|
try {
|
|
if (adapterConfig.provider === "muse") {
|
|
validatePaperclipRunnerMuseConfig(adapterConfig, false);
|
|
return;
|
|
}
|
|
if (adapterConfig.provider === "openai_dot") {
|
|
validatePaperclipRunnerDotConfig(adapterConfig, false);
|
|
return;
|
|
}
|
|
profile = resolvePaperclipRunnerProviderProfile(adapterConfig);
|
|
} catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) {
|
|
throw unprocessable(error.message, { code: error.code });
|
|
}
|
|
throw error;
|
|
}
|
|
if (profile.provider === "claude_managed") {
|
|
await managedAgentProfileService(db).requireQualified(
|
|
companyId,
|
|
profile.managedProfileId,
|
|
);
|
|
} else if (profile.provider === "aws_agentcore") {
|
|
await remoteAgentProfileService(db).requireQualified(
|
|
companyId,
|
|
profile.agentCoreProfileId,
|
|
"aws_bedrock_agentcore_harness",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (adapterType !== "opencode_local") return;
|
|
try {
|
|
requireOpenCodeModelId(adapterConfig.model);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
|
}
|
|
}
|
|
|
|
async function prepareImportedAgentAdapter(
|
|
companyId: string,
|
|
adapterType: string | null | undefined,
|
|
adapterConfig: Record<string, unknown>,
|
|
desiredSkills: string[],
|
|
mode: ImportMode,
|
|
runner?: AgentRunnerChoice,
|
|
defaultEnvironmentId?: string | null,
|
|
) {
|
|
const resolved = await resolveNewAgentRunnerForCompany(db, companyId, {
|
|
adapterType, adapterConfig, runner, defaultEnvironmentId,
|
|
});
|
|
adapterConfig = resolved.adapterConfig;
|
|
const effectiveAdapterType = assertKnownImportAdapterType(resolved.adapterType);
|
|
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
|
|
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
|
|
}
|
|
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
|
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
|
|
desiredSkills,
|
|
);
|
|
delete nextAdapterConfig.promptTemplate;
|
|
delete nextAdapterConfig.bootstrapPromptTemplate;
|
|
delete nextAdapterConfig.instructionsFilePath;
|
|
delete nextAdapterConfig.instructionsBundleMode;
|
|
delete nextAdapterConfig.instructionsRootPath;
|
|
delete nextAdapterConfig.instructionsEntryFile;
|
|
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
|
|
companyId,
|
|
nextAdapterConfig,
|
|
{ strictMode: strictSecretsMode, adapterType: effectiveAdapterType },
|
|
);
|
|
await assertImportAdapterConfigConstraints(
|
|
companyId,
|
|
effectiveAdapterType,
|
|
normalizedAdapterConfig,
|
|
);
|
|
return {
|
|
adapterType: effectiveAdapterType,
|
|
adapterConfig: normalizedAdapterConfig,
|
|
};
|
|
}
|
|
|
|
async function materializeImportEnvInputValues(
|
|
companyId: string,
|
|
manifest: CompanyPortabilityManifest,
|
|
envInputs: CompanyPortabilityEnvInput[],
|
|
secretValues: Record<string, string> | null | undefined,
|
|
actorUserId: string | null | undefined,
|
|
createdSecretIds: string[] = [],
|
|
) {
|
|
if (envInputs.length === 0) return;
|
|
const missingRequired = envInputs.filter((input) => {
|
|
if (input.requirement !== "required") return false;
|
|
const value = envInputValue(input, secretValues);
|
|
return value === null || value.trim().length === 0;
|
|
});
|
|
if (missingRequired.length > 0) {
|
|
throw unprocessable(`Required environment values are missing: ${missingRequired.map(envInputScopedKey).join(", ")}`);
|
|
}
|
|
|
|
for (const input of envInputs) {
|
|
const value = envInputValue(input, secretValues);
|
|
if (value === null || value.trim().length === 0) continue;
|
|
|
|
if (input.kind === "plain") {
|
|
writeManifestEnvBinding(manifest, input, {
|
|
type: "plain",
|
|
value,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const suffix = randomUUID().slice(0, 8);
|
|
const label = importSecretLabel(input);
|
|
const secret = await secrets.create(
|
|
companyId,
|
|
{
|
|
name: `Imported ${label} ${suffix}`,
|
|
key: importSecretKey(input, suffix),
|
|
provider: defaultSecretProvider,
|
|
value,
|
|
description: input.description ?? `Imported ${input.key} for ${label}.`,
|
|
},
|
|
{ userId: actorUserId ?? null, agentId: null },
|
|
);
|
|
createdSecretIds.push(secret.id);
|
|
writeManifestEnvBinding(manifest, input, {
|
|
type: "secret_ref",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
});
|
|
}
|
|
}
|
|
|
|
function resolveImportedAssigneeAgentId(
|
|
assigneeSlug: string | null | undefined,
|
|
importedSlugToAgentId: Map<string, string>,
|
|
existingSlugToAgentId: Map<string, string>,
|
|
agentStatusById: Map<string, string | null | undefined>,
|
|
warnings: string[],
|
|
subjectLabel: string,
|
|
) {
|
|
if (!assigneeSlug) return null;
|
|
const assigneeAgentId =
|
|
importedSlugToAgentId.get(assigneeSlug)
|
|
?? existingSlugToAgentId.get(assigneeSlug)
|
|
?? null;
|
|
if (!assigneeAgentId) return null;
|
|
const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null;
|
|
if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") {
|
|
warnings.push(
|
|
`${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`,
|
|
);
|
|
return null;
|
|
}
|
|
return assigneeAgentId;
|
|
}
|
|
|
|
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
|
|
if (source.type === "inline") {
|
|
return buildManifestFromPackageFiles(
|
|
normalizeFileMap(source.files, source.rootPath),
|
|
);
|
|
}
|
|
|
|
const parsed = parseGitHubSourceUrl(source.url);
|
|
let ref = parsed.ref;
|
|
const warnings: string[] = [];
|
|
const companyRelativePath = parsed.companyPath === "COMPANY.md"
|
|
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
|
|
: parsed.companyPath;
|
|
let companyMarkdown: string | null = null;
|
|
try {
|
|
companyMarkdown = await fetchOptionalText(
|
|
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
|
|
);
|
|
} catch (err) {
|
|
if (ref === "main") {
|
|
ref = "master";
|
|
warnings.push("GitHub ref main not found; falling back to master.");
|
|
companyMarkdown = await fetchOptionalText(
|
|
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
|
|
);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
if (!companyMarkdown) {
|
|
throw unprocessable("GitHub company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyPath = parsed.companyPath === "COMPANY.md"
|
|
? "COMPANY.md"
|
|
: normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath));
|
|
const files: Record<string, CompanyPortabilityFileEntry> = {
|
|
[companyPath]: companyMarkdown,
|
|
};
|
|
const apiBase = gitHubApiBase(parsed.hostname);
|
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
|
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
|
).catch(() => ({ tree: [] }));
|
|
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
|
const candidatePaths = (tree.tree ?? [])
|
|
.filter((entry) => entry.type === "blob")
|
|
.map((entry) => entry.path)
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.filter((entry) => {
|
|
if (basePrefix && !entry.startsWith(basePrefix)) return false;
|
|
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
|
|
return (
|
|
relative.endsWith(".md") ||
|
|
relative.startsWith("skills/") ||
|
|
relative === ".paperclip.yaml" ||
|
|
relative === ".paperclip.yml"
|
|
);
|
|
});
|
|
for (const repoPath of candidatePaths) {
|
|
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
|
|
if (files[relativePath] !== undefined) continue;
|
|
files[normalizePortablePath(relativePath)] = await fetchText(
|
|
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
|
);
|
|
}
|
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
|
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
|
for (const includeEntry of includeEntries) {
|
|
const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/");
|
|
const relativePath = normalizePortablePath(includeEntry.path);
|
|
if (files[relativePath] !== undefined) continue;
|
|
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
|
|
files[relativePath] = await fetchText(
|
|
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
|
);
|
|
}
|
|
|
|
const resolved = buildManifestFromPackageFiles(files);
|
|
const companyLogoPath = resolved.manifest.company?.logoPath;
|
|
if (companyLogoPath && !resolved.files[companyLogoPath]) {
|
|
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
|
|
try {
|
|
const binary = await fetchBinary(
|
|
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
|
);
|
|
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
|
|
} catch (err) {
|
|
warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
resolved.warnings.unshift(...warnings);
|
|
return resolved;
|
|
}
|
|
|
|
async function exportBundle(
|
|
companyId: string,
|
|
input: CompanyPortabilityExport,
|
|
options: { preview?: boolean; allowExternalInstructions?: boolean } = {},
|
|
): Promise<CompanyPortabilityExportResult> {
|
|
const include = normalizeInclude({
|
|
...input.include,
|
|
agents: input.agents && input.agents.length > 0 ? true : input.include?.agents,
|
|
projects: input.projects && input.projects.length > 0 ? true : input.include?.projects,
|
|
issues:
|
|
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
|
? true
|
|
: input.include?.issues,
|
|
skills: input.skills && input.skills.length > 0 ? true : input.include?.skills,
|
|
});
|
|
const company = await companies.getById(companyId);
|
|
if (!company) throw notFound("Company not found");
|
|
|
|
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
|
const warnings: string[] = [];
|
|
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
|
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
|
|
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 agentSelection = resolvePortableExportAgentSelection(allAgentRows, input.agents, include.agents);
|
|
const companySkillRowsRaw = include.skills ? await companySkills.listFull(companyId) : [];
|
|
if (companySkillRowsRaw.some(skill => skill.accessSourceId)) {
|
|
throw unprocessable("Project-restricted skills cannot be exported until the package can preserve their project access.");
|
|
}
|
|
const managedSkillRows = companySkillRowsRaw.filter((skill) => managedSkillIds.has(skill.id));
|
|
const companySkillRows = companySkillRowsRaw.filter((skill) => !managedSkillIds.has(skill.id));
|
|
warnings.push(...agentSelection.warnings);
|
|
if (include.skills && managedSkillRows.length > 0) {
|
|
warnings.push(`Skipped ${managedSkillRows.length} built-in managed skill${managedSkillRows.length === 1 ? "" : "s"} from export.`);
|
|
}
|
|
|
|
const agentRows = agentSelection.agents
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
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);
|
|
}
|
|
const agentPermissionGrantRows = agentRows.length > 0 && typeof (db as { select?: unknown }).select === "function"
|
|
? await db
|
|
.select({
|
|
principalId: principalPermissionGrants.principalId,
|
|
permissionKey: principalPermissionGrants.permissionKey,
|
|
scope: principalPermissionGrants.scope,
|
|
})
|
|
.from(principalPermissionGrants)
|
|
.where(and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, "agent"),
|
|
inArray(principalPermissionGrants.principalId, agentRows.map((agent) => agent.id)),
|
|
))
|
|
: [];
|
|
const permissionGrantsByAgentId = new Map<string, PortableAgentPermissionGrant[]>();
|
|
for (const row of agentPermissionGrantRows) {
|
|
if (!VALID_PERMISSION_KEYS.has(row.permissionKey as PermissionKey)) continue;
|
|
const grants = permissionGrantsByAgentId.get(row.principalId) ?? [];
|
|
grants.push({
|
|
permissionKey: row.permissionKey as PermissionKey,
|
|
scope: isPlainRecord(row.scope) ? row.scope : null,
|
|
});
|
|
permissionGrantsByAgentId.set(row.principalId, grants);
|
|
}
|
|
for (const grants of permissionGrantsByAgentId.values()) {
|
|
grants.sort((left, right) => left.permissionKey.localeCompare(right.permissionKey));
|
|
}
|
|
|
|
const projectsSvc = projectService(db);
|
|
const issuesSvc = issueService(db);
|
|
const routinesSvc = routineService(db);
|
|
const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : [];
|
|
const allProjects = allProjectsRaw.filter((project) => !project.archivedAt);
|
|
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) {
|
|
projectByReference.set(project.id, project);
|
|
projectByReference.set(project.urlKey, project);
|
|
}
|
|
|
|
const selectedProjects = new Map<string, typeof allProjects[number]>();
|
|
const normalizeProjectSelector = (selector: string) => selector.trim().toLowerCase();
|
|
for (const selector of input.projects ?? []) {
|
|
const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector));
|
|
if (!match) {
|
|
warnings.push(`Project selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedProjects.set(match.id, match);
|
|
}
|
|
|
|
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;
|
|
return trimmed.includes("-")
|
|
? issuesSvc.getByIdentifier(trimmed)
|
|
: issuesSvc.getById(trimmed);
|
|
};
|
|
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);
|
|
if (routine.projectId) {
|
|
const parentProject = projectById.get(routine.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
continue;
|
|
}
|
|
warnings.push(`Issue selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedIssues.set(issue.id, issue);
|
|
if (issue.projectId) {
|
|
const parentProject = projectById.get(issue.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
}
|
|
|
|
for (const selector of input.projectIssues ?? []) {
|
|
const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector));
|
|
if (!match) {
|
|
warnings.push(`Project-issues selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedProjects.set(match.id, match);
|
|
const projectIssues = await issuesSvc.list(companyId, { projectId: match.id });
|
|
for (const issue of projectIssues) {
|
|
selectedIssues.set(issue.id, issue);
|
|
}
|
|
for (const routine of allRoutines.filter((entry) => entry.projectId === match.id)) {
|
|
selectedRoutines.set(routine.id, routine);
|
|
}
|
|
}
|
|
|
|
if (include.projects && selectedProjects.size === 0) {
|
|
for (const project of allProjects) {
|
|
selectedProjects.set(project.id, project);
|
|
}
|
|
}
|
|
|
|
if (include.issues && selectedIssues.size === 0) {
|
|
const allIssues = await issuesSvc.list(companyId);
|
|
for (const issue of allIssues) {
|
|
selectedIssues.set(issue.id, issue);
|
|
if (issue.projectId) {
|
|
const parentProject = projectById.get(issue.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
}
|
|
if (selectedRoutines.size === 0) {
|
|
for (const routine of allRoutines) {
|
|
selectedRoutines.set(routine.id, routine);
|
|
if (routine.projectId) {
|
|
const parentProject = projectById.get(routine.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectedProjectRows = Array.from(selectedProjects.values())
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
const selectedIssueRows = Array.from(selectedIssues.values())
|
|
.filter((issue): issue is NonNullable<typeof issue> => issue != null)
|
|
.sort((left, right) => (left.identifier ?? left.title).localeCompare(right.identifier ?? right.title));
|
|
const selectedRoutineSummaries = Array.from(selectedRoutines.values())
|
|
.sort((left, right) => left.title.localeCompare(right.title));
|
|
const selectedRoutineRows = (
|
|
await Promise.all(selectedRoutineSummaries.map((routine) => routinesSvc.getDetail(routine.id)))
|
|
).filter((routine): routine is RoutineLike => routine !== null);
|
|
|
|
const taskSlugByIssueId = new Map<string, string>();
|
|
const taskSlugByRoutineId = new Map<string, string>();
|
|
const usedTaskSlugs = new Set<string>();
|
|
for (const issue of selectedIssueRows) {
|
|
const baseSlug = normalizeAgentUrlKey(issue.identifier ?? issue.title) ?? "task";
|
|
taskSlugByIssueId.set(issue.id, uniqueSlug(baseSlug, usedTaskSlugs));
|
|
}
|
|
for (const routine of selectedRoutineRows) {
|
|
const baseSlug = normalizeAgentUrlKey(routine.title) ?? "task";
|
|
taskSlugByRoutineId.set(routine.id, uniqueSlug(baseSlug, usedTaskSlugs));
|
|
}
|
|
|
|
const projectSlugById = new Map<string, string>();
|
|
const projectWorkspaceKeyByProjectId = new Map<string, Map<string, string>>();
|
|
const usedProjectSlugs = new Set<string>();
|
|
for (const project of selectedProjectRows) {
|
|
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
|
projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs));
|
|
}
|
|
const sidebarOrder = requestedSidebarOrder ?? stripEmptyValues({
|
|
agents: sortAgentsBySidebarOrder(agentSelection.agents)
|
|
.map((agent) => idToSlug.get(agent.id))
|
|
.filter((slug): slug is string => Boolean(slug)),
|
|
projects: selectedProjectRows
|
|
.map((project) => projectSlugById.get(project.id))
|
|
.filter((slug): slug is string => Boolean(slug)),
|
|
});
|
|
|
|
const companyPath = "COMPANY.md";
|
|
files[companyPath] = buildMarkdown(
|
|
{
|
|
name: company.name,
|
|
description: company.description ?? null,
|
|
schema: "agentcompanies/v1",
|
|
slug: rootPath,
|
|
},
|
|
"",
|
|
);
|
|
|
|
if (include.company && company.logoAssetId) {
|
|
if (!storage) {
|
|
warnings.push("Skipped company logo from export because storage is unavailable.");
|
|
} else {
|
|
const logoAsset = await assetRecords.getById(company.logoAssetId);
|
|
if (!logoAsset) {
|
|
warnings.push(`Skipped company logo ${company.logoAssetId} because the asset record was not found.`);
|
|
} else if (await isRunnerApiResponseAsset(db, logoAsset)) {
|
|
warnings.push("Skipped private API response snapshot used as a company logo.");
|
|
} else {
|
|
try {
|
|
const object = await storage.getObject(company.id, logoAsset.objectKey);
|
|
const body = await streamToBuffer(object.stream);
|
|
companyLogoPath = `images/${COMPANY_LOGO_FILE_NAME}${resolveCompanyLogoExtension(logoAsset.contentType, logoAsset.originalFilename)}`;
|
|
files[companyLogoPath] = bufferToPortableBinaryFile(body, logoAsset.contentType);
|
|
} catch (err) {
|
|
warnings.push(`Failed to export company logo ${company.logoAssetId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const paperclipAgentsOut: Record<string, Record<string, unknown>> = {};
|
|
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
|
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
|
const unportableTaskWorkspaceRefs = new Map<string, { workspaceId: string; taskSlugs: string[] }>();
|
|
const paperclipRoutinesOut: Record<string, Record<string, unknown>> = {};
|
|
|
|
const skillByReference = new Map<string, typeof companySkillRows[number]>();
|
|
for (const skill of companySkillRows) {
|
|
skillByReference.set(skill.id, skill);
|
|
skillByReference.set(skill.key, skill);
|
|
skillByReference.set(skill.slug, skill);
|
|
skillByReference.set(skill.name, skill);
|
|
}
|
|
const selectedSkills = new Map<string, typeof companySkillRows[number]>();
|
|
for (const selector of input.skills ?? []) {
|
|
const trimmed = selector.trim();
|
|
if (!trimmed) continue;
|
|
const normalized = normalizeSkillKey(trimmed) ?? normalizeSkillSlug(trimmed) ?? trimmed;
|
|
const match = skillByReference.get(trimmed) ?? skillByReference.get(normalized);
|
|
if (!match) {
|
|
warnings.push(`Skill selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedSkills.set(match.id, match);
|
|
}
|
|
if (selectedSkills.size === 0) {
|
|
for (const skill of companySkillRows) {
|
|
selectedSkills.set(skill.id, skill);
|
|
}
|
|
}
|
|
const selectedSkillRows = Array.from(selectedSkills.values())
|
|
.sort((left, right) => left.key.localeCompare(right.key));
|
|
|
|
const skillExportDirs = buildSkillExportDirMap(selectedSkillRows, company.issuePrefix);
|
|
const skillFileJobs: Array<{ filePath: string; load: () => Promise<string | null> }> = [];
|
|
for (const skill of selectedSkillRows) {
|
|
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
|
skillFileJobs.push({
|
|
filePath: `${packageDir}/SKILL.md`,
|
|
load: () => buildReferencedSkillMarkdown(skill),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const inventoryEntry of skill.fileInventory) {
|
|
skillFileJobs.push({
|
|
filePath: `${packageDir}/${inventoryEntry.path}`,
|
|
load: async () => {
|
|
const fileDetail = await companySkills
|
|
.readFile(companyId, skill.id, inventoryEntry.path)
|
|
.catch(() => null);
|
|
if (!fileDetail) return null;
|
|
return inventoryEntry.path === "SKILL.md"
|
|
? withSkillSourceMetadata(skill, fileDetail.content)
|
|
: fileDetail.content;
|
|
},
|
|
});
|
|
}
|
|
}
|
|
const skillFileResults = await mapWithConcurrency(
|
|
skillFileJobs,
|
|
EXPORT_READ_CONCURRENCY,
|
|
async (job) => ({ filePath: job.filePath, content: await job.load() }),
|
|
);
|
|
for (const result of skillFileResults) {
|
|
if (result.content !== null) files[result.filePath] = result.content;
|
|
}
|
|
|
|
if (include.agents) {
|
|
if (
|
|
!options.allowExternalInstructions
|
|
&& agentRows.some((agent) => agentInstructionsBundleMode(agent) === "external")
|
|
) {
|
|
throw forbidden("Instance admin access is required to export external instruction bundles");
|
|
}
|
|
const agentInstructionsById = new Map(
|
|
await mapWithConcurrency(agentRows, EXPORT_READ_CONCURRENCY, async (agent) => (
|
|
[agent.id, await instructions.exportFiles(agent)] as const
|
|
)),
|
|
);
|
|
for (const agent of agentRows) {
|
|
const slug = idToSlug.get(agent.id)!;
|
|
const exportedInstructions = agentInstructionsById.get(agent.id)!;
|
|
warnings.push(...exportedInstructions.warnings);
|
|
|
|
const envInputsStart = envInputs.length;
|
|
const exportedEnvInputs = extractPortableEnvInputs(
|
|
slug,
|
|
(agent.adapterConfig as Record<string, unknown>).env,
|
|
warnings,
|
|
);
|
|
envInputs.push(...exportedEnvInputs);
|
|
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
|
|
const portableAdapterConfig = pruneDefaultLikeValue(
|
|
normalizePortableConfig(agent.adapterConfig),
|
|
{
|
|
dropFalseBooleans: true,
|
|
defaultRules: adapterDefaultRules,
|
|
},
|
|
) as Record<string, unknown>;
|
|
const portableRuntimeConfig = pruneDefaultLikeValue(
|
|
normalizePortableConfig(agent.runtimeConfig),
|
|
{
|
|
dropFalseBooleans: true,
|
|
defaultRules: RUNTIME_DEFAULT_RULES,
|
|
},
|
|
) as Record<string, unknown>;
|
|
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
|
const portablePermissionGrants = permissionGrantsByAgentId.get(agent.id) ?? [];
|
|
const agentEnvInputs = dedupeEnvInputs(
|
|
envInputs
|
|
.slice(envInputsStart)
|
|
.filter((inputValue) => inputValue.agentSlug === slug),
|
|
);
|
|
const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null;
|
|
const desiredSkills = readPaperclipSkillSyncPreference(
|
|
(agent.adapterConfig as Record<string, unknown>) ?? {},
|
|
).desiredSkills;
|
|
|
|
const commandValue = asString(portableAdapterConfig.command);
|
|
if (commandValue && isAbsoluteCommand(commandValue)) {
|
|
warnings.push(`Agent ${slug} command ${commandValue} was omitted from export because it is system-dependent.`);
|
|
delete portableAdapterConfig.command;
|
|
}
|
|
for (const [relativePath, content] of Object.entries(exportedInstructions.files)) {
|
|
const targetPath = `agents/${slug}/${relativePath}`;
|
|
if (relativePath === exportedInstructions.entryFile) {
|
|
files[targetPath] = buildMarkdown(
|
|
stripEmptyValues({
|
|
name: agent.name,
|
|
title: agent.title ?? null,
|
|
reportsTo: reportsToSlug,
|
|
skills: desiredSkills.length > 0 ? desiredSkills : undefined,
|
|
}) as Record<string, unknown>,
|
|
content,
|
|
);
|
|
} else {
|
|
files[targetPath] = content;
|
|
}
|
|
}
|
|
|
|
const extension = stripEmptyValues({
|
|
role: agent.role !== "agent" ? agent.role : undefined,
|
|
icon: agent.icon ?? null,
|
|
appearance: agent.appearance == null ? undefined : portableAgentAppearance(agent.appearance, warnings, slug),
|
|
capabilities: agent.capabilities ?? null,
|
|
adapter: {
|
|
type: agent.adapterType,
|
|
runner: agentRunner(agent.adapterType),
|
|
config: portableAdapterConfig,
|
|
},
|
|
runtime: portableRuntimeConfig,
|
|
permissions: portablePermissions,
|
|
permissionGrants: portablePermissionGrants.length > 0 ? portablePermissionGrants : undefined,
|
|
budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined,
|
|
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
|
});
|
|
if (isPlainRecord(extension) && agentEnvInputs.length > 0) {
|
|
extension.inputs = {
|
|
env: buildEnvInputMap(agentEnvInputs),
|
|
};
|
|
}
|
|
paperclipAgentsOut[slug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
}
|
|
|
|
for (const project of selectedProjectRows) {
|
|
const slug = projectSlugById.get(project.id)!;
|
|
const projectPath = `projects/${slug}/PROJECT.md`;
|
|
const envInputsStart = envInputs.length;
|
|
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
|
|
envInputs.push(...exportedEnvInputs);
|
|
const projectEnvInputs = dedupeEnvInputs(
|
|
envInputs
|
|
.slice(envInputsStart)
|
|
.filter((inputValue) => inputValue.projectSlug === slug),
|
|
);
|
|
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
|
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
|
files[projectPath] = buildMarkdown(
|
|
{
|
|
name: project.name,
|
|
description: project.description ?? null,
|
|
owner: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
|
},
|
|
project.description ?? "",
|
|
);
|
|
const extension = stripEmptyValues({
|
|
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
|
targetDate: project.targetDate ?? null,
|
|
color: project.color ?? null,
|
|
icon: project.icon ?? null,
|
|
status: project.status,
|
|
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
|
slug,
|
|
project.executionWorkspacePolicy,
|
|
portableWorkspaces.workspaceKeyById,
|
|
warnings,
|
|
) ?? undefined,
|
|
workspaces: portableWorkspaces.extension,
|
|
});
|
|
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
|
|
extension.inputs = {
|
|
env: buildEnvInputMap(projectEnvInputs),
|
|
};
|
|
}
|
|
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
|
|
const referencedLabelIds = new Set<string>();
|
|
for (const issue of selectedIssueRows) {
|
|
for (const labelId of issue.labelIds ?? []) referencedLabelIds.add(labelId);
|
|
}
|
|
const labelNameById = new Map<string, string>();
|
|
const exportedLabels: Array<{ name: string; color: string }> = [];
|
|
if (referencedLabelIds.size > 0) {
|
|
for (const label of await issuesSvc.listLabels(companyId)) {
|
|
if (!referencedLabelIds.has(label.id)) continue;
|
|
labelNameById.set(label.id, label.name);
|
|
exportedLabels.push({ name: label.name, color: label.color });
|
|
}
|
|
exportedLabels.sort((left, right) => left.name.localeCompare(right.name));
|
|
const missingLabelIds = Array.from(referencedLabelIds).filter((labelId) => !labelNameById.has(labelId));
|
|
if (missingLabelIds.length > 0) {
|
|
warnings.push(`Skipped ${missingLabelIds.length} task label reference${missingLabelIds.length === 1 ? "" : "s"} whose label definitions no longer exist.`);
|
|
}
|
|
}
|
|
|
|
let unexportedBlockerEdgeCount = 0;
|
|
let unexportedParentEdgeCount = 0;
|
|
let unportableWorkProductRefCount = 0;
|
|
const exportedBlobs = new Map<string, CompanyPortabilityBlobManifestEntry>();
|
|
// A task export needs several independent relations per task. Load a
|
|
// bounded number of task groups in parallel so large companies do not pay
|
|
// thousands of serialized database round trips, while still respecting
|
|
// the default database pool size.
|
|
const issueExportDetails = new Map(
|
|
await mapWithConcurrency(
|
|
selectedIssueRows,
|
|
EXPORT_ISSUE_READ_CONCURRENCY,
|
|
async (issue) => {
|
|
const [comments, relationSummaries, issueDocumentRows, workProductRows, attachmentRows] = await Promise.all([
|
|
issuesSvc.listComments(issue.id, { order: "asc" }),
|
|
issuesSvc.getRelationSummaries(issue.id),
|
|
documentsSvc.listIssueDocuments(issue.id, { includeSystem: true }),
|
|
workProductsSvc.listForIssue(issue.id),
|
|
issuesSvc.listAttachments(issue.id),
|
|
]);
|
|
return [issue.id, {
|
|
comments,
|
|
relationSummaries,
|
|
issueDocumentRows,
|
|
workProductRows,
|
|
attachmentRows: attachmentRows
|
|
.slice()
|
|
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime()),
|
|
}] as const;
|
|
},
|
|
),
|
|
);
|
|
for (const issue of selectedIssueRows) {
|
|
const details = issueExportDetails.get(issue.id)!;
|
|
const taskSlug = taskSlugByIssueId.get(issue.id)!;
|
|
const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null;
|
|
// All tasks go in top-level tasks/ folder, never nested under projects/
|
|
const taskPath = `tasks/${taskSlug}/TASK.md`;
|
|
const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null;
|
|
const projectWorkspaceKey = issue.projectId && issue.projectWorkspaceId
|
|
? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null
|
|
: null;
|
|
if (issue.projectWorkspaceId && !projectWorkspaceKey) {
|
|
const aggregateKey = `${issue.projectId ?? "no-project"}:${issue.projectWorkspaceId}`;
|
|
const existing = unportableTaskWorkspaceRefs.get(aggregateKey);
|
|
if (existing) {
|
|
existing.taskSlugs.push(taskSlug);
|
|
} else {
|
|
unportableTaskWorkspaceRefs.set(aggregateKey, {
|
|
workspaceId: issue.projectWorkspaceId,
|
|
taskSlugs: [taskSlug],
|
|
});
|
|
}
|
|
}
|
|
const comments = details.comments;
|
|
// Blocker edges travel by task slug; only edges with both endpoints in
|
|
// the export can be carried.
|
|
const relationSummaries = details.relationSummaries;
|
|
const blockedBySlugs: string[] = [];
|
|
for (const blocker of relationSummaries.blockedBy) {
|
|
const blockerSlug = taskSlugByIssueId.get(blocker.id);
|
|
if (blockerSlug) {
|
|
blockedBySlugs.push(blockerSlug);
|
|
} else {
|
|
unexportedBlockerEdgeCount += 1;
|
|
}
|
|
}
|
|
blockedBySlugs.sort((left, right) => left.localeCompare(right));
|
|
unexportedBlockerEdgeCount += relationSummaries.blocks
|
|
.filter((blocked) => !taskSlugByIssueId.has(blocked.id))
|
|
.length;
|
|
// Parent links travel by task slug like blockers; a parent outside the
|
|
// export selection drops the edge and is counted for one warning.
|
|
let parentTaskSlug: string | null = null;
|
|
if (issue.parentId) {
|
|
parentTaskSlug = taskSlugByIssueId.get(issue.parentId) ?? null;
|
|
if (!parentTaskSlug) unexportedParentEdgeCount += 1;
|
|
}
|
|
const issueDocumentRows = details.issueDocumentRows;
|
|
const documentEntries = issueDocumentRows.map((document) => {
|
|
const documentPath = `tasks/${taskSlug}/documents/${document.key}.md`;
|
|
files[documentPath] = document.body ?? "";
|
|
return {
|
|
key: document.key,
|
|
title: document.title ?? null,
|
|
format: document.format,
|
|
path: documentPath,
|
|
};
|
|
});
|
|
const workProductRows = details.workProductRows;
|
|
const workProductEntries = workProductRows.map((workProduct) => {
|
|
if (workProduct.executionWorkspaceId || workProduct.runtimeServiceId || workProduct.createdByRunId) {
|
|
unportableWorkProductRefCount += 1;
|
|
}
|
|
return stripEmptyValues({
|
|
type: workProduct.type,
|
|
provider: workProduct.provider,
|
|
externalId: workProduct.externalId ?? null,
|
|
title: workProduct.title,
|
|
url: workProduct.url ?? null,
|
|
status: workProduct.status,
|
|
reviewState: workProduct.reviewState !== "none" ? workProduct.reviewState : undefined,
|
|
isPrimary: workProduct.isPrimary ? true : undefined,
|
|
healthStatus: workProduct.healthStatus !== "unknown" ? workProduct.healthStatus : undefined,
|
|
summary: workProduct.summary ?? null,
|
|
metadata: workProduct.metadata ?? null,
|
|
});
|
|
});
|
|
// Attachment bytes travel as content-addressed blobs/<sha256> entries,
|
|
// deduped across the bundle; each per-task entry references its blob by
|
|
// hash and its comment by index into the exported comments array.
|
|
const attachmentRows = details.attachmentRows;
|
|
const commentIndexById = new Map(comments.map((comment, index) => [comment.id, index] as const));
|
|
const attachmentEntries: Array<Record<string, unknown>> = [];
|
|
if (attachmentRows.length > 0 && !storage) {
|
|
warnings.push(`Skipped ${attachmentRows.length} attachment${attachmentRows.length === 1 ? "" : "s"} on task ${taskSlug} because storage is unavailable.`);
|
|
} else if (storage) {
|
|
for (const attachment of attachmentRows) {
|
|
let body: Buffer;
|
|
try {
|
|
const object = await storage.getObject(companyId, attachment.objectKey);
|
|
body = await streamToBuffer(object.stream);
|
|
} catch (err) {
|
|
warnings.push(`Skipped attachment ${attachment.originalFilename ?? attachment.sha256} on task ${taskSlug} because its stored object could not be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
continue;
|
|
}
|
|
// Blobs are addressed by the hash of the bytes actually read; a
|
|
// stale asset-row hash loses to the recomputed one.
|
|
const sha256 = sha256HexOfBytes(body);
|
|
if (attachment.sha256 && attachment.sha256.toLowerCase() !== sha256) {
|
|
warnings.push(`Attachment ${attachment.originalFilename ?? attachment.sha256} on task ${taskSlug} was exported under its recomputed content hash because the stored hash did not match.`);
|
|
}
|
|
if (!exportedBlobs.has(sha256)) {
|
|
files[portableBlobPath(sha256)] = bufferToPortableBinaryFile(body, PORTABLE_BLOB_CONTENT_TYPE);
|
|
exportedBlobs.set(sha256, { sha256, byteSize: body.length, contentType: PORTABLE_BLOB_CONTENT_TYPE });
|
|
}
|
|
attachmentEntries.push({
|
|
sha256,
|
|
contentType: attachment.contentType ?? PORTABLE_BLOB_CONTENT_TYPE,
|
|
originalFilename: attachment.originalFilename ?? null,
|
|
byteSize: body.length,
|
|
commentIndex: attachment.issueCommentId != null
|
|
? commentIndexById.get(attachment.issueCommentId) ?? null
|
|
: null,
|
|
});
|
|
}
|
|
}
|
|
files[taskPath] = buildMarkdown(
|
|
{
|
|
name: issue.title,
|
|
project: projectSlug,
|
|
assignee: assigneeSlug,
|
|
},
|
|
issue.description ?? "",
|
|
);
|
|
const extension = stripEmptyValues({
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
priority: issue.priority,
|
|
parent: parentTaskSlug ?? undefined,
|
|
createdAt: toPortableTimestamp(issue.createdAt),
|
|
updatedAt: toPortableTimestamp(issue.updatedAt),
|
|
startedAt: toPortableTimestamp(issue.startedAt),
|
|
completedAt: toPortableTimestamp(issue.completedAt),
|
|
cancelledAt: toPortableTimestamp(issue.cancelledAt),
|
|
// Labels travel by name (their natural key); the bundle-level labels
|
|
// section carries the matching color definitions.
|
|
labels: (issue.labelIds ?? [])
|
|
.map((labelId) => labelNameById.get(labelId))
|
|
.filter((name): name is string => Boolean(name)),
|
|
billingCode: issue.billingCode ?? null,
|
|
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
|
|
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
|
|
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
|
|
comments: comments.length > 0
|
|
? comments.map((comment) => ({
|
|
body: comment.body,
|
|
authorType: comment.authorType,
|
|
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
|
|
// Portable bundles preserve author kind, but not raw board user ids.
|
|
authorUserId: null,
|
|
presentation: comment.presentation,
|
|
metadata: comment.metadata,
|
|
createdAt: comment.createdAt instanceof Date
|
|
? comment.createdAt.toISOString()
|
|
: new Date(comment.createdAt).toISOString(),
|
|
}))
|
|
: undefined,
|
|
blockedBy: blockedBySlugs,
|
|
documents: documentEntries,
|
|
workProducts: workProductEntries,
|
|
attachments: attachmentEntries,
|
|
monitor: {
|
|
notes: issue.monitorNotes ?? null,
|
|
scheduledBy: issue.monitorScheduledBy ?? null,
|
|
// Timestamps are not portable; the importer restores monitors
|
|
// un-armed, so only the fact that a check was scheduled travels.
|
|
hadSchedule: issue.monitorNextCheckAt != null ? true : undefined,
|
|
},
|
|
});
|
|
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
|
|
if (unexportedBlockerEdgeCount > 0) {
|
|
warnings.push(`${unexportedBlockerEdgeCount} blocker relation${unexportedBlockerEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedBlockerEdgeCount === 1 ? "was" : "were"} not included.`);
|
|
}
|
|
if (unexportedParentEdgeCount > 0) {
|
|
warnings.push(`${unexportedParentEdgeCount} parent relation${unexportedParentEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedParentEdgeCount === 1 ? "was" : "were"} not included.`);
|
|
}
|
|
if (unportableWorkProductRefCount > 0) {
|
|
warnings.push(`${unportableWorkProductRefCount} work product${unportableWorkProductRefCount === 1 ? " references" : "s reference"} execution workspaces or runs that are not portable; those references were omitted from the export.`);
|
|
}
|
|
|
|
for (const { workspaceId, taskSlugs } of unportableTaskWorkspaceRefs.values()) {
|
|
const preview = taskSlugs.slice(0, 4).join(", ");
|
|
const remainder = taskSlugs.length > 4 ? ` and ${taskSlugs.length - 4} more` : "";
|
|
warnings.push(`Tasks ${preview}${remainder} reference workspace ${workspaceId}, but that workspace could not be exported portably.`);
|
|
}
|
|
|
|
for (const routine of selectedRoutineRows) {
|
|
const taskSlug = taskSlugByRoutineId.get(routine.id)!;
|
|
const projectSlug = routine.projectId ? (projectSlugById.get(routine.projectId) ?? null) : null;
|
|
const taskPath = `tasks/${taskSlug}/TASK.md`;
|
|
const assigneeSlug = routine.assigneeAgentId ? (idToSlug.get(routine.assigneeAgentId) ?? null) : null;
|
|
files[taskPath] = buildMarkdown(
|
|
{
|
|
name: routine.title,
|
|
project: projectSlug,
|
|
assignee: assigneeSlug,
|
|
recurring: true,
|
|
},
|
|
routine.description ?? "",
|
|
);
|
|
const extension = stripEmptyValues({
|
|
status: routine.status !== "active" ? routine.status : undefined,
|
|
priority: routine.priority !== "medium" ? routine.priority : undefined,
|
|
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
|
|
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
|
|
variables: (routine.variables ?? []).length > 0 ? routine.variables : undefined,
|
|
triggers: routine.triggers.map((trigger) => stripEmptyValues({
|
|
kind: trigger.kind,
|
|
label: trigger.label ?? null,
|
|
enabled: trigger.enabled ? undefined : false,
|
|
cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : undefined,
|
|
timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : undefined,
|
|
signingMode: trigger.kind === "webhook" && trigger.signingMode !== "bearer" ? trigger.signingMode ?? null : undefined,
|
|
replayWindowSec: trigger.kind === "webhook" && trigger.replayWindowSec !== 300
|
|
? trigger.replayWindowSec ?? null
|
|
: undefined,
|
|
})),
|
|
});
|
|
paperclipRoutinesOut[taskSlug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
|
|
// Exported markdown can embed company asset images as
|
|
// /api/assets/<id>/content references (issue descriptions and documents,
|
|
// agent instructions, comment bodies). Ship the referenced bytes as
|
|
// content-addressed blobs plus an embeddedAssets index so imports can
|
|
// recreate the assets and rewrite every reference. Each entry records
|
|
// which export categories reference it, so selection can follow the
|
|
// toggles of the referencing files.
|
|
const embeddedAssetOwners = new Map<string, Set<string>>();
|
|
const routineTaskSlugs = new Set(taskSlugByRoutineId.values());
|
|
const categorizeEmbeddedAssetOwner = (filePath: string) => {
|
|
if (filePath.startsWith("agents/")) return "agents";
|
|
if (filePath.startsWith("projects/")) return "projects";
|
|
if (filePath.startsWith("skills/")) return "skills";
|
|
const taskMatch = filePath.match(/^tasks\/([^/]+)\//);
|
|
if (taskMatch) return routineTaskSlugs.has(taskMatch[1]!) ? "routines" : "tasks";
|
|
return "always";
|
|
};
|
|
const noteEmbeddedAssetReference = (assetId: string, owner: string) => {
|
|
const owners = embeddedAssetOwners.get(assetId) ?? new Set<string>();
|
|
owners.add(owner);
|
|
embeddedAssetOwners.set(assetId, owners);
|
|
};
|
|
for (const [filePath, content] of Object.entries(files)) {
|
|
if (typeof content !== "string") continue;
|
|
for (const assetId of collectEmbeddedAssetIds(content)) {
|
|
noteEmbeddedAssetReference(assetId, categorizeEmbeddedAssetOwner(filePath));
|
|
}
|
|
}
|
|
// Comment bodies travel in the extension yaml rather than TASK.md, so
|
|
// scan the assembled task extension entries for their references too.
|
|
for (const extension of Object.values(paperclipTasksOut)) {
|
|
for (const assetId of collectEmbeddedAssetIds(JSON.stringify(extension.comments ?? []))) {
|
|
noteEmbeddedAssetReference(assetId, "tasks");
|
|
}
|
|
}
|
|
|
|
const embeddedAssetIndex: CompanyPortabilityEmbeddedAssetManifestEntry[] = [];
|
|
let unownedEmbeddedAssetRefCount = 0;
|
|
const referencedEmbeddedAssetIds = Array.from(embeddedAssetOwners.keys())
|
|
.sort((left, right) => left.localeCompare(right));
|
|
if (referencedEmbeddedAssetIds.length > 0 && !storage) {
|
|
warnings.push(`Skipped ${referencedEmbeddedAssetIds.length} embedded image asset${referencedEmbeddedAssetIds.length === 1 ? "" : "s"} because storage is unavailable.`);
|
|
} else if (storage) {
|
|
for (const assetId of referencedEmbeddedAssetIds) {
|
|
const asset = await assetRecords.getById(assetId);
|
|
// Embedded references only pull bytes for assets owned by the
|
|
// exporting company: a crafted URL naming another company's asset id
|
|
// must not leak that asset's bytes into the bundle.
|
|
if (!asset || asset.companyId !== companyId || await isRunnerApiResponseAsset(db, asset)) {
|
|
unownedEmbeddedAssetRefCount += 1;
|
|
continue;
|
|
}
|
|
let body: Buffer;
|
|
try {
|
|
const object = await storage.getObject(companyId, asset.objectKey);
|
|
body = await streamToBuffer(object.stream);
|
|
} catch (err) {
|
|
warnings.push(`Skipped embedded image asset ${asset.originalFilename ?? assetId} because its stored object could not be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
continue;
|
|
}
|
|
// Blobs are addressed by the hash of the bytes actually read; a
|
|
// stale asset-row hash loses to the recomputed one.
|
|
const sha256 = sha256HexOfBytes(body);
|
|
if (!exportedBlobs.has(sha256)) {
|
|
files[portableBlobPath(sha256)] = bufferToPortableBinaryFile(body, PORTABLE_BLOB_CONTENT_TYPE);
|
|
exportedBlobs.set(sha256, { sha256, byteSize: body.length, contentType: PORTABLE_BLOB_CONTENT_TYPE });
|
|
}
|
|
const owners = embeddedAssetOwners.get(assetId) ?? new Set<string>();
|
|
embeddedAssetIndex.push({
|
|
assetId,
|
|
sha256,
|
|
contentType: asset.contentType ?? PORTABLE_BLOB_CONTENT_TYPE,
|
|
originalFilename: asset.originalFilename ?? null,
|
|
ownedBy: Array.from(owners).sort((left, right) => left.localeCompare(right)),
|
|
});
|
|
}
|
|
}
|
|
if (unownedEmbeddedAssetRefCount > 0) {
|
|
warnings.push(unownedEmbeddedAssetRefCount === 1
|
|
? "1 embedded image reference points at an asset that is unavailable for export (missing, outside this company, or a private API response snapshot); its image was not exported."
|
|
: `${unownedEmbeddedAssetRefCount} embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.`);
|
|
}
|
|
|
|
const paperclipExtensionPath = ".paperclip.yaml";
|
|
const exportedBlobIndex = Array.from(exportedBlobs.values())
|
|
.sort((left, right) => left.sha256.localeCompare(right.sha256));
|
|
const paperclipAgents = Object.fromEntries(
|
|
Object.entries(paperclipAgentsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
const paperclipProjects = Object.fromEntries(
|
|
Object.entries(paperclipProjectsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
const paperclipTasks = Object.fromEntries(
|
|
Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
const paperclipRoutines = Object.fromEntries(
|
|
Object.entries(paperclipRoutinesOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
files[paperclipExtensionPath] = buildYamlFile(
|
|
{
|
|
schema: "paperclip/v1",
|
|
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
company: stripEmptyValues({
|
|
logoPath: companyLogoPath,
|
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
|
|
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
|
|
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
|
|
feedbackDataSharingConsentByUserId: company.feedbackDataSharingConsentByUserId ?? null,
|
|
feedbackDataSharingTermsVersion: company.feedbackDataSharingTermsVersion ?? null,
|
|
}),
|
|
sidebar: stripEmptyValues(sidebarOrder),
|
|
labels: exportedLabels.length > 0 ? exportedLabels : undefined,
|
|
blobs: exportedBlobIndex.length > 0 ? exportedBlobIndex : undefined,
|
|
embeddedAssets: embeddedAssetIndex.length > 0 ? embeddedAssetIndex : undefined,
|
|
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
|
projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined,
|
|
tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined,
|
|
routines: Object.keys(paperclipRoutines).length > 0 ? paperclipRoutines : undefined,
|
|
},
|
|
{ preserveEmptyStrings: true },
|
|
);
|
|
|
|
let finalFiles = filterExportFiles(files, input.selectedFiles, paperclipExtensionPath);
|
|
let resolved = buildManifestFromPackageFiles(finalFiles, {
|
|
sourceLabel: {
|
|
companyId: company.id,
|
|
companyName: company.name,
|
|
},
|
|
});
|
|
resolved.manifest.includes = {
|
|
company: resolved.manifest.company !== null,
|
|
agents: resolved.manifest.agents.length > 0,
|
|
projects: resolved.manifest.projects.length > 0,
|
|
issues: resolved.manifest.issues.length > 0,
|
|
skills: resolved.manifest.skills.length > 0,
|
|
};
|
|
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
|
resolved.warnings.unshift(...warnings);
|
|
|
|
// Generate org chart PNG from manifest agents
|
|
if (!options.preview && resolved.manifest.agents.length > 0) {
|
|
try {
|
|
const orgNodes = buildOrgTreeFromManifest(resolved.manifest.agents);
|
|
const pngBuffer = await renderOrgChartPng(orgNodes);
|
|
finalFiles["images/org-chart.png"] = bufferToPortableBinaryFile(pngBuffer, "image/png");
|
|
} catch {
|
|
// Non-fatal: export still works without the org chart image
|
|
}
|
|
}
|
|
|
|
if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) {
|
|
finalFiles["README.md"] = generateReadme(resolved.manifest, {
|
|
companyName: company.name,
|
|
companyDescription: company.description ?? null,
|
|
});
|
|
}
|
|
|
|
resolved = buildManifestFromPackageFiles(finalFiles, {
|
|
sourceLabel: {
|
|
companyId: company.id,
|
|
companyName: company.name,
|
|
},
|
|
});
|
|
resolved.manifest.includes = {
|
|
company: resolved.manifest.company !== null,
|
|
agents: resolved.manifest.agents.length > 0,
|
|
projects: resolved.manifest.projects.length > 0,
|
|
issues: resolved.manifest.issues.length > 0,
|
|
skills: resolved.manifest.skills.length > 0,
|
|
};
|
|
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
|
resolved.warnings.unshift(...warnings);
|
|
|
|
return {
|
|
rootPath,
|
|
manifest: resolved.manifest,
|
|
files: finalFiles,
|
|
warnings: resolved.warnings,
|
|
paperclipExtensionPath,
|
|
};
|
|
}
|
|
|
|
async function previewExport(
|
|
companyId: string,
|
|
input: CompanyPortabilityExport,
|
|
options: { allowExternalInstructions?: boolean } = {},
|
|
): Promise<CompanyPortabilityExportPreviewResult> {
|
|
const previewInput: CompanyPortabilityExport = {
|
|
...input,
|
|
include: {
|
|
...input.include,
|
|
issues:
|
|
input.include?.issues
|
|
?? Boolean((input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0))
|
|
?? false,
|
|
},
|
|
};
|
|
if (previewInput.include && previewInput.include.issues === undefined) {
|
|
previewInput.include.issues = false;
|
|
}
|
|
const exported = await exportBundle(companyId, previewInput, {
|
|
preview: true,
|
|
allowExternalInstructions: options.allowExternalInstructions,
|
|
});
|
|
return {
|
|
...exported,
|
|
fileInventory: Object.keys(exported.files)
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.map((filePath) => ({
|
|
path: filePath,
|
|
kind: classifyPortableFileKind(filePath),
|
|
})),
|
|
counts: {
|
|
files: Object.keys(exported.files).length,
|
|
agents: exported.manifest.agents.length,
|
|
skills: exported.manifest.skills.length,
|
|
projects: exported.manifest.projects.length,
|
|
issues: exported.manifest.issues.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildPreview(
|
|
input: CompanyPortabilityPreview,
|
|
options?: ImportBehaviorOptions,
|
|
): Promise<ImportPlanInternal> {
|
|
const mode = resolveImportMode(options);
|
|
const requestedInclude = normalizeInclude(input.include);
|
|
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
|
|
const manifest = source.manifest;
|
|
const include: CompanyPortabilityInclude = {
|
|
company: requestedInclude.company && manifest.company !== null,
|
|
agents: requestedInclude.agents && manifest.agents.length > 0,
|
|
projects: requestedInclude.projects && manifest.projects.length > 0,
|
|
issues: requestedInclude.issues && manifest.issues.length > 0,
|
|
skills: requestedInclude.skills && manifest.skills.length > 0,
|
|
};
|
|
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
|
if (mode === "agent_safe" && collisionStrategy === "replace") {
|
|
throw unprocessable("Safe import routes do not allow replace collision strategy.");
|
|
}
|
|
const warnings = [...source.warnings];
|
|
const errors: string[] = [];
|
|
|
|
if (include.company && !manifest.company) {
|
|
errors.push("Manifest does not include company metadata.");
|
|
}
|
|
if (mode === "agent_safe") {
|
|
errors.push(...collectAgentSafeImportPolicyErrors(manifest, include));
|
|
}
|
|
|
|
const selectedSlugs = include.agents
|
|
? (
|
|
input.agents && input.agents !== "all"
|
|
? Array.from(new Set(input.agents))
|
|
: manifest.agents.map((agent) => agent.slug)
|
|
)
|
|
: [];
|
|
|
|
const selectedAgents = include.agents
|
|
? 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.");
|
|
}
|
|
|
|
const availableSkillKeys = new Set(source.manifest.skills.map((skill) => skill.key));
|
|
const availableSkillSlugs = new Map<string, CompanyPortabilitySkillManifestEntry[]>();
|
|
for (const skill of source.manifest.skills) {
|
|
const existing = availableSkillSlugs.get(skill.slug) ?? [];
|
|
existing.push(skill);
|
|
availableSkillSlugs.set(skill.slug, existing);
|
|
}
|
|
|
|
for (const agent of selectedAgents) {
|
|
const filePath = ensureMarkdownPath(agent.path);
|
|
const markdown = readPortableTextFile(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 && parsed.frontmatter.kind !== "agent") {
|
|
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
|
|
}
|
|
for (const skillRef of agent.skills) {
|
|
const slugMatches = availableSkillSlugs.get(skillRef) ?? [];
|
|
if (!availableSkillKeys.has(skillRef) && slugMatches.length !== 1) {
|
|
warnings.push(`Agent ${agent.slug} references skill ${skillRef}, but that skill is not present in the package.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const project of manifest.projects) {
|
|
const markdown = readPortableTextFile(source.files, ensureMarkdownPath(project.path));
|
|
if (typeof markdown !== "string") {
|
|
errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`);
|
|
continue;
|
|
}
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "project") {
|
|
warnings.push(`Project markdown ${project.path} does not declare kind: project in frontmatter.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
const projectBySlug = new Map(manifest.projects.map((project) => [project.slug, project]));
|
|
for (const issue of manifest.issues) {
|
|
const markdown = readPortableTextFile(source.files, ensureMarkdownPath(issue.path));
|
|
if (typeof markdown !== "string") {
|
|
errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`);
|
|
continue;
|
|
}
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "task") {
|
|
warnings.push(`Task markdown ${issue.path} does not declare kind: task in frontmatter.`);
|
|
}
|
|
if (issue.projectWorkspaceKey) {
|
|
const project = issue.projectSlug ? projectBySlug.get(issue.projectSlug) ?? null : null;
|
|
if (!project) {
|
|
warnings.push(`Task ${issue.slug} references workspace key ${issue.projectWorkspaceKey}, but its project is not present in the package.`);
|
|
} else if (!project.workspaces.some((workspace) => workspace.key === issue.projectWorkspaceKey)) {
|
|
warnings.push(`Task ${issue.slug} references missing project workspace key ${issue.projectWorkspaceKey}.`);
|
|
}
|
|
}
|
|
if (issue.recurring) {
|
|
if (!issue.assigneeAgentSlug) {
|
|
warnings.push(`Recurring task ${issue.slug} has no assignee; the routine will stay paused until one is set.`);
|
|
}
|
|
const resolvedRoutine = resolvePortableRoutineDefinition(issue, parsed.frontmatter.schedule);
|
|
warnings.push(...resolvedRoutine.warnings);
|
|
errors.push(...resolvedRoutine.errors);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const envInput of manifest.envInputs) {
|
|
if (envInput.portability === "system_dependent") {
|
|
const scope = envInput.agentSlug
|
|
? ` for agent ${envInput.agentSlug}`
|
|
: envInput.projectSlug
|
|
? ` for project ${envInput.projectSlug}`
|
|
: "";
|
|
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (mode === "agent_safe" && include.projects && targetCompanyId) {
|
|
for (const project of manifest.projects) {
|
|
if (!project.env) continue;
|
|
try {
|
|
await secrets.normalizeEnvBindingsForPersistence(
|
|
targetCompanyId,
|
|
project.env,
|
|
{
|
|
strictMode: strictSecretsMode,
|
|
fieldPath: `projects.${project.slug}.env`,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
errors.push(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
const agentPlans: CompanyPortabilityPreviewAgentPlan[] = [];
|
|
const existingSlugToAgent = new Map<string, { id: string; name: string }>();
|
|
const existingAgentIds = new Set<string>();
|
|
const existingSlugs = new Set<string>();
|
|
const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = [];
|
|
const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = [];
|
|
const existingProjectSlugToProject = new Map<string, { id: string; name: string }>();
|
|
const existingProjectSlugs = 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);
|
|
existingAgentIds.add(existing.id);
|
|
existingSlugs.add(slug);
|
|
}
|
|
const existingProjects = await projects.list(input.target.companyId);
|
|
for (const existing of existingProjects) {
|
|
if (!existingProjectSlugToProject.has(existing.urlKey)) {
|
|
existingProjectSlugToProject.set(existing.urlKey, { id: existing.id, name: existing.name });
|
|
}
|
|
existingProjectSlugs.add(existing.urlKey);
|
|
}
|
|
|
|
const existingSkills = await companySkills.listFull(input.target.companyId);
|
|
const existingSkillKeys = new Set(existingSkills.map((skill) => skill.key));
|
|
const existingSkillSlugs = new Set(existingSkills.map((skill) => normalizeSkillSlug(skill.slug) ?? skill.slug));
|
|
for (const skill of manifest.skills) {
|
|
const skillSlug = normalizeSkillSlug(skill.slug) ?? skill.slug;
|
|
if (existingSkillKeys.has(skill.key) || existingSkillSlugs.has(skillSlug)) {
|
|
if (mode === "agent_safe") {
|
|
warnings.push(`Existing skill "${skill.slug}" matched during safe import and will ${collisionStrategy === "skip" ? "be skipped" : "be renamed"} instead of overwritten.`);
|
|
} else if (collisionStrategy === "replace") {
|
|
warnings.push(`Existing skill "${skill.slug}" (${skill.key}) will be overwritten by import.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const manifestAgent of selectedAgents) {
|
|
if (
|
|
manifestAgent.reportsToExistingAgentId
|
|
&& !existingAgentIds.has(manifestAgent.reportsToExistingAgentId)
|
|
) {
|
|
warnings.push(
|
|
`Agent ${manifestAgent.slug} references existing manager id ${manifestAgent.reportsToExistingAgentId}, but that agent is not present in the target company.`,
|
|
);
|
|
}
|
|
if (
|
|
manifestAgent.reportsToExistingAgentSlug
|
|
&& !existingSlugToAgent.has(manifestAgent.reportsToExistingAgentSlug)
|
|
) {
|
|
warnings.push(
|
|
`Agent ${manifestAgent.slug} references existing manager slug ${manifestAgent.reportsToExistingAgentSlug}, but that agent is not present in the target company.`,
|
|
);
|
|
}
|
|
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 (mode === "board_full" && 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.",
|
|
});
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const manifestProject of manifest.projects) {
|
|
const existing = existingProjectSlugToProject.get(manifestProject.slug) ?? null;
|
|
if (!existing) {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "create",
|
|
plannedName: manifestProject.name,
|
|
existingProjectId: null,
|
|
reason: null,
|
|
});
|
|
continue;
|
|
}
|
|
if (mode === "board_full" && collisionStrategy === "replace") {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "update",
|
|
plannedName: existing.name,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; replace strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
if (collisionStrategy === "skip") {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "skip",
|
|
plannedName: existing.name,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; skip strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
const renamed = uniqueProjectName(manifestProject.name, existingProjectSlugs);
|
|
existingProjectSlugs.add(deriveProjectUrlKey(renamed, renamed));
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "create",
|
|
plannedName: renamed,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; rename strategy.",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply user-specified name overrides (keyed by slug)
|
|
if (input.nameOverrides) {
|
|
for (const ap of agentPlans) {
|
|
const override = input.nameOverrides[ap.slug];
|
|
if (override) {
|
|
ap.plannedName = override;
|
|
}
|
|
}
|
|
for (const pp of projectPlans) {
|
|
const override = input.nameOverrides[pp.slug];
|
|
if (override) {
|
|
pp.plannedName = override;
|
|
}
|
|
}
|
|
for (const ip of issuePlans) {
|
|
const override = input.nameOverrides[ip.slug];
|
|
if (override) {
|
|
ip.plannedTitle = override;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warn about agents that will be overwritten/updated
|
|
for (const ap of agentPlans) {
|
|
if (ap.action === "update") {
|
|
warnings.push(`Existing agent "${ap.plannedName}" (${ap.slug}) will be overwritten by import.`);
|
|
}
|
|
}
|
|
|
|
// Warn about projects that will be overwritten/updated
|
|
for (const pp of projectPlans) {
|
|
if (pp.action === "update") {
|
|
warnings.push(`Existing project "${pp.plannedName}" (${pp.slug}) will be overwritten by import.`);
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
for (const manifestIssue of manifest.issues) {
|
|
issuePlans.push({
|
|
slug: manifestIssue.slug,
|
|
action: "create",
|
|
plannedTitle: manifestIssue.title,
|
|
reason: manifestIssue.recurring ? "Recurring task will be imported as a routine." : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const preview: CompanyPortabilityPreviewResult = {
|
|
include,
|
|
targetCompanyId,
|
|
targetCompanyName,
|
|
collisionStrategy,
|
|
selectedAgentSlugs: selectedAgents.map((agent) => agent.slug),
|
|
plan: {
|
|
companyAction: input.target.mode === "new_company"
|
|
? "create"
|
|
: include.company && mode === "board_full"
|
|
? "update"
|
|
: "none",
|
|
agentPlans,
|
|
projectPlans,
|
|
issuePlans,
|
|
},
|
|
manifest,
|
|
files: source.files,
|
|
envInputs: manifest.envInputs ?? [],
|
|
warnings,
|
|
errors,
|
|
};
|
|
|
|
return {
|
|
preview,
|
|
source,
|
|
include,
|
|
collisionStrategy,
|
|
selectedAgents,
|
|
};
|
|
}
|
|
|
|
async function previewImport(
|
|
input: CompanyPortabilityPreview,
|
|
options?: ImportBehaviorOptions,
|
|
): Promise<CompanyPortabilityPreviewResult> {
|
|
const plan = await buildPreview(input, options);
|
|
return plan.preview;
|
|
}
|
|
|
|
async function importBundle(
|
|
input: CompanyPortabilityImport,
|
|
actorUserId: string | null | undefined,
|
|
options?: ImportBehaviorOptions,
|
|
): Promise<CompanyPortabilityImportResult> {
|
|
// Fail closed before any preview or write work when an inline body arrived
|
|
// incomplete. A truncated or re-framed request can hand the parser a
|
|
// structurally valid JSON object with fewer files than the client sent;
|
|
// the declared count is the only signal that distinguishes it from a
|
|
// deliberately small bundle. Reject the fragment rather than importing it.
|
|
assertInlineSourceComplete(input.source);
|
|
const mode = resolveImportMode(options);
|
|
const plan = await buildPreview(input, options);
|
|
if (plan.preview.errors.length > 0) {
|
|
throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`);
|
|
}
|
|
if (
|
|
mode === "agent_safe"
|
|
&& (
|
|
plan.preview.plan.companyAction === "update"
|
|
|| plan.preview.plan.agentPlans.some((entry) => entry.action === "update")
|
|
|| plan.preview.plan.projectPlans.some((entry) => entry.action === "update")
|
|
)
|
|
) {
|
|
throw unprocessable("Safe import routes only allow create or skip actions.");
|
|
}
|
|
|
|
const sourceManifest = plan.source.manifest;
|
|
const pauseAutomations = options?.pauseAutomations === true;
|
|
const importedAutomationPausedAt = pauseAutomations ? new Date() : null;
|
|
const warnings = [...plan.preview.warnings];
|
|
const include = plan.include;
|
|
|
|
if (include.agents) {
|
|
const importedAgentSlugs = new Set(
|
|
plan.preview.plan.agentPlans
|
|
.filter((entry) => entry.action !== "skip")
|
|
.map((entry) => entry.slug),
|
|
);
|
|
const runnerSelections = sourceManifest.agents.filter((agent) =>
|
|
importedAgentSlugs.has(agent.slug)
|
|
&& (input.adapterOverrides?.[agent.slug]?.adapterType ?? agent.adapterType)
|
|
=== "paperclip_runner",
|
|
);
|
|
if (runnerSelections.length > 0) {
|
|
const experimental = await instanceSettingsService(db).getExperimental();
|
|
for (const agent of runnerSelections) {
|
|
const config = input.adapterOverrides?.[agent.slug]?.adapterConfig ?? agent.adapterConfig;
|
|
if (config.provider === "muse") {
|
|
if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse and Paperclip Runner must be enabled before importing a Muse agent.", {code:"paperclip_runner_muse_disabled"});
|
|
} else if (config.provider === "openai_dot") {
|
|
if (experimental.enableOpenAiDot !== true) throw unprocessable(
|
|
"OpenAI Dot is experimental and disabled on this instance.",
|
|
{ code: "paperclip_runner_dot_disabled" },
|
|
);
|
|
} else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && !["claude_local", "pi_local"].includes(agentHarnessType("paperclip_runner", config)) && experimental.enableNativeRunner !== true) {
|
|
throw unprocessable(
|
|
"Paperclip Runner is experimental and disabled on this instance.",
|
|
{ code: "paperclip_runner_rollout_disabled" },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Content-addressed blobs double as the bundle's tamper seal. Verify every
|
|
// blob before any row is written so a corrupted package cannot leave a
|
|
// partially imported company behind.
|
|
for (const [filePath, fileEntry] of Object.entries(plan.source.files)) {
|
|
if (!filePath.startsWith("blobs/")) continue;
|
|
const declaredSha = filePath.slice("blobs/".length);
|
|
const body = portableFileToBuffer(fileEntry, filePath);
|
|
if (sha256HexOfBytes(body) !== declaredSha) {
|
|
throw unprocessable(`Bundle blob ${filePath} does not match its declared sha256; the package is corrupted or was tampered with.`);
|
|
}
|
|
}
|
|
|
|
let targetCompany: {
|
|
id: string;
|
|
name: string;
|
|
requireBoardApprovalForNewAgents?: boolean | null;
|
|
} | null = null;
|
|
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
|
|
|
if (input.target.mode === "new_company") {
|
|
if (mode === "agent_safe" && !options?.sourceCompanyId) {
|
|
throw unprocessable("Safe new-company imports require a source company context.");
|
|
}
|
|
if (mode === "agent_safe" && options?.sourceCompanyId) {
|
|
const sourceMemberships = await access.listActiveUserMemberships(options.sourceCompanyId);
|
|
if (sourceMemberships.length === 0) {
|
|
throw unprocessable("Safe new-company import requires at least one active user membership on the source company.");
|
|
}
|
|
}
|
|
const requestedCompanyName = asString(input.target.newCompanyName);
|
|
const manifestCompanyName =
|
|
sourceManifest.company?.name ?? sourceManifest.source?.companyName ?? "Imported Company";
|
|
// De-duplicate only for board-driven imports. The lookup reads every
|
|
// company name in the instance, and reflecting a collision back through
|
|
// the numeric suffix would let a company-scoped agent (agent_safe mode)
|
|
// probe for the existence of company names outside its own company.
|
|
const companyName =
|
|
requestedCompanyName ??
|
|
(mode === "agent_safe"
|
|
? manifestCompanyName
|
|
: dedupeImportedCompanyName(
|
|
manifestCompanyName,
|
|
(await companies.list()).map((company) => company.name),
|
|
));
|
|
const created = await companies.create({
|
|
name: companyName,
|
|
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
|
requireBoardApprovalForNewAgents: include.company
|
|
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
|
|
: false,
|
|
feedbackDataSharingEnabled: include.company
|
|
? (sourceManifest.company?.feedbackDataSharingEnabled ?? false)
|
|
: false,
|
|
feedbackDataSharingConsentAt: include.company && sourceManifest.company?.feedbackDataSharingConsentAt
|
|
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
|
|
: null,
|
|
feedbackDataSharingConsentByUserId: include.company
|
|
? (sourceManifest.company?.feedbackDataSharingConsentByUserId ?? null)
|
|
: null,
|
|
feedbackDataSharingTermsVersion: include.company
|
|
? (sourceManifest.company?.feedbackDataSharingTermsVersion ?? null)
|
|
: null,
|
|
});
|
|
if (mode === "agent_safe" && options?.sourceCompanyId) {
|
|
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
|
|
} else {
|
|
const ownerPrincipalId = actorUserId ?? "board";
|
|
await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active");
|
|
await access.ensureRoleDefaultGrants(
|
|
created.id,
|
|
ownerPrincipalId,
|
|
"owner",
|
|
actorUserId ?? null,
|
|
);
|
|
}
|
|
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 && mode === "board_full") {
|
|
const updated = await companies.update(targetCompany.id, {
|
|
name: sourceManifest.company.name,
|
|
description: sourceManifest.company.description,
|
|
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
|
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
|
|
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
|
|
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
|
|
: null,
|
|
feedbackDataSharingConsentByUserId: sourceManifest.company.feedbackDataSharingConsentByUserId,
|
|
feedbackDataSharingTermsVersion: sourceManifest.company.feedbackDataSharingTermsVersion,
|
|
});
|
|
targetCompany = updated ?? targetCompany;
|
|
companyAction = "updated";
|
|
}
|
|
}
|
|
|
|
if (!targetCompany) throw notFound("Target company not found");
|
|
|
|
const importedAgentEnvSlugs = new Set(
|
|
plan.preview.plan.agentPlans
|
|
.filter((entry) => entry.action !== "skip")
|
|
.map((entry) => entry.slug),
|
|
);
|
|
const importedProjectEnvSlugs = new Set(
|
|
plan.preview.plan.projectPlans
|
|
.filter((entry) => entry.action !== "skip")
|
|
.map((entry) => entry.slug),
|
|
);
|
|
const importEnvInputs = (sourceManifest.envInputs ?? []).filter((inputValue) => {
|
|
if (inputValue.agentSlug) {
|
|
return include.agents && importedAgentEnvSlugs.has(inputValue.agentSlug);
|
|
}
|
|
if (inputValue.projectSlug) {
|
|
return include.projects && importedProjectEnvSlugs.has(inputValue.projectSlug);
|
|
}
|
|
return true;
|
|
});
|
|
const createdImportSecretIds: string[] = [];
|
|
try {
|
|
await materializeImportEnvInputValues(
|
|
targetCompany.id,
|
|
sourceManifest,
|
|
importEnvInputs,
|
|
input.secretValues,
|
|
actorUserId,
|
|
createdImportSecretIds,
|
|
);
|
|
|
|
if (include.company) {
|
|
const logoPath = sourceManifest.company?.logoPath ?? null;
|
|
if (!logoPath) {
|
|
const cleared = await companies.update(targetCompany.id, { logoAssetId: null });
|
|
targetCompany = cleared ?? targetCompany;
|
|
} else {
|
|
const logoFile = plan.source.files[logoPath];
|
|
if (!logoFile) {
|
|
warnings.push(`Skipped company logo import because ${logoPath} is missing from the package.`);
|
|
} else if (!storage) {
|
|
warnings.push("Skipped company logo import because storage is unavailable.");
|
|
} else {
|
|
const contentType = isPortableBinaryFile(logoFile)
|
|
? (logoFile.contentType ?? inferContentTypeFromPath(logoPath))
|
|
: inferContentTypeFromPath(logoPath);
|
|
if (!contentType || !COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType]) {
|
|
warnings.push(`Skipped company logo import for ${logoPath} because the file type is unsupported.`);
|
|
} else {
|
|
try {
|
|
const body = portableFileToBuffer(logoFile, logoPath);
|
|
const stored = await storage.putFile({
|
|
companyId: targetCompany.id,
|
|
namespace: "assets/companies",
|
|
originalFilename: path.posix.basename(logoPath),
|
|
contentType,
|
|
body,
|
|
});
|
|
const createdAsset = await assetRecords.create(targetCompany.id, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: null,
|
|
createdByUserId: actorUserId ?? null,
|
|
});
|
|
const updated = await companies.update(targetCompany.id, {
|
|
logoAssetId: createdAsset.id,
|
|
});
|
|
targetCompany = updated ?? targetCompany;
|
|
} catch (err) {
|
|
warnings.push(`Failed to import company logo ${logoPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recreate embedded markdown image assets before any markdown is
|
|
// written, so issue descriptions, comments, documents, and agent
|
|
// instructions can be rewritten to the asset ids this board minted.
|
|
// References whose entry or blob cannot be imported keep their source
|
|
// ids and stay broken rather than pointing at the wrong asset.
|
|
const embeddedAssetIdMap = new Map<string, string>();
|
|
const manifestEmbeddedAssets = sourceManifest.embeddedAssets ?? [];
|
|
if (manifestEmbeddedAssets.length > 0) {
|
|
if (!storage) {
|
|
warnings.push(`Skipped ${manifestEmbeddedAssets.length} embedded image asset${manifestEmbeddedAssets.length === 1 ? "" : "s"} because storage is unavailable; their references were left unchanged.`);
|
|
} else {
|
|
for (const embeddedAsset of manifestEmbeddedAssets) {
|
|
const embeddedAssetLabel = embeddedAsset.originalFilename ?? embeddedAsset.assetId;
|
|
const blobPath = portableBlobPath(embeddedAsset.sha256);
|
|
const blobFile = plan.source.files[blobPath];
|
|
if (blobFile === undefined) {
|
|
warnings.push(`Embedded image asset ${embeddedAssetLabel} was skipped because its blob is missing from the package: ${blobPath}; its references were left unchanged.`);
|
|
continue;
|
|
}
|
|
// The pre-apply loop above already verified that every blobs/*
|
|
// entry hashes to its path, which this entry's sha256 derives.
|
|
const body = portableFileToBuffer(blobFile, blobPath);
|
|
try {
|
|
const stored = await storage.putFile({
|
|
companyId: targetCompany.id,
|
|
namespace: "assets/general",
|
|
originalFilename: embeddedAsset.originalFilename,
|
|
contentType: embeddedAsset.contentType,
|
|
body,
|
|
});
|
|
const createdAsset = await assetRecords.create(targetCompany.id, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: null,
|
|
createdByUserId: actorUserId ?? null,
|
|
});
|
|
embeddedAssetIdMap.set(embeddedAsset.assetId, createdAsset.id);
|
|
} catch (error) {
|
|
warnings.push(`Embedded image asset ${embeddedAssetLabel} could not be imported: ${error instanceof Error ? error.message : String(error)}; its references were left unchanged.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
|
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
|
|
const resultRoutines: CompanyPortabilityImportResult["routines"] = [];
|
|
const importedSlugToAgentId = new Map<string, string>();
|
|
const existingSlugToAgentId = new Map<string, string>();
|
|
const preImportExistingSlugToAgentId = new Map<string, string>();
|
|
const preImportExistingAgentIds = new Set<string>();
|
|
const agentStatusById = new Map<string, string | null | undefined>();
|
|
const existingAgents = await agents.list(targetCompany.id);
|
|
for (const existing of existingAgents) {
|
|
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
|
|
existingSlugToAgentId.set(slug, existing.id);
|
|
preImportExistingSlugToAgentId.set(slug, existing.id);
|
|
preImportExistingAgentIds.add(existing.id);
|
|
agentStatusById.set(existing.id, existing.status);
|
|
}
|
|
const importedSlugToProjectId = new Map<string, string>();
|
|
const importedProjectWorkspaceIdByProjectSlug = new Map<string, Map<string, string>>();
|
|
const existingProjectSlugToId = new Map<string, string>();
|
|
const existingProjects = await projects.list(targetCompany.id);
|
|
for (const existing of existingProjects) {
|
|
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
|
}
|
|
|
|
const importedSkills = include.skills || include.agents
|
|
? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
|
|
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
|
})
|
|
: [];
|
|
const desiredSkillRefMap = new Map<string, string>();
|
|
for (const importedSkill of importedSkills) {
|
|
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);
|
|
desiredSkillRefMap.set(importedSkill.originalSlug, importedSkill.skill.key);
|
|
if (importedSkill.action === "skipped") {
|
|
warnings.push(`Skipped skill ${importedSkill.originalSlug}; existing skill ${importedSkill.skill.slug} was kept.`);
|
|
} else if (importedSkill.action === "renamed") {
|
|
warnings.push(`Imported skill ${importedSkill.originalSlug} as ${importedSkill.skill.slug} to avoid overwriting an existing skill.`);
|
|
}
|
|
}
|
|
|
|
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 bundlePrefix = `agents/${manifestAgent.slug}/`;
|
|
const bundleFiles = Object.fromEntries(
|
|
Object.entries(plan.source.files)
|
|
.filter(([filePath]) => filePath.startsWith(bundlePrefix))
|
|
.flatMap(([filePath, content]) => typeof content === "string"
|
|
? [[normalizePortablePath(filePath.slice(bundlePrefix.length)), content] as const]
|
|
: []),
|
|
);
|
|
const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path);
|
|
const entryRelativePath = normalizePortablePath(manifestAgent.path).startsWith(bundlePrefix)
|
|
? normalizePortablePath(manifestAgent.path).slice(bundlePrefix.length)
|
|
: "AGENTS.md";
|
|
if (typeof markdownRaw === "string") {
|
|
const importedInstructionsBody = parseFrontmatterMarkdown(markdownRaw).body;
|
|
bundleFiles[entryRelativePath] = importedInstructionsBody;
|
|
if (entryRelativePath !== "AGENTS.md") {
|
|
bundleFiles["AGENTS.md"] = importedInstructionsBody;
|
|
}
|
|
}
|
|
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
|
if (!markdownRaw && fallbackPromptTemplate) {
|
|
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
|
}
|
|
if (!markdownRaw && !fallbackPromptTemplate) {
|
|
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported with an empty managed bundle.`);
|
|
}
|
|
if (embeddedAssetIdMap.size > 0) {
|
|
for (const [relativePath, content] of Object.entries(bundleFiles)) {
|
|
bundleFiles[relativePath] = rewriteEmbeddedAssetUrls(content, embeddedAssetIdMap);
|
|
}
|
|
}
|
|
|
|
// Apply adapter overrides from request if present
|
|
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
|
|
const baseAdapterConfig = adapterOverride?.adapterConfig
|
|
? { ...adapterOverride.adapterConfig }
|
|
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
|
|
|
|
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
|
|
const existingImportAgent = planAgent.action === "update" && planAgent.existingAgentId
|
|
? await agents.getById(planAgent.existingAgentId) : null;
|
|
let selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
|
|
const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig);
|
|
let runner = adapterOverride?.runner;
|
|
if (runner === undefined && adapterOverride?.adapterType !== "paperclip_runner") {
|
|
if (selectedHarness === agentHarnessType(manifestAgent.adapterType, manifestAgent.adapterConfig)) {
|
|
runner = manifestAgent.runner;
|
|
}
|
|
if (runner === undefined && existingImportAgent
|
|
&& selectedHarness === agentHarnessType(existingImportAgent.adapterType, existingImportAgent.adapterConfig)) {
|
|
runner = agentRunner(existingImportAgent.adapterType);
|
|
}
|
|
}
|
|
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedHarness)
|
|
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
|
|
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
|
|
: undefined;
|
|
if (retainedNative) {
|
|
selectedAdapterType = "paperclip_runner";
|
|
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
|
|
if (retainedNative.adapterConfig[field] !== undefined) baseAdapterConfig[field] = retainedNative.adapterConfig[field];
|
|
}
|
|
}
|
|
const normalizedAdapter = await prepareImportedAgentAdapter(
|
|
targetCompany.id,
|
|
selectedAdapterType,
|
|
baseAdapterConfig,
|
|
desiredSkills,
|
|
mode,
|
|
runner,
|
|
existingImportAgent?.defaultEnvironmentId,
|
|
);
|
|
const patch = {
|
|
name: planAgent.plannedName,
|
|
role: manifestAgent.role,
|
|
title: manifestAgent.title,
|
|
icon: manifestAgent.icon,
|
|
...(manifestAgent.appearance ? { appearance: portableAgentAppearance(manifestAgent.appearance, warnings, manifestAgent.slug) } : {}),
|
|
capabilities: manifestAgent.capabilities,
|
|
reportsTo: null,
|
|
adapterType: normalizedAdapter.adapterType,
|
|
adapterConfig: normalizedAdapter.adapterConfig,
|
|
runtimeConfig: sanitizeImportedAgentRuntimeConfig(manifestAgent.runtimeConfig),
|
|
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
|
permissions: manifestAgent.permissions,
|
|
metadata: manifestAgent.metadata,
|
|
};
|
|
// "import", not "system": the UI reads this to explain that the
|
|
// agent was parked by the import safety default and to offer a
|
|
// scoped resume; "system" stays reserved for platform-managed
|
|
// pauses (plugins, built-ins).
|
|
const automationPausePatch = pauseAutomations
|
|
? {
|
|
status: "paused",
|
|
pauseReason: "import",
|
|
pausedAt: importedAutomationPausedAt,
|
|
}
|
|
: {};
|
|
|
|
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
|
if (pauseAutomations) await agentsLifecycle.pauseAgent(planAgent.existingAgentId, "import");
|
|
let 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;
|
|
}
|
|
try {
|
|
const materialized = await instructions.materializeManagedBundle(updated, bundleFiles, {
|
|
clearLegacyPromptTemplate: true,
|
|
replaceExisting: true,
|
|
});
|
|
updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
|
|
} catch (err) {
|
|
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
await applyImportedAgentPermissionGrants(
|
|
targetCompany.id,
|
|
updated.id,
|
|
manifestAgent.permissionGrants ?? [],
|
|
actorUserId ?? null,
|
|
);
|
|
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
|
|
await secrets.syncEnvBindingsForTarget?.(
|
|
targetCompany.id,
|
|
{ targetType: "agent", targetId: updated.id },
|
|
isPlainRecord(updated.adapterConfig) ? updated.adapterConfig.env : undefined,
|
|
);
|
|
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;
|
|
}
|
|
|
|
let created = await agentsLifecycle.requestHire(targetCompany.id, {
|
|
...patch,
|
|
runner: agentRunner(patch.adapterType),
|
|
...automationPausePatch,
|
|
status: pauseAutomations ? "paused" : "idle",
|
|
}, { createdByUserId: actorUserId });
|
|
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
|
|
await access.setPrincipalPermission(
|
|
targetCompany.id,
|
|
"agent",
|
|
created.id,
|
|
"tasks:assign",
|
|
true,
|
|
actorUserId ?? null,
|
|
);
|
|
try {
|
|
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
|
|
clearLegacyPromptTemplate: true,
|
|
replaceExisting: true,
|
|
});
|
|
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
|
} catch (err) {
|
|
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
await applyImportedAgentPermissionGrants(
|
|
targetCompany.id,
|
|
created.id,
|
|
manifestAgent.permissionGrants ?? [],
|
|
actorUserId ?? null,
|
|
);
|
|
agentStatusById.set(created.id, created.status ?? (pauseAutomations ? "paused" : "idle"));
|
|
await secrets.syncEnvBindingsForTarget?.(
|
|
targetCompany.id,
|
|
{ targetType: "agent", targetId: created.id },
|
|
isPlainRecord(created.adapterConfig) ? created.adapterConfig.env : undefined,
|
|
);
|
|
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;
|
|
let existingManagerId: string | null = null;
|
|
if (
|
|
manifestAgent.reportsToExistingAgentId
|
|
&& preImportExistingAgentIds.has(manifestAgent.reportsToExistingAgentId)
|
|
) {
|
|
existingManagerId = manifestAgent.reportsToExistingAgentId;
|
|
} else if (manifestAgent.reportsToExistingAgentSlug) {
|
|
existingManagerId =
|
|
preImportExistingSlugToAgentId.get(manifestAgent.reportsToExistingAgentSlug) ?? null;
|
|
}
|
|
if (!managerSlug && !existingManagerId) continue;
|
|
const managerId =
|
|
existingManagerId
|
|
?? (managerSlug
|
|
? importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null
|
|
: null);
|
|
if (!managerId || managerId === agentId) continue;
|
|
try {
|
|
await agents.update(agentId, { reportsTo: managerId });
|
|
} catch {
|
|
const managerRef =
|
|
managerSlug
|
|
?? manifestAgent.reportsToExistingAgentSlug
|
|
?? manifestAgent.reportsToExistingAgentId;
|
|
warnings.push(`Could not assign manager ${managerRef} for imported agent ${manifestAgent.slug}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const planProject of plan.preview.plan.projectPlans) {
|
|
const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug);
|
|
if (!manifestProject) continue;
|
|
if (planProject.action === "skip") {
|
|
resultProjects.push({
|
|
slug: planProject.slug,
|
|
id: planProject.existingProjectId,
|
|
action: "skipped",
|
|
name: planProject.plannedName,
|
|
reason: planProject.reason,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const projectLeadAgentId = manifestProject.leadAgentSlug
|
|
? importedSlugToAgentId.get(manifestProject.leadAgentSlug)
|
|
?? existingSlugToAgentId.get(manifestProject.leadAgentSlug)
|
|
?? null
|
|
: null;
|
|
const projectWorkspaceIdByKey = new Map<string, string>();
|
|
const normalizedProjectEnv = manifestProject.env
|
|
? await secrets.normalizeEnvBindingsForPersistence(
|
|
targetCompany.id,
|
|
manifestProject.env,
|
|
{
|
|
strictMode: strictSecretsMode,
|
|
fieldPath: `projects.${manifestProject.slug}.env`,
|
|
},
|
|
)
|
|
: null;
|
|
const projectPatch = {
|
|
name: planProject.plannedName,
|
|
description: manifestProject.description,
|
|
leadAgentId: projectLeadAgentId,
|
|
targetDate: manifestProject.targetDate,
|
|
color: manifestProject.color,
|
|
icon: normalizeProjectIconName(manifestProject.icon),
|
|
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
|
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
|
: "backlog",
|
|
env: normalizedProjectEnv,
|
|
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
|
|
};
|
|
|
|
let projectId: string | null = null;
|
|
if (planProject.action === "update" && planProject.existingProjectId) {
|
|
const updated = await projects.update(planProject.existingProjectId, projectPatch);
|
|
if (!updated) {
|
|
warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`);
|
|
resultProjects.push({
|
|
slug: planProject.slug,
|
|
id: null,
|
|
action: "skipped",
|
|
name: planProject.plannedName,
|
|
reason: "Existing target project not found.",
|
|
});
|
|
continue;
|
|
}
|
|
projectId = updated.id;
|
|
importedSlugToProjectId.set(planProject.slug, updated.id);
|
|
existingProjectSlugToId.set(updated.urlKey, updated.id);
|
|
resultProjects.push({
|
|
slug: planProject.slug,
|
|
id: updated.id,
|
|
action: "updated",
|
|
name: updated.name,
|
|
reason: planProject.reason,
|
|
});
|
|
} else {
|
|
const created = await projects.create(targetCompany.id, projectPatch);
|
|
projectId = created.id;
|
|
importedSlugToProjectId.set(planProject.slug, created.id);
|
|
existingProjectSlugToId.set(created.urlKey, created.id);
|
|
resultProjects.push({
|
|
slug: planProject.slug,
|
|
id: created.id,
|
|
action: "created",
|
|
name: created.name,
|
|
reason: planProject.reason,
|
|
});
|
|
}
|
|
|
|
if (!projectId) continue;
|
|
|
|
await secrets.syncEnvBindingsForTarget?.(
|
|
targetCompany.id,
|
|
{ targetType: "project", targetId: projectId },
|
|
normalizedProjectEnv ?? {},
|
|
);
|
|
|
|
for (const workspace of manifestProject.workspaces) {
|
|
const createdWorkspace = await projects.createWorkspace(projectId, {
|
|
name: workspace.name,
|
|
sourceType: workspace.sourceType ?? undefined,
|
|
repoUrl: workspace.repoUrl ?? undefined,
|
|
repoRef: workspace.repoRef ?? undefined,
|
|
defaultRef: workspace.defaultRef ?? undefined,
|
|
visibility: workspace.visibility ?? undefined,
|
|
setupCommand: workspace.setupCommand ?? undefined,
|
|
cleanupCommand: workspace.cleanupCommand ?? undefined,
|
|
metadata: workspace.metadata ?? undefined,
|
|
isPrimary: workspace.isPrimary,
|
|
});
|
|
if (!createdWorkspace) {
|
|
warnings.push(`Project ${planProject.slug} workspace ${workspace.key} could not be created during import.`);
|
|
continue;
|
|
}
|
|
projectWorkspaceIdByKey.set(workspace.key, createdWorkspace.id);
|
|
}
|
|
importedProjectWorkspaceIdByProjectSlug.set(planProject.slug, projectWorkspaceIdByKey);
|
|
|
|
const hydratedProjectExecutionWorkspacePolicy = importPortableProjectExecutionWorkspacePolicy(
|
|
planProject.slug,
|
|
manifestProject.executionWorkspacePolicy,
|
|
projectWorkspaceIdByKey,
|
|
warnings,
|
|
);
|
|
if (hydratedProjectExecutionWorkspacePolicy) {
|
|
await projects.update(projectId, {
|
|
executionWorkspacePolicy: hydratedProjectExecutionWorkspacePolicy,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
const routines = routineService(db);
|
|
|
|
// Resolve label names against the target company before creating
|
|
// issues: reuse existing labels (the target's color wins) and create
|
|
// the rest from the bundle's definitions. Old bundles carry raw
|
|
// labelIds instead; those only survive when the id happens to exist
|
|
// in the target company.
|
|
const labelColorByName = new Map<string, string>();
|
|
for (const label of sourceManifest.labels ?? []) {
|
|
if (!labelColorByName.has(label.name)) labelColorByName.set(label.name, label.color);
|
|
}
|
|
const referencedLabelNames = new Set<string>();
|
|
let hasLegacyLabelIds = false;
|
|
for (const manifestIssue of sourceManifest.issues) {
|
|
if (manifestIssue.recurring) continue;
|
|
for (const name of manifestIssue.labelNames ?? []) referencedLabelNames.add(name);
|
|
if ((manifestIssue.labelIds ?? []).length > 0) hasLegacyLabelIds = true;
|
|
}
|
|
const labelIdByName = new Map<string, string>();
|
|
const existingTargetLabelIds = new Set<string>();
|
|
if (referencedLabelNames.size > 0 || hasLegacyLabelIds) {
|
|
const targetLabels = await issues.listLabels(targetCompany.id);
|
|
const targetLabelByName = new Map(targetLabels.map((label) => [label.name, label]));
|
|
for (const label of targetLabels) existingTargetLabelIds.add(label.id);
|
|
const keptColorNames: string[] = [];
|
|
for (const name of Array.from(referencedLabelNames).sort((left, right) => left.localeCompare(right))) {
|
|
const existing = targetLabelByName.get(name);
|
|
if (existing) {
|
|
labelIdByName.set(name, existing.id);
|
|
const exportedColor = labelColorByName.get(name);
|
|
if (exportedColor && exportedColor.toLowerCase() !== existing.color.toLowerCase()) {
|
|
keptColorNames.push(name);
|
|
}
|
|
continue;
|
|
}
|
|
const created = await issues.createLabel(targetCompany.id, {
|
|
name,
|
|
color: labelColorByName.get(name) ?? DEFAULT_IMPORTED_LABEL_COLOR,
|
|
});
|
|
labelIdByName.set(name, created.id);
|
|
}
|
|
if (keptColorNames.length > 0) {
|
|
warnings.push(`Existing label color${keptColorNames.length === 1 ? " was" : "s were"} kept for ${keptColorNames.join(", ")}; the imported bundle used different colors.`);
|
|
}
|
|
}
|
|
|
|
const importedIssueIdBySlug = new Map<string, string>();
|
|
const blockedByBySlug = new Map<string, string[]>();
|
|
const parentSlugBySlug = new Map<string, string>();
|
|
let unarmedMonitorCount = 0;
|
|
let attachmentsSkippedNoStorage = 0;
|
|
|
|
// Import writes every issue and its children as a single batch instead
|
|
// of one network round-trip per row. The loop below resolves each
|
|
// manifest issue (ids pre-generated so children reference parents
|
|
// without waiting) and buffers the resulting rows; the buffers are
|
|
// flushed through the batched writers once resolution is complete.
|
|
const issueRows: ImportIssueRow[] = [];
|
|
const commentRows: ImportIssueCommentRow[] = [];
|
|
const documentRows: ImportIssueDocumentRow[] = [];
|
|
const workProductRows: ImportIssueWorkProductRow[] = [];
|
|
const attachmentRows: ImportIssueAttachmentRow[] = [];
|
|
|
|
for (const manifestIssue of sourceManifest.issues) {
|
|
const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path);
|
|
const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
|
|
const rawDescription = parsed?.body || manifestIssue.description || null;
|
|
const description = rawDescription === null
|
|
? null
|
|
: rewriteEmbeddedAssetUrls(rawDescription, embeddedAssetIdMap);
|
|
const assigneeAgentId = resolveImportedAssigneeAgentId(
|
|
manifestIssue.assigneeAgentSlug,
|
|
importedSlugToAgentId,
|
|
existingSlugToAgentId,
|
|
agentStatusById,
|
|
warnings,
|
|
`Task ${manifestIssue.slug}`,
|
|
);
|
|
const projectId = manifestIssue.projectSlug
|
|
? importedSlugToProjectId.get(manifestIssue.projectSlug)
|
|
?? existingProjectSlugToId.get(manifestIssue.projectSlug)
|
|
?? null
|
|
: null;
|
|
const projectWorkspaceId = manifestIssue.projectSlug && manifestIssue.projectWorkspaceKey
|
|
? importedProjectWorkspaceIdByProjectSlug.get(manifestIssue.projectSlug)?.get(manifestIssue.projectWorkspaceKey) ?? null
|
|
: null;
|
|
if (manifestIssue.projectWorkspaceKey && !projectWorkspaceId) {
|
|
warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`);
|
|
}
|
|
if (manifestIssue.recurring) {
|
|
// Routines can legitimately exist without a project or assignee;
|
|
// routines.create accepts a null project and pauses an active
|
|
// routine that has no assignee to run it.
|
|
if (manifestIssue.projectSlug && !projectId) {
|
|
warnings.push(`Recurring task ${manifestIssue.slug} references project ${manifestIssue.projectSlug}, which was not imported; the routine was created without a project.`);
|
|
}
|
|
const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule);
|
|
if (resolvedRoutine.errors.length > 0) {
|
|
throw unprocessable(`Recurring task ${manifestIssue.slug} could not be imported as a routine: ${resolvedRoutine.errors.join("; ")}`);
|
|
}
|
|
warnings.push(...resolvedRoutine.warnings);
|
|
const routineDefinition = resolvedRoutine.routine ?? {
|
|
concurrencyPolicy: null,
|
|
catchUpPolicy: null,
|
|
variables: null,
|
|
triggers: [],
|
|
};
|
|
const createdRoutine = await routines.create(targetCompany.id, {
|
|
projectId,
|
|
goalId: null,
|
|
parentIssueId: null,
|
|
title: manifestIssue.title,
|
|
description,
|
|
assigneeAgentId,
|
|
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
|
|
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
|
|
: "medium",
|
|
status: pauseAutomations
|
|
? "paused"
|
|
: manifestIssue.status && ROUTINE_STATUSES.includes(manifestIssue.status as any)
|
|
? manifestIssue.status as typeof ROUTINE_STATUSES[number]
|
|
: "active",
|
|
concurrencyPolicy:
|
|
routineDefinition.concurrencyPolicy && ROUTINE_CONCURRENCY_POLICIES.includes(routineDefinition.concurrencyPolicy as any)
|
|
? routineDefinition.concurrencyPolicy as typeof ROUTINE_CONCURRENCY_POLICIES[number]
|
|
: "coalesce_if_active",
|
|
catchUpPolicy:
|
|
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
|
|
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
|
|
: "skip_missed",
|
|
variables: routineDefinition.variables ?? [],
|
|
}, {
|
|
agentId: null,
|
|
userId: actorUserId ?? null,
|
|
});
|
|
resultRoutines.push({
|
|
slug: manifestIssue.slug,
|
|
id: createdRoutine.id,
|
|
action: "created",
|
|
title: createdRoutine.title,
|
|
status: createdRoutine.status,
|
|
});
|
|
if (!assigneeAgentId) {
|
|
warnings.push(`Routine ${manifestIssue.slug} was imported without an assignee and will stay paused until one is set.`);
|
|
}
|
|
for (const trigger of routineDefinition.triggers) {
|
|
if (trigger.kind === "schedule") {
|
|
await routines.createTrigger(createdRoutine.id, {
|
|
kind: "schedule",
|
|
label: trigger.label,
|
|
enabled: trigger.enabled,
|
|
cronExpression: trigger.cronExpression!,
|
|
timezone: trigger.timezone!,
|
|
}, {
|
|
agentId: null,
|
|
userId: actorUserId ?? null,
|
|
});
|
|
continue;
|
|
}
|
|
if (trigger.kind === "webhook") {
|
|
await routines.createTrigger(createdRoutine.id, {
|
|
kind: "webhook",
|
|
label: trigger.label,
|
|
enabled: trigger.enabled,
|
|
signingMode:
|
|
trigger.signingMode && ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any)
|
|
? trigger.signingMode as typeof ROUTINE_TRIGGER_SIGNING_MODES[number]
|
|
: "bearer",
|
|
replayWindowSec: trigger.replayWindowSec ?? 300,
|
|
}, {
|
|
agentId: null,
|
|
userId: actorUserId ?? null,
|
|
});
|
|
continue;
|
|
}
|
|
await routines.createTrigger(createdRoutine.id, {
|
|
kind: "api",
|
|
label: trigger.label,
|
|
enabled: trigger.enabled,
|
|
}, {
|
|
agentId: null,
|
|
userId: actorUserId ?? null,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
|
|
? manifestIssue.status as typeof ISSUE_STATUSES[number]
|
|
: "backlog";
|
|
if (!assigneeAgentId && issueStatus === "in_progress") {
|
|
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
|
|
issueStatus = "todo";
|
|
}
|
|
const resolvedLabelIds: string[] = [];
|
|
for (const name of manifestIssue.labelNames ?? []) {
|
|
const labelId = labelIdByName.get(name);
|
|
if (labelId && !resolvedLabelIds.includes(labelId)) resolvedLabelIds.push(labelId);
|
|
}
|
|
const legacyLabelIds = manifestIssue.labelIds ?? [];
|
|
const unresolvedLegacyCount = legacyLabelIds.filter((labelId) => !existingTargetLabelIds.has(labelId)).length;
|
|
if (unresolvedLegacyCount > 0) {
|
|
warnings.push(`Task ${manifestIssue.slug} dropped ${unresolvedLegacyCount} label reference${unresolvedLegacyCount === 1 ? "" : "s"} because the bundle carries raw label ids that do not exist in the target company.`);
|
|
}
|
|
for (const labelId of legacyLabelIds) {
|
|
if (existingTargetLabelIds.has(labelId) && !resolvedLabelIds.includes(labelId)) {
|
|
resolvedLabelIds.push(labelId);
|
|
}
|
|
}
|
|
// Pre-generate the issue id so this issue's comments, documents, and
|
|
// attachments can reference it without waiting on a per-issue insert
|
|
// round-trip. The row itself is buffered and flushed after the loop.
|
|
const issueId = randomUUID();
|
|
// Created comment ids are captured positionally so attachment
|
|
// entries can resolve their commentIndex against them.
|
|
const createdCommentIds: Array<string | null> = [];
|
|
for (const comment of manifestIssue.comments ?? []) {
|
|
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
|
|
? importedSlugToAgentId.get(comment.authorAgentSlug)
|
|
?? existingSlugToAgentId.get(comment.authorAgentSlug)
|
|
?? null
|
|
: null;
|
|
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
|
|
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
|
|
}
|
|
if (comment.authorType === "user" && !actorUserId) {
|
|
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because no importing user was available.`);
|
|
}
|
|
const authorType = authorAgentId
|
|
? "agent"
|
|
: comment.authorType === "user" && actorUserId
|
|
? "user"
|
|
: "system";
|
|
const commentId = randomUUID();
|
|
commentRows.push({
|
|
id: commentId,
|
|
companyId: targetCompany.id,
|
|
issueId,
|
|
body: rewriteEmbeddedAssetUrls(comment.body, embeddedAssetIdMap),
|
|
authorType,
|
|
authorAgentId: authorAgentId ?? null,
|
|
authorUserId: authorType === "user" ? actorUserId ?? null : null,
|
|
presentation: comment.presentation ?? null,
|
|
metadata: comment.metadata ?? null,
|
|
createdAt: comment.createdAt ?? null,
|
|
});
|
|
createdCommentIds.push(commentId);
|
|
}
|
|
importedIssueIdBySlug.set(manifestIssue.slug, issueId);
|
|
if ((manifestIssue.blockedBy ?? []).length > 0) {
|
|
blockedByBySlug.set(manifestIssue.slug, manifestIssue.blockedBy ?? []);
|
|
}
|
|
if (manifestIssue.parentSlug) {
|
|
parentSlugBySlug.set(manifestIssue.slug, manifestIssue.parentSlug);
|
|
}
|
|
for (const documentEntry of manifestIssue.documents ?? []) {
|
|
const documentBody = readPortableTextFile(plan.source.files, documentEntry.path);
|
|
if (documentBody === null) {
|
|
warnings.push(`Task ${manifestIssue.slug} document ${documentEntry.key} was skipped because its file is missing from the package: ${documentEntry.path}`);
|
|
continue;
|
|
}
|
|
documentRows.push({
|
|
companyId: targetCompany.id,
|
|
issueId,
|
|
key: documentEntry.key,
|
|
title: documentEntry.title,
|
|
format: documentEntry.format,
|
|
body: rewriteEmbeddedAssetUrls(documentBody, embeddedAssetIdMap),
|
|
createdByAgentId: null,
|
|
createdByUserId: actorUserId ?? null,
|
|
createdByRunId: null,
|
|
sourceTrust: null,
|
|
});
|
|
}
|
|
for (const workProductEntry of manifestIssue.workProducts ?? []) {
|
|
workProductRows.push({
|
|
companyId: targetCompany.id,
|
|
issueId,
|
|
projectId: projectId ?? null,
|
|
type: workProductEntry.type,
|
|
provider: workProductEntry.provider,
|
|
externalId: workProductEntry.externalId ?? null,
|
|
title: workProductEntry.title,
|
|
url: workProductEntry.url ?? null,
|
|
status: workProductEntry.status,
|
|
reviewState: workProductEntry.reviewState ?? "none",
|
|
isPrimary: workProductEntry.isPrimary ?? false,
|
|
healthStatus: workProductEntry.healthStatus ?? "unknown",
|
|
summary: workProductEntry.summary ?? null,
|
|
metadata: workProductEntry.metadata ?? null,
|
|
// Workspace/run references never travel across boards.
|
|
executionWorkspaceId: null,
|
|
runtimeServiceId: null,
|
|
createdByRunId: null,
|
|
sourceTrust: null,
|
|
});
|
|
}
|
|
// Monitors land un-armed: notes and provenance are restored on the
|
|
// issue row itself but monitorNextCheckAt stays NULL until an operator
|
|
// re-arms them.
|
|
let monitorNotes: string | null = null;
|
|
let monitorScheduledBy: string | null = null;
|
|
if (manifestIssue.monitor) {
|
|
if (manifestIssue.monitor.notes !== null || manifestIssue.monitor.scheduledBy !== null) {
|
|
monitorNotes = manifestIssue.monitor.notes;
|
|
monitorScheduledBy = manifestIssue.monitor.scheduledBy;
|
|
}
|
|
unarmedMonitorCount += 1;
|
|
}
|
|
for (const attachmentEntry of manifestIssue.attachments ?? []) {
|
|
const attachmentLabel = attachmentEntry.originalFilename ?? attachmentEntry.sha256;
|
|
if (!storage) {
|
|
attachmentsSkippedNoStorage += 1;
|
|
continue;
|
|
}
|
|
const blobPath = portableBlobPath(attachmentEntry.sha256);
|
|
const blobFile = plan.source.files[blobPath];
|
|
if (blobFile === undefined) {
|
|
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} was skipped because its blob is missing from the package: ${blobPath}`);
|
|
continue;
|
|
}
|
|
const body = portableFileToBuffer(blobFile, blobPath);
|
|
// Content-addressed blobs double as the bundle's tamper seal:
|
|
// bytes that do not hash to their declared sha256 fail closed.
|
|
if (sha256HexOfBytes(body) !== attachmentEntry.sha256) {
|
|
throw unprocessable(`Attachment blob ${blobPath} does not match its declared sha256; the package is corrupted or was tampered with.`);
|
|
}
|
|
if (body.length > MAX_ATTACHMENT_BYTES) {
|
|
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} was skipped because it exceeds this deployment's attachment size limit of ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)}.`);
|
|
continue;
|
|
}
|
|
let issueCommentId: string | null = null;
|
|
if (attachmentEntry.commentIndex !== null) {
|
|
issueCommentId = createdCommentIds[attachmentEntry.commentIndex] ?? null;
|
|
if (!issueCommentId) {
|
|
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} was imported at task scope because its comment reference could not be resolved.`);
|
|
}
|
|
}
|
|
try {
|
|
const stored = await storage.putFile({
|
|
companyId: targetCompany.id,
|
|
namespace: `issues/${issueId}`,
|
|
originalFilename: attachmentEntry.originalFilename,
|
|
contentType: attachmentEntry.contentType,
|
|
body,
|
|
});
|
|
attachmentRows.push({
|
|
companyId: targetCompany.id,
|
|
issueId,
|
|
issueCommentId,
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: null,
|
|
createdByUserId: actorUserId ?? null,
|
|
});
|
|
} catch (error) {
|
|
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} could not be imported: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
issueRows.push({
|
|
id: issueId,
|
|
ref: manifestIssue.slug,
|
|
projectId: projectId ?? null,
|
|
projectWorkspaceId: projectWorkspaceId ?? null,
|
|
title: manifestIssue.title,
|
|
description,
|
|
assigneeAgentId,
|
|
status: issueStatus,
|
|
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
|
|
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
|
|
: "medium",
|
|
billingCode: manifestIssue.billingCode ?? null,
|
|
assigneeAdapterOverrides: sanitizeImportedIssueAssigneeAdapterOverrides(
|
|
manifestIssue.assigneeAdapterOverrides,
|
|
),
|
|
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings ?? null,
|
|
labelIds: resolvedLabelIds,
|
|
monitorNotes,
|
|
monitorScheduledBy,
|
|
parentId: null,
|
|
createdAt: portableManifestDate(manifestIssue.createdAt),
|
|
updatedAt: portableManifestDate(manifestIssue.updatedAt),
|
|
startedAt: portableManifestDate(manifestIssue.startedAt),
|
|
completedAt: portableManifestDate(manifestIssue.completedAt),
|
|
cancelledAt: portableManifestDate(manifestIssue.cancelledAt),
|
|
});
|
|
}
|
|
|
|
// Parent links resolve against the pre-generated ids before the flush
|
|
// so each issue row carries its parentId on insert. Edges whose parent
|
|
// was not imported, self-references, and cycles (possible only in a
|
|
// hand-edited bundle) drop with a warning, mirroring blocker handling.
|
|
if (parentSlugBySlug.size > 0) {
|
|
const rowBySlug = new Map(issueRows.map((row) => [row.ref, row] as const));
|
|
const acceptedParentById = new Map<string, string>();
|
|
const wouldCreateParentCycle = (childId: string, parentId: string) => {
|
|
let current: string | undefined = parentId;
|
|
const visited = new Set<string>();
|
|
while (current) {
|
|
if (current === childId) return true;
|
|
if (visited.has(current)) return false;
|
|
visited.add(current);
|
|
current = acceptedParentById.get(current);
|
|
}
|
|
return false;
|
|
};
|
|
for (const [slug, parentSlug] of parentSlugBySlug) {
|
|
const row = rowBySlug.get(slug);
|
|
if (!row) continue;
|
|
const parentId = importedIssueIdBySlug.get(parentSlug);
|
|
if (!parentId) {
|
|
warnings.push(`Task ${slug} parent ${parentSlug} was skipped because that task was not imported.`);
|
|
continue;
|
|
}
|
|
if (parentId === row.id || wouldCreateParentCycle(row.id, parentId)) {
|
|
warnings.push(`Task ${slug} parent ${parentSlug} was skipped because it would create a parent cycle.`);
|
|
continue;
|
|
}
|
|
acceptedParentById.set(row.id, parentId);
|
|
row.parentId = parentId;
|
|
}
|
|
// The parent foreign key is checked per insert statement, so order
|
|
// rows parents-first: a child must never land in an earlier chunk
|
|
// than its parent.
|
|
if (acceptedParentById.size > 0) {
|
|
const rowById = new Map(issueRows.map((row) => [row.id, row] as const));
|
|
const orderedRows: typeof issueRows = [];
|
|
const emitted = new Set<string>();
|
|
for (const row of issueRows) {
|
|
const chain: typeof issueRows = [];
|
|
let current: (typeof issueRows)[number] | undefined = row;
|
|
while (current && !emitted.has(current.id)) {
|
|
emitted.add(current.id);
|
|
chain.push(current);
|
|
current = current.parentId ? rowById.get(current.parentId) : undefined;
|
|
}
|
|
for (const entry of chain.reverse()) orderedRows.push(entry);
|
|
}
|
|
issueRows.length = 0;
|
|
issueRows.push(...orderedRows);
|
|
}
|
|
}
|
|
|
|
// Flush the buffered rows in dependency order: issues first (parents of
|
|
// every other row), then comments (attachments may reference them), then
|
|
// the remaining children. Each writer inserts in chunked multi-row
|
|
// statements, turning what used to be one round-trip per row into a
|
|
// handful per table. Empty buffers are skipped so an issues-free import
|
|
// (e.g. routines only) issues no writes at all.
|
|
if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows);
|
|
// Imported issues are historical work, not new inbox items. Seed a
|
|
// per-user inbox archive for the importing board user so a large import
|
|
// (a real 1,418-task company shipped every task to the inbox) does not
|
|
// flood it: the inbox "mine" query hides archived issues, and genuine
|
|
// new activity still resurfaces them. Agent/system imports (no board
|
|
// user) have no inbox to protect, so the seeding is skipped.
|
|
if (actorUserId && issueRows.length > 0) {
|
|
await issues.archiveImportedInbox(
|
|
targetCompany.id,
|
|
issueRows.map((row) => row.id),
|
|
actorUserId,
|
|
);
|
|
}
|
|
if (commentRows.length > 0) await issues.addImportedComments(commentRows);
|
|
if (documentRows.length > 0) await documentsSvc.createIssueDocumentsForImport(documentRows);
|
|
if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows);
|
|
if (attachmentRows.length > 0) await issues.addImportedAttachments(attachmentRows);
|
|
|
|
if (blockedByBySlug.size > 0) {
|
|
const acceptedAdjacency = new Map<string, string[]>();
|
|
const wouldCreateBlockingCycle = (blockedIssueId: string, blockerIssueId: string) => {
|
|
// Mirrors assertNoBlockingCycles in the issues service: a cycle
|
|
// exists when the blocked issue already (transitively) blocks the
|
|
// prospective blocker.
|
|
const queue = [...(acceptedAdjacency.get(blockedIssueId) ?? [])];
|
|
const visited = new Set<string>([blockedIssueId]);
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!;
|
|
if (current === blockerIssueId) return true;
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
queue.push(...(acceptedAdjacency.get(current) ?? []));
|
|
}
|
|
return false;
|
|
};
|
|
const relationRows: Array<{ issueId: string; relatedIssueId: string }> = [];
|
|
for (const [slug, blockerSlugs] of blockedByBySlug) {
|
|
const blockedIssueId = importedIssueIdBySlug.get(slug);
|
|
if (!blockedIssueId) continue;
|
|
for (const blockerSlug of blockerSlugs) {
|
|
const blockerIssueId = importedIssueIdBySlug.get(blockerSlug);
|
|
if (!blockerIssueId) {
|
|
warnings.push(`Task ${slug} blocker ${blockerSlug} was skipped because that task was not imported.`);
|
|
continue;
|
|
}
|
|
if (blockerIssueId === blockedIssueId) continue;
|
|
if (wouldCreateBlockingCycle(blockedIssueId, blockerIssueId)) {
|
|
warnings.push(`Task ${slug} blocker ${blockerSlug} was skipped because it would create a blocking cycle.`);
|
|
continue;
|
|
}
|
|
const adjacency = acceptedAdjacency.get(blockerIssueId) ?? [];
|
|
adjacency.push(blockedIssueId);
|
|
acceptedAdjacency.set(blockerIssueId, adjacency);
|
|
relationRows.push({ issueId: blockerIssueId, relatedIssueId: blockedIssueId });
|
|
}
|
|
}
|
|
if (relationRows.length > 0) {
|
|
const relationCompanyId = targetCompany.id;
|
|
await db
|
|
.insert(issueRelations)
|
|
.values(relationRows.map((row) => ({
|
|
companyId: relationCompanyId,
|
|
issueId: row.issueId,
|
|
relatedIssueId: row.relatedIssueId,
|
|
type: "blocks" as const,
|
|
createdByAgentId: null,
|
|
createdByUserId: actorUserId ?? null,
|
|
})))
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
if (unarmedMonitorCount > 0) {
|
|
warnings.push(`${unarmedMonitorCount} monitor${unarmedMonitorCount === 1 ? " was" : "s were"} imported un-armed; re-arm ${unarmedMonitorCount === 1 ? "it" : "them"} from the task page to resume checks.`);
|
|
}
|
|
|
|
if (attachmentsSkippedNoStorage > 0) {
|
|
warnings.push(`Skipped ${attachmentsSkippedNoStorage} attachment${attachmentsSkippedNoStorage === 1 ? "" : "s"} because storage is unavailable.`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
company: {
|
|
id: targetCompany.id,
|
|
name: targetCompany.name,
|
|
action: companyAction,
|
|
},
|
|
agents: resultAgents,
|
|
skills: importedSkills.map((result) => ({
|
|
originalKey: result.originalKey,
|
|
originalSlug: result.originalSlug,
|
|
key: result.skill.key,
|
|
slug: result.skill.slug,
|
|
id: result.skill.id,
|
|
action: result.action,
|
|
reason: result.reason,
|
|
})),
|
|
projects: resultProjects,
|
|
routines: resultRoutines,
|
|
envInputs: sourceManifest.envInputs ?? [],
|
|
warnings,
|
|
};
|
|
} catch (error) {
|
|
for (const secretId of createdImportSecretIds) {
|
|
await secrets.remove(secretId).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return {
|
|
exportBundle,
|
|
previewExport,
|
|
previewImport,
|
|
importBundle,
|
|
};
|
|
}
|