Reduce portability warning fan-out

Infer portable repo metadata from local git workspaces when repoUrl is missing, and collapse repeated task workspace export warnings into a single summary per missing workspace.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-23 12:35:08 -05:00
parent 2e76a2a554
commit c41dd2e393
2 changed files with 299 additions and 10 deletions

View File

@@ -1,3 +1,7 @@
import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
@@ -859,6 +863,194 @@ describe("company portability", () => {
}));
});
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
const portability = companyPortabilityService({} as any);
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], {
cwd: repoDir,
stdio: "ignore",
});
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Paperclip App",
urlKey: "paperclip-app",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-1",
},
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "paperclip",
sourceType: "local_path",
cwd: repoDir,
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: "Task body",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
expect(extension).toContain('projectWorkspaceKey: "paperclip"');
expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl"));
expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1"));
});
it("collapses repeated task workspace warnings into one summary per missing workspace", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Local Scratch",
sourceType: "local_path",
cwd: "/tmp/local-only",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-2",
identifier: "PAP-2",
title: "Task two",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-3",
identifier: "PAP-3",
title: "Task three",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably.");
expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0);
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);

View File

@@ -705,7 +705,60 @@ function stripPortableProjectExecutionWorkspaceRefs(policy: Record<string, unkno
return isPlainRecord(cleaned) ? cleaned : null;
}
function buildPortableProjectWorkspaces(
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[],
@@ -713,17 +766,43 @@ function buildPortableProjectWorkspaces(
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 repoUrl = asString(workspace.repoUrl);
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)) {
@@ -748,8 +827,8 @@ function buildPortableProjectWorkspaces(
name: workspace.name,
sourceType: workspace.sourceType,
repoUrl,
repoRef: asString(workspace.repoRef),
defaultRef: asString(workspace.defaultRef),
repoRef,
defaultRef,
visibility: asString(workspace.visibility),
setupCommand,
cleanupCommand,
@@ -759,19 +838,21 @@ function buildPortableProjectWorkspaces(
if (!isPlainRecord(portableWorkspace)) continue;
exportedWorkspaces[workspaceKey] = portableWorkspace;
manifestWorkspaces.push({
const manifestWorkspace = {
key: workspaceKey,
name: workspace.name,
sourceType: asString(workspace.sourceType),
repoUrl,
repoRef: asString(workspace.repoRef),
defaultRef: asString(workspace.defaultRef),
repoRef,
defaultRef,
visibility: asString(workspace.visibility),
setupCommand,
cleanupCommand,
metadata,
isPrimary: workspace.isPrimary,
});
};
manifestWorkspaces.push(manifestWorkspace);
manifestWorkspaceByKey.set(workspaceKey, manifestWorkspace);
}
return {
@@ -2838,6 +2919,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
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]>();
@@ -2971,7 +3053,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const project of selectedProjectRows) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const portableWorkspaces = buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
files[projectPath] = buildMarkdown(
{
@@ -3007,7 +3089,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null
: null;
if (issue.projectWorkspaceId && !projectWorkspaceKey) {
warnings.push(`Task ${taskSlug} workspace reference ${issue.projectWorkspaceId} was omitted from export because that workspace is not portable.`);
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],
});
}
}
files[taskPath] = buildMarkdown(
{
@@ -3030,6 +3121,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
}
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 = projectSlugById.get(routine.projectId) ?? null;