Files
orca/src/main/runtime/runtime-repository-clone-controller.ts
T
Neil 316ec38f67 fix(repos): route icon and remote-identity probes on a resolved execution host (#18377)
`detectRepoIcon`, `detectRepoIconAndUpstream`, `detectGitHubAvatarIcon`,
`detectRepoFileIcon` and `probeGitRemoteIdentity` took a `connectionId`-shaped
parameter threaded down from their callers. That shape spells "runtime host",
"unresolved" and "genuinely local" all as one falsy value, and because it is a
*parameter* each caller decided independently what to pass — a wrong answer was
invisible at the boundary.

Replace it with a required `ExecutionHostId` and route through #18296's
`resolveGitRouteForHost` / `resolveFilesystemRouteForHost`. The parameter is
removed rather than added beside, so every caller became a compile error. No new
resolver, no wire change: nothing these modules return carries a host id.

Fixed at the call sites:

- `repo-git-remote-identity-enrichment` read `repo.connectionId` raw, so a row
  minted with only `executionHostId: ssh:<t>` ran `git remote -v` against this
  machine's copy of the path (#11163), and a `runtime:` row handed its *nested*
  SSH target to this client's dispatch table — a same-named box of ours.
- Its location key had the same collapse, so two rows at one path on different
  hosts shared a probe, an abort controller and a backoff deadline.
- `runtime-repository-fork-backfill` guarded on `repo.connectionId`, so an
  `executionHostId`-only SSH row had its upstream read off the client.

`runtime:` is refused inside the modules (this process does not execute another
environment's git or filesystem), but store-backed callers ask
`getSshTargetIdForExecutionHost` — "what may this client dial" — so a `runtime:`
row keeps the probe this process has always run for it. Registering and cloning
stay `local` on purpose: those controllers do the filesystem work here, whatever
host id is stamped on the row (see `assertCloneHostIsSupported`).
2026-09-03 00:49:36 -07:00

195 lines
6.7 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import {
cleanupClaimedCloneTarget,
claimCloneTarget,
deriveValidatedClonePath,
getClonePathComparisonKey
} from '../git/repo-clone-path'
import { gitSpawnAfterWindowsEnvironmentReady, nonInteractiveGitEnv } from '../git/runner'
import { runWithGitReadCacheInvalidation } from '../git/status'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { isFolderRepo } from '../../shared/repo-kind'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getRepoName } from '../git/repo'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import type { RuntimeStore } from './runtime-store-contract'
import { runtimeRepoMatchesExecutionHost } from './runtime-worktree-selection'
type RuntimeRepositoryCloneDependencies = {
getStore: () => RuntimeStore | null
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
}
export class RuntimeRepositoryCloneController {
private readonly inFlightByPath = new Map<string, Promise<void>>()
constructor(private readonly deps: RuntimeRepositoryCloneDependencies) {}
async clone(
url: string,
destination: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
if (!this.deps.getStore()) {
throw new Error('runtime_unavailable')
}
const trimmedUrl = url.trim()
const trimmedDestination = destination.trim()
if (!trimmedDestination) {
throw new Error('Clone destination is required')
}
const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination })
const clonePathKey = getClonePathComparisonKey(clonePath)
const previous = this.inFlightByPath.get(clonePathKey) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.then(
() => current,
() => current
)
this.inFlightByPath.set(clonePathKey, tail)
try {
await previous
return await runWithGitReadCacheInvalidation(() =>
this.cloneAfterPathLock(
trimmedUrl,
trimmedDestination,
clonePath,
clonePathKey,
executionHostId
)
)
} finally {
release()
if (this.inFlightByPath.get(clonePathKey) === tail) {
this.inFlightByPath.delete(clonePathKey)
}
}
}
private async cloneAfterPathLock(
trimmedUrl: string,
trimmedDestination: string,
clonePath: string,
clonePathKey: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
const store = this.deps.getStore()
if (!store) {
throw new Error('runtime_unavailable')
}
const existingBeforeClone = store.getRepos().find((repo) => {
return (
getClonePathComparisonKey(repo.path) === clonePathKey &&
runtimeRepoMatchesExecutionHost(repo, executionHostId)
)
})
if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) {
return existingBeforeClone
}
await mkdir(trimmedDestination, { recursive: true })
const claimedTarget = await claimCloneTarget(clonePath)
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
try {
proc = await gitSpawnAfterWindowsEnvironmentReady(
['clone', '--progress', '--', trimmedUrl, clonePath],
{
cwd: trimmedDestination,
admissionTier: 'interactive',
env: nonInteractiveGitEnv(),
stdio: ['ignore', 'ignore', 'pipe']
}
)
} catch (error) {
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Clone failed: ${message}`)
}
await new Promise<void>((resolve, reject) => {
let stderrTail = ''
let settled = false
proc.stderr?.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4096)
})
const finish = async (code: number | null, signal: NodeJS.Signals | null, error?: Error) => {
if (settled) {
return
}
settled = true
if (error || code !== 0 || signal) {
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
}
if (error) {
reject(new Error(`Clone failed: ${error.message}`))
} else if (signal === 'SIGTERM') {
reject(new Error('Clone aborted'))
} else if (code === 0) {
resolve()
} else {
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`))
}
}
proc.on('error', (error) => void finish(null, null, error))
proc.on('close', (code, signal) => void finish(code, signal))
})
const existing = store.getRepos().find((repo) => {
return (
getClonePathComparisonKey(repo.path) === clonePathKey &&
runtimeRepoMatchesExecutionHost(repo, executionHostId)
)
})
if (existing) {
if (isFolderRepo(existing)) {
const updated = store.updateRepo(existing.id, { kind: 'git' })
if (updated) {
await prepareLocalWorktreeRootForRepo(store, updated)
invalidateAuthorizedRootsCache()
this.invalidate(updated.id)
return updated
}
}
return existing
}
// `cloneRepo` ran `git clone` in this process (see `assertCloneHostIsSupported`), so the
// checkout is here regardless of the host id stamped on the row.
const detected = await detectRepoIconAndUpstream({
repoPath: clonePath,
kind: 'git',
executionHostId: LOCAL_EXECUTION_HOST_ID
})
const repo: Repo = {
id: randomUUID(),
path: clonePath,
displayName: getRepoName(clonePath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...(executionHostId != null ? { executionHostId } : {}),
...detected,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibilityLegacy: false
}
store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
invalidateAuthorizedRootsCache()
this.invalidate(repo.id)
return store.getRepo(repo.id) ?? repo
}
private invalidate(repoId: string): void {
this.deps.invalidateResolvedWorktrees()
this.deps.invalidateWorktreeScan(repoId)
this.deps.notifyReposChanged()
}
}