From 8be6fe987b67065a3c8bb5ac8ae7b1e444b01cf9 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 20:02:04 -0500 Subject: [PATCH] Repair stale worktree links before runtime start Co-Authored-By: Paperclip --- .../src/__tests__/workspace-runtime.test.ts | 93 ++++++++++- server/src/services/workspace-runtime.ts | 154 +++++++++++++++++- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 911010f5..0d6211c1 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -20,6 +20,7 @@ import { import { eq } from "drizzle-orm"; import { cleanupExecutionWorkspaceArtifacts, + ensureServerWorkspaceLinksCurrent, ensureRuntimeServicesForRun, normalizeAdapterManagedRuntimeServices, reconcilePersistedRuntimeServicesOnStartup, @@ -187,6 +188,96 @@ describe("sanitizeRuntimeServiceBaseEnv", () => { }); }); +describe("ensureServerWorkspaceLinksCurrent", () => { + it("relinks stale server workspace dependencies inside the current repo root", async () => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-")); + const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-")); + const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai"); + const expectedPackageDir = path.join(repoRoot, "packages", "db"); + const stalePackageDir = path.join(staleRoot, "db"); + + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.mkdir(expectedPackageDir, { recursive: true }); + await fs.mkdir(stalePackageDir, { recursive: true }); + await fs.mkdir(serverNodeModulesScopeDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify({ + name: "@paperclipai/server", + dependencies: { + "@paperclipai/db": "workspace:*", + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(expectedPackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.writeFile( + path.join(stalePackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db")); + + const commands: Array<{ command: string; args: string[]; cwd: string }> = []; + await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"), { + runCommand: async (command, args, cwd) => { + commands.push({ command, args, cwd }); + await fs.rm(path.join(serverNodeModulesScopeDir, "db"), { force: true }); + await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db")); + }, + }); + + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + command: process.platform === "win32" ? "pnpm.cmd" : "pnpm", + args: ["install", "--force", "--config.confirmModulesPurge=false"], + cwd: repoRoot, + }); + expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir)); + }); + + it("skips relinking when server workspace dependencies already point at the repo", async () => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-")); + const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai"); + const expectedPackageDir = path.join(repoRoot, "packages", "db"); + + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.mkdir(expectedPackageDir, { recursive: true }); + await fs.mkdir(serverNodeModulesScopeDir, { recursive: true }); + await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify({ + name: "@paperclipai/server", + dependencies: { + "@paperclipai/db": "workspace:*", + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(expectedPackageDir, "package.json"), + JSON.stringify({ name: "@paperclipai/db" }), + "utf8", + ); + await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db")); + + let invoked = false; + await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"), { + runCommand: async () => { + invoked = true; + }, + }); + + expect(invoked).toBe(false); + }); +}); + describe("realizeExecutionWorkspace", () => { it("creates and reuses a git worktree for an issue-scoped branch", async () => { const repoRoot = await createTempRepo(); @@ -663,7 +754,7 @@ describe("realizeExecutionWorkspace", () => { await fs.realpath(path.join(repoRoot, "packages", "shared")), ); }, - 15_000, + 30_000, ); it("records worktree setup and provision operations when a recorder is provided", async () => { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 44040311..e9e97548 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import fs from "node:fs/promises"; import net from "node:net"; import { createHash, randomUUID } from "node:crypto"; @@ -122,6 +123,153 @@ function stableStringify(value: unknown): string { return JSON.stringify(value); } +type WorkspaceLinkMismatch = { + packageName: string; + expectedPath: string; + actualPath: string | null; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function findWorkspaceRoot(startCwd: string) { + let current = path.resolve(startCwd); + while (true) { + if (existsSync(path.join(current, "pnpm-workspace.yaml"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function discoverWorkspacePackagePaths(rootDir: string): Map { + const packagePaths = new Map(); + const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]); + + function visit(dirPath: string) { + if (!existsSync(dirPath)) return; + + const packageJsonPath = path.join(dirPath, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + packagePaths.set(packageJson.name, dirPath); + } + } + + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (ignoredDirNames.has(entry.name)) continue; + visit(path.join(dirPath, entry.name)); + } + } + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; +} + +function findServerWorkspaceLinkMismatches(rootDir: string): WorkspaceLinkMismatch[] { + const serverPackageJsonPath = path.join(rootDir, "server", "package.json"); + if (!existsSync(serverPackageJsonPath)) return []; + + const serverPackageJson = readJsonFile(serverPackageJsonPath); + const dependencies = { + ...(serverPackageJson.dependencies as Record | undefined), + ...(serverPackageJson.devDependencies as Record | undefined), + }; + const workspacePackagePaths = discoverWorkspacePackagePaths(rootDir); + const mismatches: WorkspaceLinkMismatch[] = []; + + for (const [packageName, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !version.startsWith("workspace:")) continue; + + const expectedPath = workspacePackagePaths.get(packageName); + if (!expectedPath) continue; + const normalizedExpectedPath = existsSync(expectedPath) ? path.resolve(realpathSync(expectedPath)) : path.resolve(expectedPath); + + const linkPath = path.join(rootDir, "server", "node_modules", ...packageName.split("/")); + const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; + if (actualPath === normalizedExpectedPath) continue; + + mismatches.push({ + packageName, + expectedPath: normalizedExpectedPath, + actualPath, + }); + } + + return mismatches; +} + +async function runCommand(command: string, args: string[], cwd: string) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "ignore", + shell: process.platform === "win32", + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`, + ), + ); + }); + }); +} + +export async function ensureServerWorkspaceLinksCurrent( + startCwd: string, + opts?: { + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + runCommand?: (command: string, args: string[], cwd: string) => Promise; + }, +) { + const workspaceRoot = findWorkspaceRoot(startCwd); + if (!workspaceRoot) return; + + const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot); + if (mismatches.length === 0) return; + + if (opts?.onLog) { + await opts.onLog("stdout", "[runtime] detected stale workspace package links for server; relinking dependencies...\n"); + for (const mismatch of mismatches) { + await opts.onLog( + "stdout", + `[runtime] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}\n`, + ); + } + } + + const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + await (opts?.runCommand ?? runCommand)( + pnpmBin, + ["install", "--force", "--config.confirmModulesPurge=false"], + workspaceRoot, + ); + + const remainingMismatches = findServerWorkspaceLinkMismatches(workspaceRoot); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { @@ -1374,7 +1522,11 @@ async function startLocalRuntimeService(input: { ); } } - + + await ensureServerWorkspaceLinksCurrent(serviceCwd, { + onLog: input.onLog, + }); + const shell = resolveShell(); const child = spawn(shell, ["-lc", command], { cwd: serviceCwd,