Files
orca/src/main/provisioned-root-ssh-adoption.ts
T
Neil b241a68ae4 Fix worktree identity collisions across hosts (#16691)
* fix(workspaces): add collision-safe worktree identity

* fix(workspaces): read worktree metadata per host and repair ambiguous identities

The canonical identity store landed write-only: getWorktreeMetaForHost had no
production callers while setWorktreeMetaForHost kept the legacy projection only
for the first known owner, so a second host's edits persisted and were never
read back. Wire the listing paths through host-qualified reads.

An ambiguous alias was also unrecoverable — reads returned undefined and writes
threw forever, and the throw escaped the detected-worktree loop, emptying the
whole repo's sidebar. Fail open onto the most recently active instance instead.

- collapse ambiguous aliases deterministically and persist the repair
- reclaim identity rows in the metadata GC so they cannot outlive their locator
  or resurrect onto a worktree recreated at the same path
- drop every host's rows when a locator is removed outright, not just the owner's
- honour an explicit instanceId so the stale-lineage rotation guard still works
- scope a rename to the moving host; other hosts keep their own locator
- prefer the project host setup matching the repo's own execution host, so a
  repoId registered on two hosts no longer stamps the wrong one durably
- reject an unencoded `|` in a host id, the invariant the alias delimiter needs
- drop the never-populated hostGeneration from the canonical key

* fix(workspaces): close remaining identity review gaps

* fix(workspaces): close remaining review gaps

* fix(workspaces): address review and CI regressions

* test(workspaces): update host-qualified metadata expectations

* fix(workspaces): preserve ambiguous identity records

* fix(workspaces): snapshot metadata during listing

* test(workspaces): mirror listing metadata snapshot in windows fixture

* fix(workspaces): preserve identity routing for metadata writes

* fix(workspaces): scope stale metadata cleanup by host

* fix(workspaces): rekey identities on SSH readoption

* fix(workspaces): fail closed for ambiguous board ids

* perf(workspaces): snapshot metadata across catalog listing

* fix(workspaces): retain neighboring manual order updates

* test(workspaces): cover ambiguous board id index

* fix(persistence): harden host-qualified worktree metadata

* refactor(shared): split project host setup lookup

* refactor(workspaces): simplify host-qualified metadata
2026-08-27 15:08:40 -07:00

207 lines
8.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import type {
AdoptProvisionedRootArgs,
CreateWorktreeResult
} from '../shared/worktree/create-types'
import type { WorktreeMeta } from '../shared/worktree/meta-types'
import type { AutomationWorkspaceProvenance } from '../shared/worktree/types'
import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../shared/execution-host'
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultProjectRoot
} from '../shared/ephemeral-vm-recipes'
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup'
import { isTuiAgent } from '../shared/tui-agent-config'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import {
getSshProviderAuthority,
isCurrentSshProviderAuthority
} from './ssh/ssh-provider-authority'
import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment'
import { getWorktreeCreationLayout, mergeWorktree } from './ipc/worktree-logic'
type AdoptionArgs = AdoptProvisionedRootArgs & {
automationProvenance?: AutomationWorkspaceProvenance
}
export async function adoptProvisionedRootSshCheckout(args: {
userDataPath: string
request: AdoptionArgs
repo: Repo
store: Store
isRepoCurrent: () => boolean
}): Promise<CreateWorktreeResult> {
const { request, repo, store } = args
if (request.sparseCheckout) {
throw new Error('Provisioned-root recipes do not support sparse checkout.')
}
const connectionId = repo.connectionId
if (!connectionId || !isRuntimeOwnedSshTargetId(connectionId)) {
throw new Error('Provisioned-root adoption requires a runtime-owned SSH target.')
}
if (request.executionHostId !== toSshExecutionHostId(connectionId)) {
throw new Error('Provisioned-root workspace host does not match its SSH target.')
}
const runtime = requireOwnedProvisionedRootRuntime(
args.userDataPath,
request.runtimeId,
connectionId
)
const projectRoot = getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)
if (!pathsEqual(request.expectedPath, projectRoot) || !pathsEqual(repo.path, projectRoot)) {
throw new Error('The recipe projectRoot does not match the imported Git checkout root.')
}
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error('The recipe-created SSH connection is no longer available.')
}
const authority = { ...getSshProviderAuthority(connectionId) }
const [worktrees, sparseCheckoutEnabled] = await Promise.all([
provider.listWorktrees(repo.path),
isSparseCheckoutEnabled(provider, projectRoot)
])
if (
getSshGitProvider(connectionId) !== provider ||
!isCurrentSshProviderAuthority(authority) ||
!args.isRepoCurrent()
) {
throw new Error('The recipe-created SSH connection changed during checkout verification.')
}
requireOwnedProvisionedRootRuntime(args.userDataPath, request.runtimeId, connectionId)
const matches = worktrees.filter((worktree) => pathsEqual(worktree.path, projectRoot))
if (matches.length !== 1) {
throw new Error('The recipe projectRoot is not a unique Git checkout root.')
}
const gitWorktree = matches[0]
if (!gitWorktree.isMainWorktree) {
throw new Error('The recipe projectRoot must be the repository primary checkout.')
}
if (gitWorktree.isBare) {
throw new Error('Provisioned-root recipes cannot adopt a bare repository.')
}
if (gitWorktree.isSparse || sparseCheckoutEnabled) {
throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.')
}
const requestedBranch = request.branchNameOverride ?? request.name
if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) {
throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.")
}
if (request.baseBranch && !request.expectedRefHead) {
throw new Error('The requested provisioned-root ref identity is missing.')
}
if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) {
throw new Error("The recipe projectRoot was not created from Orca's requested ref.")
}
const worktreeId = `${repo.id}::${gitWorktree.path}`
attachEphemeralVmRuntimeToWorkspace({
userDataPath: args.userDataPath,
runtimeId: request.runtimeId,
workspaceId: worktreeId
})
const now = Date.now()
const meta = store.setWorktreeMeta(
worktreeId,
buildProvisionedRootMeta(store, repo, request, now)
)
return { worktree: mergeWorktree(repo.id, gitWorktree, meta) }
}
async function isSparseCheckoutEnabled(
provider: NonNullable<ReturnType<typeof getSshGitProvider>>,
projectRoot: string
): Promise<boolean> {
const { stdout } = await provider.exec(
['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'],
projectRoot
)
return stdout.trim() === 'true'
}
function requireOwnedProvisionedRootRuntime(
userDataPath: string,
runtimeId: string,
connectionId: string
): EphemeralVmRuntimeRecord {
const runtime = listEphemeralVmRuntimes(userDataPath).find((entry) => entry.id === runtimeId)
if (!runtime) {
throw new Error(`Unknown ephemeral VM runtime: ${runtimeId}`)
}
if (
runtime.connectionMode !== 'ssh' ||
runtime.sshTargetId !== connectionId ||
runtime.recipe?.checkoutMode !== 'provisioned-root' ||
getEphemeralVmRecipeResultCheckoutMode(runtime.recipeResult) !== 'provisioned-root' ||
['cleanup_pending', 'cleanup_failed', 'cleaned'].includes(runtime.status)
) {
throw new Error('The ephemeral VM runtime does not own this provisioned SSH checkout.')
}
return runtime
}
function pathsEqual(left: string, right: string): boolean {
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
}
function buildProvisionedRootMeta(
store: Store,
repo: Repo,
args: AdoptionArgs,
now: number
): Partial<WorktreeMeta> {
return {
instanceId: randomUUID(),
...(store.getProjectHostSetups
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
: {}),
hostId: args.executionHostId,
ephemeralVmCheckoutMode: 'provisioned-root',
displayName: args.displayName || args.name,
lastActivityAt: now,
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'ssh',
creatorProvenance: { kind: 'host' },
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, store.getSettings()),
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.compareBaseRef || args.baseBranch
? { baseRef: args.compareBaseRef ?? args.baseBranch }
: {}),
...(args.pushTarget ? { pushTarget: args.pushTarget } : {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
? { pendingFirstAgentMessageRename: true }
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}),
...(args.linkedAzureDevOpsPR !== undefined
? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR }
: {}),
...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}),
...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}),
...(args.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: args.linkedTaskSourceContext }
: {})
}
}