import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, type Dirent } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefreshResult, WorkspaceSymlinkProvision } from "agent-relay-sdk"; import { errMessage } from "agent-relay-sdk"; import { workspaceDepsMode } from "../config"; import { execProcess } from "../process"; // Floor for the workspace scan. `workspaceScanDepth` widens this to reach every // workspace the repo's package.json declares (e.g. a nested `connectors/voice`), // so no declared workspace is missed regardless of nesting. const NODE_MODULES_SCAN_DEPTH = 2; const LOCKFILES: Array<{ file: string; pm: string; install: string[] }> = [ { file: "bun.lockb", pm: "bun", install: ["bun", "install"] }, { file: "bun.lock", pm: "bun", install: ["bun", "install"] }, { file: "pnpm-lock.yaml", pm: "pnpm", install: ["pnpm", "install"] }, { file: "package-lock.json", pm: "npm", install: ["npm", "install"] }, { file: "yarn.lock", pm: "yarn", install: ["yarn", "install"] }, ]; function pathExists(p: string): boolean { try { lstatSync(p); return true; } catch { return false; } } /** * Depth-limited walk collecting relative dirs (from root) matching `match`. * Never descends into node_modules or dot-dirs (no point, and avoids walking * into sibling worktrees under .agent-relay/). */ function scanProjectDirs(root: string, depth: number, match: (dir: string) => boolean): string[] { const found: string[] = []; const walk = (dir: string, rel: string, remaining: number): void => { if (match(dir)) found.push(rel); if (remaining <= 0) return; let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; walk(join(dir, entry.name), rel ? join(rel, entry.name) : entry.name, remaining - 1); } }; walk(root, "", depth); return found; } /** * Scan depth needed to reach every workspace `root`'s package.json declares. Reads the * `workspaces` globs (array form, or the `{ packages: [...] }` object form) purely to * WIDEN the scan when a repo nests workspaces below the default floor — the deepest * glob's path-segment count is the depth at which `scanProjectDirs` matches that * workspace's node_modules. Absent/malformed config falls back to the floor (a plain * single-package repo). This governs how DEEP the scan reaches, never WHICH dirs are * linked — the link set still comes from scanning for real node_modules dirs, so the * derivation makes no package-manager assumption. */ function workspaceScanDepth(root: string): number { let deepest = NODE_MODULES_SCAN_DEPTH; try { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { workspaces?: unknown }; const ws = pkg.workspaces; const globs = Array.isArray(ws) ? ws : ws && typeof ws === "object" && Array.isArray((ws as { packages?: unknown }).packages) ? (ws as { packages: unknown[] }).packages : []; for (const glob of globs) { if (typeof glob !== "string") continue; const segments = glob.split("/").filter((s) => s.length > 0 && s !== ".").length; if (segments > deepest) deepest = segments; } } catch { // No/invalid package.json — the floor is correct for a single-package repo. } return deepest; } /** * Relative dirs (from `root`) that own a `node_modules` directory, derived from the * repo's ACTUAL layout — never a hardcoded per-project list (#1390). Toolchain-agnostic: * it links whatever per-workspace `node_modules` the source has, making no assumption * about the package manager or workspace-config format. Depth reaches every workspace. * * SHARED derivation: both the worker-worktree linker (`symlinkNodeModules`) and the * land-gate linker (`integrated-land-gates`) call this, so the two can't drift apart * the way the old hardcoded land-gate list did. `""` denotes the repo root. */ export function nodeModulesDirs(root: string): string[] { return scanProjectDirs(root, workspaceScanDepth(root), (dir) => existsSync(join(dir, "node_modules"))); } function detectPackageManager(dir: string): { pm: string; install: string[] } | undefined { for (const lock of LOCKFILES) { if (existsSync(join(dir, lock.file))) return { pm: lock.pm, install: lock.install }; } return undefined; } /** Symlink the source checkout's node_modules dirs into the worktree. Fast and * exact (matches the deps the source actually runs with). Best-effort per dir. */ function symlinkNodeModules(repoRoot: string, worktreePath: string): string[] { const dirs = nodeModulesDirs(repoRoot); const linked: string[] = []; for (const rel of dirs) { const source = join(repoRoot, rel, "node_modules"); const targetParent = join(worktreePath, rel); const target = join(targetParent, "node_modules"); if (pathExists(target)) continue; try { mkdirSync(targetParent, { recursive: true }); symlinkSync(source, target, "dir"); linked.push(rel || "."); } catch { // best-effort: a single failed link shouldn't block the spawn } } return linked; } const GLOB_META = /[*?[\]{}!()]/; /** * Resolve one config entry to the relative paths it covers within `repoRoot`. * Plain entries are taken literally (match files AND dirs via existsSync); entries * with glob metacharacters are expanded against main with dotfiles included. Anything * that resolves outside the repo (absolute / `..` traversal) or doesn't exist is dropped. */ function resolveSymlinkPattern(repoRoot: string, pattern: string): string[] { const within = (rel: string): boolean => { const abs = resolve(repoRoot, rel); const back = relative(repoRoot, abs); return back !== "" && !back.startsWith("..") && !isAbsolute(back); }; if (GLOB_META.test(pattern)) { const matches: string[] = []; for (const rel of new Bun.Glob(pattern).scanSync({ cwd: repoRoot, dot: true, onlyFiles: false })) { if (within(rel)) matches.push(rel); } return matches; } if (!within(pattern)) return []; return existsSync(join(repoRoot, pattern)) ? [pattern] : []; } /** * Symlink configured untracked files/dirs from the main checkout into a fresh isolated * worktree. Only links paths that exist in main and aren't already present in the worktree * (git-tracked files are left untouched). Best-effort: a failed link never blocks the spawn. */ export function provisionWorkspaceSymlinks( repoRoot: string, worktreePath: string, patterns: string[], ): WorkspaceSymlinkProvision { const linked: string[] = []; const seen = new Set(); const errors: string[] = []; for (const pattern of patterns) { let rels: string[]; try { rels = resolveSymlinkPattern(repoRoot, pattern); } catch (error) { errors.push(`${pattern}: ${errMessage(error)}`); continue; } for (const rel of rels) { if (seen.has(rel)) continue; seen.add(rel); const source = join(repoRoot, rel); const target = join(worktreePath, rel); if (pathExists(target)) continue; // git-tracked or already linked — don't clobber try { mkdirSync(dirname(target), { recursive: true }); symlinkSync(source, target, lstatSync(source).isDirectory() ? "dir" : "file"); linked.push(rel); } catch (error) { errors.push(`${rel}: ${errMessage(error)}`); } } } return errors.length ? { linked, errors } : { linked }; } /** Run the detected package manager's install in a single dir. Shared by fresh * provisioning and the deps refresh (issue #51) so the install invocation lives * in one place. */ async function installDir(dir: string): Promise<{ ok: boolean; packageManager?: string; error?: string }> { const pm = detectPackageManager(dir); if (!pm) return { ok: false, error: "no recognized lockfile" }; const proc = await execProcess(pm.install, { cwd: dir, stdout: "ignore", stderr: "pipe", env: process.env }); if (proc.ok) return { ok: true, packageManager: pm.pm }; return { ok: false, packageManager: pm.pm, error: proc.stderr.slice(0, 300) }; } /** Run a package install in each dir that owns a lockfile. Root install covers * the package manager's workspace members; standalone sub-projects with their * own lockfile (e.g. dashboard) get a separate install. */ async function installNodeModules(worktreePath: string): Promise<{ installed: string[]; packageManager?: string; error?: string }> { const dirs = scanProjectDirs(worktreePath, workspaceScanDepth(worktreePath), (dir) => detectPackageManager(dir) !== undefined); const installed: string[] = []; let packageManager: string | undefined; let error: string | undefined; for (const rel of dirs) { const result = await installDir(join(worktreePath, rel)); if (result.packageManager) packageManager = result.packageManager; if (result.ok) { installed.push(rel || "."); } else { const detail = `${rel || "."}: ${result.error ?? "install failed"}`; error = error ? `${error}; ${detail}` : detail; } } return { installed, packageManager, error }; } const DEP_FIELDS = ["dependencies", "devDependencies"] as const; /** Top-level deps a package.json declares that MUST be present for typecheck/build * (dependencies + devDependencies). peer/optional are excluded — their absence is * legitimate and would cause spurious refreshes. */ function declaredDeps(pkgPath: string): string[] { try { const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record; const names = new Set(); for (const field of DEP_FIELDS) { const deps = pkg[field]; if (deps && typeof deps === "object") for (const name of Object.keys(deps as Record)) names.add(name); } return [...names]; } catch { return []; } } /** Declared deps absent from `dir`/node_modules (resolves through a symlink to the * shared source). `join` handles scoped names (`@scope/pkg`). This is the staleness * signal: a dep added to the base after the worktree's node_modules was provisioned. */ function missingDeps(dir: string): string[] { const nm = join(dir, "node_modules"); return declaredDeps(join(dir, "package.json")).filter((name) => !existsSync(join(nm, name))); } function isSymlink(p: string): boolean { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } } /** * Workspaces whose node_modules exists in the SOURCE checkout but is ABSENT from the * worktree (#1390). These were missed at provision time — the worktree was created * before that workspace's node_modules was populated (e.g. `kernel/node_modules` * appearing after a `zod` dep was added). Lacking a lockfile of their own, they are * skipped by the install loop, so the refresh must (re)link them. Distinct from a * STALE dir (symlink present but missing a newly-declared dep) — this is a dir with * no link at all. `""` denotes the repo root. */ function missingWorkspaceLinks(repoRoot: string, worktreePath: string): string[] { return nodeModulesDirs(repoRoot).filter((rel) => !pathExists(join(worktreePath, rel, "node_modules"))); } /** * Re-provision an isolated worktree's deps when the shared (symlinked) node_modules * has gone stale relative to the worktree's package.json — i.e. a dep was added to * the base AFTER the worktree was created (issue #51). For each stale project dir, * replace the shared symlink with a REAL, isolated install so we never `bun install` * through the symlink into the source checkout (forbidden + races sibling worktrees). * `checkOnly` reports staleness without installing. Never throws. */ export async function refreshWorkspaceDeps(repoRoot: string, worktreePath: string, opts: { checkOnly?: boolean } = {}): Promise { const requested = workspaceDepsMode(); if (requested === "none") return { refreshed: false, dirs: [], error: "deps provisioning disabled (AGENT_RELAY_WORKSPACE_DEPS=none)" }; try { const dirs = scanProjectDirs(worktreePath, workspaceScanDepth(worktreePath), (dir) => existsSync(join(dir, "package.json")) && detectPackageManager(dir) !== undefined); const installDirs = new Set(dirs.map((rel) => rel || ".")); const results: WorkspaceDepsRefreshDir[] = []; // Bug B (#1390): (re)link workspaces whose node_modules exists in the source but is // absent here — provisioning missed them (worktree created before that workspace's // node_modules was populated). These have no lockfile of their own, so the install // loop below skips them; linking the shared symlink is exactly right (their deps // live in the source, unchanged). Dirs the install loop DOES own are left to it // (it may promote a stale symlink to a real install). check-only never mutates. for (const rel of missingWorkspaceLinks(repoRoot, worktreePath)) { const label = rel || "."; if (installDirs.has(label)) continue; if (opts.checkOnly) { results.push({ dir: label, status: "stale", wasSymlink: false }); continue; } const source = join(repoRoot, rel, "node_modules"); const targetParent = join(worktreePath, rel); const target = join(targetParent, "node_modules"); try { mkdirSync(targetParent, { recursive: true }); symlinkSync(source, target, "dir"); results.push({ dir: label, status: "relinked" }); } catch (error) { results.push({ dir: label, status: "failed", error: `relink failed: ${errMessage(error)}` }); } } for (const rel of dirs) { const dir = join(worktreePath, rel); const missing = missingDeps(dir); const label = rel || "."; if (missing.length === 0) { results.push({ dir: label, status: "ok" }); continue; } if (opts.checkOnly) { results.push({ dir: label, status: "stale", missing: missing.slice(0, 20), wasSymlink: isSymlink(join(dir, "node_modules")) }); continue; } const nm = join(dir, "node_modules"); const wasSymlink = isSymlink(nm); // Drop the shared symlink first so the install writes a real dir here, not // through the link into the source checkout. rmSync on a symlink unlinks only // the link — the source node_modules is untouched. if (wasSymlink) { try { rmSync(nm); } catch { /* fall through; install may still succeed */ } } const result = await installDir(dir); if (result.ok) { const stillMissing = missingDeps(dir); results.push({ dir: label, status: stillMissing.length ? "failed" : "installed", missing: missing.slice(0, 20), wasSymlink, ...(result.packageManager ? { packageManager: result.packageManager } : {}), ...(stillMissing.length ? { error: `still missing after install: ${stillMissing.slice(0, 10).join(", ")}` } : {}), }); } else { // Install failed — restore the prior symlink so the worktree isn't left with // NO deps at all (worse than stale). Best-effort. if (wasSymlink) { try { symlinkSync(join(repoRoot, rel, "node_modules"), nm, "dir"); } catch { /* leave as-is */ } } results.push({ dir: label, status: "failed", missing: missing.slice(0, 20), wasSymlink, ...(result.packageManager ? { packageManager: result.packageManager } : {}), ...(result.error ? { error: result.error } : {}) }); } } const refreshed = results.some((r) => r.status === "installed" || r.status === "relinked"); const stale = results.some((r) => r.status === "stale" || r.status === "failed"); const failed = results.find((r) => r.status === "failed"); return { refreshed, ...(stale ? { stale } : {}), dirs: results, ...(failed ? { error: `${failed.dir}: ${failed.error ?? "install failed"}` } : {}) }; } catch (error) { return { refreshed: false, dirs: [], error: errMessage(error) }; } } /** * Provision node_modules into a freshly created isolated worktree (#159). * Default: symlink the source checkout's node_modules (instant, matches what * the source runs with). Falls back to a fresh install when the source has no * node_modules to borrow. Set AGENT_RELAY_WORKSPACE_DEPS=install to always * install (full isolation, no shared cache), or =none to skip entirely. * Never throws — provisioning failure must not block the spawn. */ export async function provisionWorkspaceDeps(repoRoot: string, worktreePath: string): Promise { const requested = workspaceDepsMode(); if (requested === "none") return { mode: "none" }; try { if (requested !== "install") { const linked = symlinkNodeModules(repoRoot, worktreePath); if (linked.length > 0) return { mode: "symlink", linked }; // Source has no installed deps to borrow — install fresh instead. } const result = await installNodeModules(worktreePath); if (result.installed.length === 0 && !result.error) return { mode: "none" }; return { mode: "install", installed: result.installed, ...(result.packageManager ? { packageManager: result.packageManager } : {}), ...(result.error ? { error: result.error } : {}), }; } catch (error) { return { mode: "none", error: errMessage(error) }; } }