diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a4c931f945a..1545846ac38 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4533,6 +4533,12 @@ retryable restoration failure. It never substitutes the default branch. Reopening failures reject the consuming request with HTTP 409 before saving a comment or changing its task. The response preserves the restoration message, so the composer keeps an editable draft and permits retry after access is restored. +It also carries `diagnostic: { step, kind }`, and the server log records the same +pair. Both are closed values: `step` names the failed stage (for example +`source_materialize`, `restore_point` or `saved_commit`), and `kind` names the +failure class (`remote_unavailable`, `auth_rejected`, `commit_unavailable`, +`timeout`, `verification`, `filesystem`, `git_command` or `unknown`). They never +contain paths, repository URLs or Git output. Both enable switches default off. Rollback disables further expiration while leaving saved recipes and restoration support intact. Recovery archives, runner diff --git a/server/src/__tests__/git-workspace-expiration.test.ts b/server/src/__tests__/git-workspace-expiration.test.ts index ee42fda684d..5f3ea1f2c89 100644 --- a/server/src/__tests__/git-workspace-expiration.test.ts +++ b/server/src/__tests__/git-workspace-expiration.test.ts @@ -986,13 +986,32 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { const f = await fixture(); expect((await f.expire()).baseExpired).toBe(true); await fs.rename(f.remote, `${f.remote}.offline`); - expect(await f.reopen()).toMatchObject({ ok: false, code: "rebuild_failed", message: "The saved workspace commit could not be restored. Check repository access and retry." }); + const failed = await f.reopen(); + expect(failed).toMatchObject({ ok: false, code: "rebuild_failed", message: "The saved workspace commit could not be restored. Check repository access and retry.", + diagnostic: { step: "source_materialize", kind: "remote_unavailable" } }); + expect(JSON.stringify(failed)).not.toContain(root); expect(await f.reopen(randomUUID())).toMatchObject({ ok: false, code: "not_reopenable" }); expect(await exists(f.cwd)).toBe(false); await fs.rename(`${f.remote}.offline`, f.remote); expect(await f.reopen()).toMatchObject({ ok: true }); expect(git(f.cwd, "rev-parse", "HEAD")).toBe(f.commitA); }); + it("reports the failed step and kind when the saved commit is gone from upstream", async () => { + const f = await fixture(); + expect((await f.expire()).baseExpired).toBe(true); + git(f.source, "checkout", "--orphan", "rewritten"); + await fs.writeFile(path.join(f.source, "marker.txt"), "rewritten\n"); + git(f.source, "add", "."); git(f.source, "commit", "-m", "Rewritten"); + git(f.source, "push", "--force", "origin", "rewritten:main"); + git(f.remote, "reflog", "expire", "--expire=now", "--all"); git(f.remote, "gc", "--prune=now"); + const failed = await f.reopen(); + expect(failed).toMatchObject({ ok: false, code: "rebuild_failed", message: "The saved workspace commit could not be restored. Check repository access and retry.", + diagnostic: { step: "saved_commit", kind: "commit_unavailable" } }); + expect(JSON.stringify(failed)).not.toContain(root); + expect(JSON.stringify(failed)).not.toContain(f.commitA); + expect((await f.row()).metadata?.gitCacheRestoration).toMatchObject({ commit: f.commitA }); + }); + it("serializes concurrent reopening after deletion and publishes one transition", async () => { const f = await fixture(); let release!: () => void, entered!: () => void; diff --git a/server/src/__tests__/git-workspace-restoration-diagnostic.test.ts b/server/src/__tests__/git-workspace-restoration-diagnostic.test.ts new file mode 100644 index 00000000000..04251308f35 --- /dev/null +++ b/server/src/__tests__/git-workspace-restoration-diagnostic.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { classifyWorkspaceRestorationError, GitCacheRestorationFailure } from "../services/git-workspace-restoration.js"; +import { workspaceReopenDiagnostic } from "../services/execution-workspaces.js"; +import { GitConnectionFailureError } from "../services/git-connection-failure.js"; + +const gitError = (stderr: string, extra: Record = {}) => + Object.assign(new Error(`Command failed: git fetch\n${stderr}`), { code: 128, stderr, ...extra }); + +describe("workspace restoration diagnostic", () => { + it.each([ + ["fatal: Authentication failed for 'https://user:sk-secret@example.test/repo.git/'", "auth_rejected"], + ["fatal: could not read Username for 'https://github.com': terminal prompts disabled", "auth_rejected"], + ["fatal: remote error: upload-pack: not our ref 9a204a56", "commit_unavailable"], + ["fatal: couldn't find remote ref refs/heads/gone", "commit_unavailable"], + ["fatal: unable to access 'https://example.test/repo.git/': Could not resolve host: example.test", "remote_unavailable"], + ["fatal: '/srv/repo.git' does not appear to be a git repository", "remote_unavailable"], + ["fatal: bad revision", "git_command"], + ])("classifies %s", (stderr, kind) => { + expect(classifyWorkspaceRestorationError(gitError(stderr))).toBe(kind); + }); + + it("classifies timeouts, typed connection failures, filesystem errors and checks", () => { + expect(classifyWorkspaceRestorationError(gitError("", { killed: true, signal: "SIGTERM" }))).toBe("timeout"); + expect(classifyWorkspaceRestorationError(new GitConnectionFailureError("clone failed", + { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" }))).toBe("auth_rejected"); + expect(classifyWorkspaceRestorationError(new GitConnectionFailureError("clone failed", + { schemaVersion: 1, provider: "git", operation: "clone", reason: "dns_failure" }))).toBe("remote_unavailable"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("EACCES: permission denied, open '/x'"), { code: "EACCES" }))) + .toBe("filesystem"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("ENOSPC: no space left"), { code: "ENOSPC" }))).toBe("filesystem"); + expect(classifyWorkspaceRestorationError(new GitCacheRestorationFailure())).toBe("verification"); + expect(classifyWorkspaceRestorationError("not an error")).toBe("unknown"); + }); + + it("keeps the innermost restoration step and never carries Git output", () => { + const inner = new GitCacheRestorationFailure({ step: "saved_commit", kind: "commit_unavailable" }); + expect(workspaceReopenDiagnostic("source_verify", inner)).toEqual({ step: "saved_commit", kind: "commit_unavailable" }); + const diagnostic = workspaceReopenDiagnostic("managed_base", gitError("fatal: Authentication failed for 'https://user:sk-secret@example.test/r.git/'")); + expect(diagnostic).toEqual({ step: "managed_base", kind: "auth_rejected" }); + expect(JSON.stringify(diagnostic)).not.toContain("sk-secret"); + expect(workspaceReopenDiagnostic("publish", undefined)).toEqual({ step: "publish", kind: "unknown" }); + expect(inner.message).toBe("The saved workspace commit could not be restored. Check repository access and retry."); + }); + + it("bounds command text and distinguishes network failures from filesystem failures", () => { + const text = "x".repeat(16_384) + "Authentication failed for a private repository"; + expect(classifyWorkspaceRestorationError(gitError(text))).toBe("git_command"); + expect(classifyWorkspaceRestorationError(gitError("", { stderr: Buffer.from(text) }))).toBe("git_command"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("network failure"), { code: "ECONNRESET" }))).toBe("remote_unavailable"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("resolver failure"), { code: "EAI_AGAIN" }))).toBe("remote_unavailable"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("timeout"), { name: "TimeoutError" }))).toBe("timeout"); + expect(classifyWorkspaceRestorationError(gitError("", { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true }))).toBe("git_command"); + expect(classifyWorkspaceRestorationError(Object.assign(new Error("unknown"), { code: "EUNRECOGNIZED" }))).toBe("unknown"); + }); +}); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index e54a939d44d..9d109d1a63f 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -343,6 +343,7 @@ describe("closed isolated workspace issue routes", () => { ok: false, code: "rebuild_failed", message, + diagnostic: { step: "saved_commit", kind: "commit_unavailable" }, }); const app = request(createApp()); @@ -351,7 +352,7 @@ describe("closed isolated workspace issue routes", () => { : await app.patch(`/api/issues/${issueId}`).send({ comment: "continue" }); expect(res.status).toBe(409); - expect(res.body.error).toBe(message); + expect(res.body).toEqual({ error: message, diagnostic: { step: "saved_commit", kind: "commit_unavailable" } }); expect(mockIssueService.addComment).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0a1ce1705b9..4bf0e443318 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8014,7 +8014,7 @@ export function issueRoutes( "This issue is linked to a closed workspace that cannot be reopened.", }); } else { - res.status(409).json({ error: result.message }); + res.status(409).json({ error: result.message, ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) }); } return null; } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ab9462e811e..4ee7b3daa5f 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -20,7 +20,8 @@ import { hasPendingNestedCloneMigration, isNestedCloneMigrationCandidate, nextNe NESTED_CLONE_MIGRATION_RETRY_REASONS, type NestedCloneMigrationRotation } from "./nested-clone-migration.js"; import type { StorageService } from "../storage/types.js"; import { getStorageService } from "../storage/index.js"; -import { readGitCacheRestoration, restoreGitCacheBase, recoverGitCacheExpirationLocks, GitCacheRestorationFailure, gitRestorePointExpired } from "./git-workspace-restoration.js"; +import { readGitCacheRestoration, restoreGitCacheBase, recoverGitCacheExpirationLocks, GitCacheRestorationFailure, gitRestorePointExpired, + classifyWorkspaceRestorationError, type GitCacheRestorationStep, type WorkspaceRestorationFailureKind } from "./git-workspace-restoration.js"; export const GIT_RESTORE_POINT_EXPIRED_WARNING = "The saved state of this workspace expired, so it was reopened as a fresh workspace on its branch. Unpushed and uncommitted work from before it was cleaned up is no longer available."; import { parseObject } from "../adapters/utils.js"; @@ -327,7 +328,16 @@ export type ReopenClosedIsolatedExecutionWorkspaceResult = // generation, so a stale actor never clears a newer reopen's fence. | { ok: true; workspace: ExecutionWorkspace; reopened: true; generation: number; warning?: string } | { ok: true; workspace: ExecutionWorkspace; reopened: false; generation: number } - | { ok: false; code: "not_reopenable" | "rebuild_failed"; message: string }; + | { ok: false; code: "not_reopenable" | "rebuild_failed"; message: string; diagnostic?: WorkspaceReopenDiagnostic }; + +export type WorkspaceReopenFailureStep = GitCacheRestorationStep | "managed_base" | "copy_identity" | "expiration_locks" + | "workspace_rebuild" | "publish" | "transaction"; +export type WorkspaceReopenDiagnostic = { step: WorkspaceReopenFailureStep; kind: WorkspaceRestorationFailureKind }; + +export function workspaceReopenDiagnostic(step: WorkspaceReopenFailureStep, error: unknown): WorkspaceReopenDiagnostic { + if (error instanceof GitCacheRestorationFailure && error.step) return { step: error.step, kind: classifyWorkspaceRestorationError(error) }; + return { step, kind: error === undefined ? "unknown" : classifyWorkspaceRestorationError(error) }; +} export type ExecutionWorkspaceServiceOptions = { pluginWorkerManager?: WorkspaceWorkers; @@ -4345,9 +4355,16 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }); } await assertTaskWorkspaceExecutionAccess(db, input.authorizationActor, issue.companyId, { issueId: issue.id, workspaceId: snapshot.id }); - let recipe: ReturnType; + let recipe: ReturnType = null; + const rebuildFailed = (step: WorkspaceReopenFailureStep, error: unknown, restoration = recipe !== null): ReopenClosedIsolatedExecutionWorkspaceResult => { + const diagnostic = workspaceReopenDiagnostic(step, error); + logger.warn({ event: "execution_workspace.reopen", outcome: "rebuild_failed", executionWorkspaceId: snapshot.id, + issueId: issue.id, companyId: issue.companyId, ...diagnostic }, "execution workspace reopen rebuild failed"); + return { ok: false, code: "rebuild_failed", diagnostic, + message: restoration ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + }; try { recipe = readGitCacheRestoration(snapshot); } - catch { return { ok: false, code: "rebuild_failed", message: new GitCacheRestorationFailure().message }; } + catch (error) { return rebuildFailed("recipe", error, true); } const freshStart = recipe !== null && isClosedExecutionWorkspaceStatus(snapshot.status) && gitRestorePointExpired(recipe); const gitAuth = createIssueGitRemoteAuthProvider(db, snapshot.companyId, issue.id); const [ @@ -4383,9 +4400,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic resolvedBaseCwd = recipe && isClosedExecutionWorkspaceStatus(snapshot.status) ? await restoreGitCacheBase(snapshot, recipe, gitAuth, undefined, { freshStart }) : await verifiedRecordedIsolatedRemoteSource(snapshot) ?? projectWorkspace?.cwd ?? null; - } catch { - return { ok: false, code: "rebuild_failed", message: recipe - ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + } catch (error) { + return rebuildFailed("source_verify", error); } if (isClosedExecutionWorkspaceStatus(snapshot.status) && resolvedBaseCwd == null && snapshot.strategyType === "git_worktree" && snapshot.projectId) { try { @@ -4409,8 +4425,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic resolveGitAuth: createIssueGitRemoteAuthProvider(db, snapshot.companyId, issue.id), }); resolvedBaseCwd = managedWorkspace.cwd; - } catch { - return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + } catch (error) { + return rebuildFailed("managed_base", error); } } let copyIdentity: { sourceCwd: string; configuredSourceCwd: string; targetCwd: string } | null = null; @@ -4427,8 +4443,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic sourceCwd: await fs.realpath(sourceRoot), configuredSourceCwd: await fs.realpath(resolvedBaseCwd), targetCwd, }; - } catch { - return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + } catch (error) { + return rebuildFailed("copy_identity", error); } } const result = await db.transaction(async (tx): Promise => { @@ -4478,7 +4494,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } } try { await recoverGitCacheExpirationLocks(row); } - catch { return { ok: false, code: "rebuild_failed", message: new GitCacheRestorationFailure().message }; } + catch (error) { return rebuildFailed("expiration_locks", error, true); } const verifyCopyIdentity = copyIdentity ? async (sourceCwd: string, targetCwd: string) => { if (await fs.realpath(sourceCwd) !== copyIdentity.sourceCwd || await fs.realpath(resolvedBaseCwd!) !== copyIdentity.configuredSourceCwd @@ -4525,6 +4541,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }); let rebuildError: string | null = null; + let rebuildDiagnostic: WorkspaceReopenDiagnostic | null = null; let taskDirectoryWarning: string | null = null; const taskDirectoryState = readTaskDirectoryRetention(row); try { @@ -4644,9 +4661,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }); if (!realized) { rebuildError = "Execution workspace could not be rebuilt"; + rebuildDiagnostic = workspaceReopenDiagnostic("workspace_rebuild", undefined); } } catch (error) { rebuildError = error instanceof Error ? error.message : String(error); + rebuildDiagnostic = workspaceReopenDiagnostic("workspace_rebuild", error); } if (rebuildError) { @@ -4677,6 +4696,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic actorType: actor.actorType, actorAgentId: actor.agentId ?? null, generation: nextGeneration, + ...rebuildDiagnostic, error: rebuildError, }, "execution workspace reopen rebuild failed", @@ -4693,9 +4713,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic issueId: issue.id, outcome: "rebuild_failed", generation: nextGeneration, + ...rebuildDiagnostic, }, }); - return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + return { ok: false, code: "rebuild_failed", ...(rebuildDiagnostic ? { diagnostic: rebuildDiagnostic } : {}), + message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; } // The rebuild succeeded. Publish the row as active in one write, and clear @@ -4730,7 +4752,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .returning() .then((rows) => rows[0] ?? null); if (!activeRow) { - return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + return rebuildFailed("publish", undefined); } logger.info( { @@ -4770,7 +4792,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ...(reopenWarning ? { warning: reopenWarning } : {}) }; }).catch((error): ReopenClosedIsolatedExecutionWorkspaceResult => { logger.warn({ error, executionWorkspaceId: snapshot.id }, "Execution workspace reopen failed"); - return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" }; + return rebuildFailed("transaction", error); }); return result; }; diff --git a/server/src/services/git-workspace-restoration.ts b/server/src/services/git-workspace-restoration.ts index b84325b0d83..3726b996173 100644 --- a/server/src/services/git-workspace-restoration.ts +++ b/server/src/services/git-workspace-restoration.ts @@ -8,7 +8,8 @@ import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { createHash } from "node:crypto"; import { resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot } from "../home-paths.js"; -import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js"; +import { materializeManagedProjectWorkspace, RepositoryRefNotFoundError } from "./managed-repository-checkout.js"; +import { readGitConnectionFailure } from "./git-connection-failure.js"; import { promisify } from "node:util"; import type { GitRemoteAuthProvider } from "./git-credentials.js"; import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js"; @@ -85,8 +86,54 @@ export async function verifiedManagedGitObjectSource(workspace: Workspace, sourc return sourcePath; } +export type GitCacheRestorationStep = "recipe" | "trash_recovery" | "source_check" | "source_materialize" | "source_verify" + | "restore_point" | "saved_commit"; +export type WorkspaceRestorationFailureKind = "remote_unavailable" | "auth_rejected" | "commit_unavailable" | "timeout" + | "verification" | "filesystem" | "git_command" | "unknown"; + export class GitCacheRestorationFailure extends Error { - constructor() { super("The saved workspace commit could not be restored. Check repository access and retry."); } + readonly step: GitCacheRestorationStep | null; + readonly kind: WorkspaceRestorationFailureKind | null; + constructor(diagnostic: { step: GitCacheRestorationStep; kind: WorkspaceRestorationFailureKind } | null = null) { + super("The saved workspace commit could not be restored. Check repository access and retry."); + this.step = diagnostic?.step ?? null; + this.kind = diagnostic?.kind ?? null; + } +} + +export function classifyWorkspaceRestorationError(error: unknown): WorkspaceRestorationFailureKind { + if (error instanceof GitCacheRestorationFailure) return error.kind ?? "verification"; + if (!(error instanceof Error)) return "unknown"; + const connection = readGitConnectionFailure(error); + if (connection) { + if (connection.reason === "authentication_failed") return "auth_rejected"; + return connection.reason === "connection_timeout" ? "timeout" : "remote_unavailable"; + } + if (error instanceof RepositoryRefNotFoundError) return "commit_unavailable"; + const details = error as Error & { code?: unknown; killed?: unknown; stderr?: unknown }; + const code = typeof details.code === "string" ? details.code : null; + if (details.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "git_command"; + if (details.killed === true || details.code === "ETIMEDOUT" || error.name === "AbortError" || error.name === "TimeoutError") return "timeout"; + if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH"].includes(code ?? "")) return "remote_unavailable"; + if (["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM", "ENOSPC", "EROFS", "EMFILE", "ENFILE", "ELOOP", "ENAMETOOLONG", "EBUSY", "EIO", "EDQUOT"].includes(code ?? "")) return "filesystem"; + const stderr = typeof details.stderr === "string" ? details.stderr.slice(0, 16_384) + : Buffer.isBuffer(details.stderr) ? details.stderr.subarray(0, 16_384).toString("utf8") : error.message.slice(0, 16_384); + if (/authentication failed|could not read (username|password)|terminal prompts disabled|permission denied|returned error: 40[13]/i.test(stderr)) { + return "auth_rejected"; + } + if (/not our ref|couldn't find remote ref|no such remote ref|unadvertised object|did not send all necessary objects/i.test(stderr)) { + return "commit_unavailable"; + } + if (/could not resolve host|unable to access|could not read from remote repository|does not appear to be a git repository|repository .*not found|connection (refused|timed out|reset)/i.test(stderr)) { + return "remote_unavailable"; + } + if (typeof details.stderr === "string" || typeof details.code === "number") return "git_command"; + return "unknown"; +} + +function restorationFailureAt(step: GitCacheRestorationStep, error: unknown) { + if (error instanceof GitCacheRestorationFailure && error.step) return error; + return new GitCacheRestorationFailure({ step, kind: classifyWorkspaceRestorationError(error) }); } export function readGitCacheRestoration(workspace: Workspace): GitCacheRestoration | null { @@ -719,9 +766,12 @@ export function gitRestorePointExpired(recipe: GitCacheRestoration) { export async function restoreGitCacheBase(workspace: Workspace, recipe: GitCacheRestoration, resolveGitAuth: GitRemoteAuthProvider, storage?: StorageService, options: { freshStart?: boolean } = {}) { - if (readGitCacheRestoration(workspace)?.commit !== recipe.commit) throw new GitCacheRestorationFailure(); + let step: GitCacheRestorationStep = "recipe"; try { + if (readGitCacheRestoration(workspace)?.commit !== recipe.commit) throw new GitCacheRestorationFailure(); + step = "trash_recovery"; await finishGitCacheTrash(workspace); + step = "source_check"; const present = await fs.lstat(recipe.sourcePath).then(() => true, (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return false; throw error; @@ -736,21 +786,25 @@ export async function restoreGitCacheBase(workspace: Workspace, recipe: GitCache if (!entry.isDirectory() || entry.isSymbolicLink() || await fs.realpath(parent) !== parent) throw new GitCacheRestorationFailure(); } } + step = present ? "source_verify" : "source_materialize"; const cwd = present ? await verifiedGitCacheSource(workspace, recipe.sourcePath) : recipe.sourceKind === "isolated_remote" ? await materializeIsolatedRemoteWorkspaceSource({ companyId: recipe.companyId, issueId: recipe.sourceIssueId, repoUrl: recipe.repoUrl, resolveGitAuth }) : (await materializeManagedProjectWorkspace(recipe.sourcePath, { repoUrl: recipe.repoUrl, resolveGitAuth })).cwd; + step = "source_verify"; await verifiedGitCacheSource(workspace, recipe.sourcePath); if (!cwd) throw new GitCacheRestorationFailure(); if (options.freshStart) return cwd; + step = "restore_point"; if (recipe.restorePoint) { await ensureGitRestorePointCommit({ storage: storage ?? getStorageService(), workspace: { companyId: workspace.companyId, id: workspace.id }, ref: recipe.restorePoint, repositoryCwd: cwd }); } + step = "saved_commit"; await ensureSavedGitCommit(cwd, recipe.repoUrl, recipe.commit, resolveGitAuth); return cwd; - } catch { throw new GitCacheRestorationFailure(); } + } catch (error) { throw restorationFailureAt(step, error); } } export async function ensureSavedGitCommit(cwd: string, repoUrl: string, commit: string, resolveGitAuth: (url: string) => Promise<{ configArgs: string[]; env: Record } | null>) { @@ -762,7 +816,7 @@ export async function ensureSavedGitCommit(cwd: string, repoUrl: string, commit: timeout: 60_000, maxBuffer: 1024 * 1024, env: { ...process.env, ...auth?.env, GIT_TERMINAL_PROMPT: "0" }, }); if ((await gitCacheCommand(cwd, ["rev-parse", `${commit}^{commit}`])).trim() !== commit) throw new GitCacheRestorationFailure(); - } catch { throw new GitCacheRestorationFailure(); } + } catch (error) { throw restorationFailureAt("saved_commit", error); } } /** Runtime callers must bind the recipe to the selected workspace and base. */