mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Repair stale worktree links before runtime start
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
08fea10ce1
commit
8be6fe987b
2 changed files with 245 additions and 2 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cleanupExecutionWorkspaceArtifacts,
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
|
ensureServerWorkspaceLinksCurrent,
|
||||||
ensureRuntimeServicesForRun,
|
ensureRuntimeServicesForRun,
|
||||||
normalizeAdapterManagedRuntimeServices,
|
normalizeAdapterManagedRuntimeServices,
|
||||||
reconcilePersistedRuntimeServicesOnStartup,
|
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", () => {
|
describe("realizeExecutionWorkspace", () => {
|
||||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
|
|
@ -663,7 +754,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
await fs.realpath(path.join(repoRoot, "packages", "shared")),
|
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 () => {
|
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
@ -122,6 +123,153 @@ function stableStringify(value: unknown): string {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceLinkMismatch = {
|
||||||
|
packageName: string;
|
||||||
|
expectedPath: string;
|
||||||
|
actualPath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> {
|
||||||
|
const packagePaths = new Map<string, string>();
|
||||||
|
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<string, unknown> | undefined),
|
||||||
|
...(serverPackageJson.devDependencies as Record<string, unknown> | 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<void>((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<void>;
|
||||||
|
runCommand?: (command: string, args: string[], cwd: string) => Promise<void>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
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 {
|
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||||
for (const key of Object.keys(env)) {
|
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 shell = resolveShell();
|
||||||
const child = spawn(shell, ["-lc", command], {
|
const child = spawn(shell, ["-lc", command], {
|
||||||
cwd: serviceCwd,
|
cwd: serviceCwd,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue