diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index a80c01f7793..0fe3a2625d3 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -80,6 +80,84 @@ paperclipai_command_available() { return 1 } +existing_worktree_config_is_usable() { + WORKTREE_CONFIG_PATH="$worktree_config_path" \ + WORKTREE_ENV_PATH="$worktree_env_path" \ + node <<'EOF' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function expandHomePrefix(value) { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function parseEnvFile(contents) { + const entries = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = rawValue.trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + return entries; +} + +function fail(reason) { + console.error(reason); + process.exit(1); +} + +const configPath = path.resolve(process.env.WORKTREE_CONFIG_PATH); +const envPath = path.resolve(process.env.WORKTREE_ENV_PATH); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +const env = parseEnvFile(fs.readFileSync(envPath, "utf8")); +const envConfigPath = expandHomePrefix(env.PAPERCLIP_CONFIG); +if (envConfigPath && path.resolve(envConfigPath) !== configPath) { + fail(`existing worktree env points at ${envConfigPath}, not ${configPath}`); +} + +const homeDir = expandHomePrefix(env.PAPERCLIP_HOME); +const instanceId = env.PAPERCLIP_INSTANCE_ID; +if (!homeDir || !instanceId) { + fail("existing worktree env is missing PAPERCLIP_HOME or PAPERCLIP_INSTANCE_ID"); +} +if (!fs.existsSync(homeDir)) { + fail(`existing worktree home does not exist on this host: ${homeDir}`); +} + +const instanceRoot = path.resolve(homeDir, "instances", instanceId); +const runtimePaths = [ + config.database?.embeddedPostgresDataDir, + config.database?.backup?.dir, + config.logging?.logDir, + config.storage?.localDisk?.baseDir, + config.secrets?.localEncrypted?.keyFilePath, +].filter((value) => typeof value === "string" && value.length > 0); + +for (const rawValue of runtimePaths) { + const resolved = path.resolve(expandHomePrefix(rawValue)); + const relative = path.relative(instanceRoot, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`existing worktree config path is outside ${instanceRoot}: ${resolved}`); + } +} +EOF +} + write_fallback_worktree_config() { WORKTREE_NAME="$worktree_name" \ BASE_CWD="$base_cwd" \ @@ -332,9 +410,12 @@ main().catch((error) => { EOF } -if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]]; then +if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2 else + if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then + echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2 + fi if paperclipai_command_available; then run_isolated_worktree_init else diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index b0f9ac016a5..9ba8780e938 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1536,6 +1536,97 @@ describe("realizeExecutionWorkspace", () => { } }); + it("regenerates stale worktree config that points at another host", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-config-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + + try { + await fs.mkdir(baseRoot, { recursive: true }); + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + path.join(paperclipDir, "config.json"), + JSON.stringify({ + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/Users/example/.paperclip-worktrees/instances/stale/db", + }, + logging: { + mode: "file", + logDir: "/Users/example/.paperclip-worktrees/instances/stale/logs", + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/Users/example/.paperclip-worktrees/instances/stale/data/storage", + }, + }, + secrets: { + provider: "local_encrypted", + localEncrypted: { + keyFilePath: "/Users/example/.paperclip-worktrees/instances/stale/secrets/master.key", + }, + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(paperclipDir, ".env"), + [ + "PAPERCLIP_HOME=/Users/example/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=stale", + `PAPERCLIP_CONFIG=/Users/example/paperclip/${path.basename(worktreeRoot)}/.paperclip/config.json`, + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then", + " mkdir -p \"$PWD/.paperclip\"", + " printf '%s\\n' '{\"database\":{\"embeddedPostgresDataDir\":\"'$PWD'/.paperclip/runtime/db\"}}' > \"$PWD/.paperclip/config.json\"", + " printf '%s\\n' \"PAPERCLIP_HOME=$PWD/.paperclip/runtime\" \"PAPERCLIP_INSTANCE_ID=healthy\" \"PAPERCLIP_CONFIG=$PWD/.paperclip/config.json\" > \"$PWD/.paperclip/.env\"", + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + const result = await execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + + expect(result.stderr).toContain("Existing isolated Paperclip worktree config is stale for this host; regenerating."); + await expect(fs.readFile(path.join(paperclipDir, ".env"), "utf8")).resolves.toContain( + `PAPERCLIP_CONFIG=${worktreeRoot}/.paperclip/config.json`, + ); + await expect(fs.readFile(path.join(paperclipDir, "config.json"), "utf8")).resolves.toContain(worktreeRoot); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-")); const baseRoot = path.join(tempRoot, "base");