import { createHash } from "node:crypto"; import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { errMessage, isPathWithinBase } from "agent-relay-sdk"; import { git, requireGit } from "../git"; import { execProcess } from "../process"; import { withRepoLock } from "../repo-lock"; import type { ProjectAcquisitionManifest } from "./types"; export type ProjectRootSyncAction = "synced" | "noop" | "skipped-dirty" | "skipped-diverged"; export interface ProjectAcquisitionResult { applied: boolean; action: "cloned" | "synced" | "noop"; syncAction: ProjectRootSyncAction; rootPath: string; remoteUrl: string; ref?: string; headSha?: string; baseRef?: string; baseSha?: string; syncTarget?: string; } const inFlight = new Map>(); const LOCK_TIMEOUT_MS = 5 * 60_000; const LOCK_POLL_MS = 100; export async function applyProjectAcquisitionManifest( manifest: ProjectAcquisitionManifest | undefined, baseDir: string, ): Promise { if (!manifest) return undefined; const rootPath = resolve(manifest.rootPath); const prior = inFlight.get(rootPath); if (prior) return prior; // #1020 (B2) — take the physical-repo lock BEFORE the acquisition's git mutations so a land/cleanup // on the same checkout (which also takes it) can't interleave. The file lock below still guards // against a second orchestrator process; this in-process lock guards against the concurrent // background spawn dispatch on THIS process. const run = withRepoLock(rootPath, () => withAcquisitionLock(rootPath, baseDir, () => applyManifestLocked(manifest, baseDir))); inFlight.set(rootPath, run); try { return await run; } finally { if (inFlight.get(rootPath) === run) inFlight.delete(rootPath); } } async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () => Promise): Promise { const lockDir = join(resolve(baseDir), ".agent-relay", "locks", `acquire-${hash(rootPath)}.lock`); mkdirSync(dirname(lockDir), { recursive: true }); const started = Date.now(); for (;;) { try { mkdirSync(lockDir); writeFileSync(join(lockDir, "owner"), `${process.pid}\n${rootPath}\n`); break; } catch (error) { if (Date.now() - started > LOCK_TIMEOUT_MS) { throw new Error(`repo acquisition lock timed out for ${rootPath}: ${errMessage(error)}`); } await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS)); } } try { return await fn(); } finally { rmSync(lockDir, { recursive: true, force: true }); } } async function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: string): Promise { validateManifest(manifest, baseDir); const rootPath = resolve(manifest.rootPath); const remoteUrl = manifest.remoteUrl.trim(); let action: ProjectAcquisitionResult["action"] = "noop"; if (!existsSync(rootPath)) { await cloneRoot(manifest, baseDir); action = "cloned"; } const sync = await syncRoot(manifest); if (sync.action === "synced" && action !== "cloned") action = "synced"; return { applied: true, action, syncAction: sync.action, rootPath, remoteUrl, ...(manifest.ref ? { ref: manifest.ref } : {}), headSha: (await git(["rev-parse", "HEAD"], rootPath)).stdout || undefined, ...(sync.baseRef ? { baseRef: sync.baseRef } : {}), ...(sync.baseSha ? { baseSha: sync.baseSha } : {}), ...(sync.target ? { syncTarget: sync.target } : {}), }; } function validateManifest(manifest: ProjectAcquisitionManifest, baseDir: string): void { const rootPath = resolve(manifest.rootPath); if (!manifest.remoteUrl.trim()) throw new Error("project acquisition remoteUrl is required"); if (!isPathWithinBase(rootPath, baseDir) || rootPath === resolve(baseDir)) { throw new Error(`project acquisition rootPath must be within orchestrator baseDir: ${baseDir}`); } if (!isPathWithinBase(resolve(manifest.cwd), rootPath)) { throw new Error(`project acquisition cwd must be within rootPath: ${manifest.cwd}`); } } async function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): Promise { const rootPath = resolve(manifest.rootPath); const parent = dirname(rootPath); if (!isPathWithinBase(parent, baseDir)) throw new Error(`project acquisition parent must be within orchestrator baseDir: ${parent}`); mkdirSync(parent, { recursive: true }); const tmp = join(parent, `.${basename(rootPath)}.agent-relay-clone-${process.pid}-${Date.now()}`); rmSync(tmp, { recursive: true, force: true }); const args = ["clone", "--origin", "origin"]; if (manifest.ref) args.push("--branch", manifest.ref); args.push(manifest.remoteUrl.trim(), tmp); const cloned = await runGit(args, parent); if (!cloned.ok) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`git clone failed for ${rootPath}: ${cloned.stderr || cloned.stdout}`); } try { renameSync(tmp, rootPath); } catch (error) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`failed to install cloned repo at ${rootPath}: ${errMessage(error)}`); } } interface SyncTarget { target?: string; branch?: string; baseRef?: string; } interface RootSyncResult extends SyncTarget { action: ProjectRootSyncAction; baseSha?: string; } async function syncRoot(manifest: ProjectAcquisitionManifest): Promise { const rootPath = resolve(manifest.rootPath); await assertExistingGitRoot(rootPath); await assertRemote(rootPath, manifest.remoteUrl.trim()); const fetch = await git(["fetch", "--prune", "origin"], rootPath); if (!fetch.ok) throw new Error(`git fetch failed for ${rootPath}: ${fetch.stderr || fetch.stdout}`); const target = await resolveSyncTarget(rootPath, manifest.ref); const baseSha = target.target ? await requireGit(["rev-parse", target.target], rootPath) : undefined; const status = await git(["status", "--porcelain"], rootPath); if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`); if (status.stdout.trim()) { warnSyncSkipped(rootPath, target.target, "skipped-dirty", "local changes"); return { ...target, action: "skipped-dirty", ...(baseSha ? { baseSha } : {}) }; } if (!target.target) return { ...target, action: "noop", ...(baseSha ? { baseSha } : {}) }; const localRef = target.branch && await localBranchExists(rootPath, target.branch) ? `refs/heads/${target.branch}` : "HEAD"; const head = await requireGit(["rev-parse", localRef], rootPath); if (!baseSha || head === baseSha) return { ...target, action: "noop", ...(baseSha ? { baseSha } : {}) }; if (!(await git(["merge-base", "--is-ancestor", localRef, target.target], rootPath)).ok) { warnSyncSkipped(rootPath, target.target, "skipped-diverged", `${localRef} is ahead of or diverged from target`); return { ...target, action: "skipped-diverged", baseSha }; } if (target.branch) { const checked = await checkoutBranchForSync(rootPath, target.branch, target.target); if (!checked.ok) throw new Error(`git checkout ${target.branch} failed for ${rootPath}: ${checked.stderr || checked.stdout}`); } const merged = await git(["merge", "--ff-only", target.target], rootPath); if (!merged.ok) throw new Error(`git ff-only sync failed for ${rootPath}: ${merged.stderr || merged.stdout}`); return { ...target, action: "synced", baseSha }; } async function assertExistingGitRoot(rootPath: string): Promise { let stat; try { stat = statSync(rootPath); } catch (error) { throw new Error(`project root does not exist after acquisition: ${rootPath}: ${errMessage(error)}`); } if (!stat.isDirectory()) throw new Error(`project root exists but is not a directory: ${rootPath}`); const top = await git(["rev-parse", "--show-toplevel"], rootPath); if (!top.ok || resolve(top.stdout) !== rootPath) throw new Error(`project root exists but is not a git checkout root: ${rootPath}`); } async function assertRemote(rootPath: string, remoteUrl: string): Promise { const current = await git(["remote", "get-url", "origin"], rootPath); if (!current.ok || !current.stdout) throw new Error(`project root ${rootPath} has no origin remote`); if (normalizeGitRemoteIdentity(current.stdout) !== normalizeGitRemoteIdentity(remoteUrl)) { throw new Error(`project root ${rootPath} origin mismatch: expected ${remoteUrl}, found ${current.stdout.trim()}`); } } /** * Returns a transport-independent identity for a git remote. * * The owner and repository remain case-sensitive, while the host is not. Paths * that are not URLs (notably local test/dev remotes) retain a normalized path * identity so they continue to work without pretending to have a host. */ export function normalizeGitRemoteIdentity(remoteUrl: string): string { const value = remoteUrl.trim(); const parsed = parseGitRemote(value); if (!parsed) return `path:${value.replace(/\/+$/, "")}`; const path = parsed.path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, ""); return `remote:${parsed.host.toLowerCase()}/${path}`; } function parseGitRemote(value: string): { host: string; path: string } | undefined { const scp = !value.includes("://") && value.match(/^(?:[^@\/\s]+@)?([^:\/\s]+):(.+)$/); if (scp) return { host: scp[1]!, path: scp[2]! }; try { const url = new URL(value); if (!url.hostname || !url.pathname) return undefined; return { host: url.hostname, path: url.pathname }; } catch { return undefined; } } async function resolveSyncTarget(rootPath: string, ref: string | undefined): Promise { if (ref?.trim()) { const name = ref.trim(); const remoteBranch = `refs/remotes/origin/${name}`; if ((await git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath)).ok) { return { target: `origin/${name}`, branch: name, baseRef: name }; } if ((await git(["rev-parse", "--verify", name], rootPath)).ok) { return {}; } throw new Error(`project acquisition ref "${name}" not found on origin for ${rootPath}`); } const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath); if (upstream.ok && upstream.stdout) return { target: upstream.stdout, baseRef: remoteBranchName(upstream.stdout) }; const originHead = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath); if (originHead.ok && originHead.stdout) return { target: originHead.stdout, baseRef: remoteBranchName(originHead.stdout) }; throw new Error(`project root ${rootPath} has no upstream or origin/HEAD for ff-only sync`); } async function localBranchExists(rootPath: string, branch: string): Promise { return (await git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], rootPath)).ok; } async function checkoutBranchForSync(rootPath: string, branch: string, target: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { if (await localBranchExists(rootPath, branch)) return await git(["checkout", branch], rootPath); return await git(["checkout", "-B", branch, target], rootPath); } function remoteBranchName(ref: string | undefined): string | undefined { const trimmed = ref?.trim(); if (!trimmed) return undefined; return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } function warnSyncSkipped(rootPath: string, target: string | undefined, action: ProjectRootSyncAction, reason: string): void { console.warn(`[orchestrator] project acquisition root sync ${action} for ${rootPath}${target ? ` -> ${target}` : ""}: ${reason}`); } async function runGit(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { return await execProcess(["git", ...args], { cwd }); } function hash(value: string): string { return createHash("sha1").update(resolve(value)).digest("hex").slice(0, 16); }