diff --git a/src/main/ephemeral-vm-runtime-environment-release.test.ts b/src/main/ephemeral-vm-runtime-environment-release.test.ts new file mode 100644 index 00000000000..e563cfc9b1a --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + remove: vi.fn(), + removeManaged: vi.fn(), + closeTunnel: vi.fn(async () => {}) +})) + +vi.mock('../shared/runtime-environment-store', () => ({ + listEnvironments: mocks.list, + removeEnvironment: mocks.remove +})) +vi.mock('../shared/runtime-environment-managed-orcad-store', () => ({ + removeManagedOrcadEnvironment: mocks.removeManaged +})) +vi.mock('./ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) + +import { releaseEphemeralVmRuntimeEnvironments } from './ephemeral-vm-runtime-environment-release' + +describe('releaseEphemeralVmRuntimeEnvironments', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("closes a destroyed VM's managed server tunnel and forgets the server", async () => { + mocks.list.mockReturnValue([ + { id: 'env-vm', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } }, + { id: 'env-other', orcadDeployment: { sshTargetId: 'ssh-builder' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-vm', + sshTargetId: 'runtime-ssh-vm' + }) + expect(mocks.closeTunnel).toHaveBeenCalledTimes(1) + expect(mocks.closeTunnel).toHaveBeenCalledWith('env-vm') + expect(mocks.removeManaged).toHaveBeenCalledTimes(1) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-vm') + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('finds the server of a relay-era VM that converted without recording it', async () => { + mocks.list.mockReturnValue([ + { id: 'env-converted', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { sshTargetId: 'runtime-ssh-vm' }) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-converted') + }) + + it('removes a paired recipe server the ordinary way', async () => { + mocks.list.mockReturnValue([{ id: 'env-paired' }]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-paired' + }) + expect(mocks.remove).toHaveBeenCalledWith('/user-data', 'env-paired') + expect(mocks.closeTunnel).not.toHaveBeenCalled() + }) + + it('ignores a server that is already gone', async () => { + mocks.list.mockReturnValue([]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { runtimeEnvironmentId: 'env-gone' }) + expect(mocks.remove).not.toHaveBeenCalled() + expect(mocks.removeManaged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ephemeral-vm-runtime-environment-release.ts b/src/main/ephemeral-vm-runtime-environment-release.ts new file mode 100644 index 00000000000..affe5437fba --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.ts @@ -0,0 +1,26 @@ +import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' +import { removeManagedOrcadEnvironment } from '../shared/runtime-environment-managed-orcad-store' +import { listEnvironments, removeEnvironment } from '../shared/runtime-environment-store' + +/** Forgets the servers a destroyed VM ran; a managed one's tunnel would otherwise keep redialing. */ +export async function releaseEphemeralVmRuntimeEnvironments( + userDataPath: string, + runtime: Pick +): Promise { + // Why the target too: a relay-era VM converted on connect never recorded its server's id. + const environments = listEnvironments(userDataPath).filter( + (entry) => + entry.id === runtime.runtimeEnvironmentId || + (runtime.sshTargetId !== undefined && + entry.orcadDeployment?.sshTargetId === runtime.sshTargetId) + ) + for (const environment of environments) { + if (!environment.orcadDeployment) { + removeEnvironment(userDataPath, environment.id) + continue + } + const { closeOrcadManagedTunnel } = await import('./ssh/orcad-managed-tunnel') + await closeOrcadManagedTunnel(environment.id).catch(() => undefined) + removeManagedOrcadEnvironment(userDataPath, environment.id) + } +} diff --git a/src/main/ephemeral-vm-runtime-ssh.test.ts b/src/main/ephemeral-vm-runtime-ssh.test.ts new file mode 100644 index 00000000000..236a4ba3796 --- /dev/null +++ b/src/main/ephemeral-vm-runtime-ssh.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + remove: vi.fn(async () => {}), + gitProvider: vi.fn(), + fsProvider: vi.fn() +})) + +vi.mock('./ipc/ssh', () => ({ + connectRegisteredSshTarget: mocks.connect, + getSshConnectionStore: () => ({ + upsertRuntimeOwnedTarget: (runtimeId: string) => ({ + id: `runtime-ssh-${runtimeId}`, + label: 'VM', + host: 'vm', + port: 22, + username: 'root' + }) + }) +})) +vi.mock('./ipc/ssh-session-teardown', () => ({ + disconnectRegisteredSshTarget: vi.fn(), + removeRegisteredSshTarget: mocks.remove +})) +vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.gitProvider })) +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.fsProvider +})) + +import { connectRuntimeOwnedSshTarget } from './ephemeral-vm-runtime-ssh' + +const connection = { + type: 'ssh' as const, + projectRoot: '/workspace/repo', + target: { label: 'VM', host: 'vm', port: 22, username: 'root' } +} + +describe('connectRuntimeOwnedSshTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns the managed server a recipe VM's host runs, without waiting on relay providers", async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'managed', environmentId: 'env-vm' } + }) + await expect( + connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + ).resolves.toMatchObject({ targetId: 'runtime-ssh-vm-1', environmentId: 'env-vm' }) + expect(mocks.gitProvider).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('waits for the direct providers when the host has no managed server', async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mocks.gitProvider.mockReturnValue({}) + mocks.fsProvider.mockReturnValue({}) + const result = await connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + expect(result.environmentId).toBeUndefined() + expect(mocks.gitProvider).toHaveBeenCalledWith('runtime-ssh-vm-1') + }) +}) diff --git a/src/main/ephemeral-vm-runtime-ssh.ts b/src/main/ephemeral-vm-runtime-ssh.ts index 770a43cbf8f..a0868544b48 100644 --- a/src/main/ephemeral-vm-runtime-ssh.ts +++ b/src/main/ephemeral-vm-runtime-ssh.ts @@ -14,6 +14,8 @@ const SSH_PROVIDER_READY_INTERVAL_MS = 100 export type RuntimeOwnedSshConnectionResult = { targetId: string target: SshTarget + /** The managed Orca server that serves this host; absent when it is reached directly. */ + environmentId?: string } export async function connectRuntimeOwnedSshTarget(args: { @@ -26,19 +28,25 @@ export async function connectRuntimeOwnedSshTarget(args: { throw new Error('SSH handlers are not registered.') } const target = store.upsertRuntimeOwnedTarget(args.runtimeId, args.connection.target) + let environmentId: string | undefined try { const state = await connectRegisteredSshTarget(target.id) if (state.status !== 'connected') { throw new Error(state.error || `SSH target did not connect: ${state.status}`) } - await waitForRuntimeSshProviders(target.id, args.signal) + if (state.managedServer?.kind === 'managed') { + // Its server answers for the host; no direct providers are registered for it. + environmentId = state.managedServer.environmentId + } else { + await waitForRuntimeSshProviders(target.id, args.signal) + } } catch (error) { // The target is persisted at upsert, so a failed connect/provider-wait would // orphan it; remove it (idempotent) before rethrowing so cleanup is complete. await removeRuntimeOwnedSshTarget(target.id).catch(() => undefined) throw error } - return { targetId: target.id, target } + return { targetId: target.id, target, ...(environmentId ? { environmentId } : {}) } } export async function disconnectRuntimeOwnedSshTarget(targetId: string | undefined): Promise { diff --git a/src/main/ipc/ephemeral-vm-managed-ssh.test.ts b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts new file mode 100644 index 00000000000..8166861ab16 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: unknown) => unknown +const { handlers, connect, getPath } = vi.hoisted(() => ({ + handlers: new Map(), + connect: vi.fn(), + getPath: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath }, + ipcMain: { + handle: (channel: string, handler: Handler) => handlers.set(channel, handler), + removeHandler: vi.fn() + } +})) +vi.mock('../ephemeral-vm-runtime-ssh', () => ({ + connectRuntimeOwnedSshTarget: connect, + disconnectRuntimeOwnedSshTarget: vi.fn(async () => {}), + removeRuntimeOwnedSshTarget: vi.fn(async () => {}) +})) +vi.mock('./runtime-environments', () => ({ invalidateRuntimeEnvironmentTransport: vi.fn() })) + +import type { Store } from '../persistence' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { registerEphemeralVmHandlers } from './ephemeral-vm' + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const sshResult = { + schemaVersion: 1, + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'Sandbox', host: 'sandbox.example.com', port: 22, username: 'root' } + } +} + +function setupManagedSshRecipe(): { userDataPath: string } { + const userDataPath = tempDir('orca-vm-managed-user-data-') + const repoPath = tempDir('orca-vm-managed-repo-') + getPath.mockReturnValue(userDataPath) + const script = (name: string, body: string): string => { + const scriptPath = join(repoPath, name) + writeFileSync(scriptPath, body) + return JSON.stringify(`"${process.execPath}" "${scriptPath}"`) + } + const printResult = `console.log(${JSON.stringify(JSON.stringify(sshResult))})` + writeFileSync( + join(repoPath, 'orca.yaml'), + [ + 'environmentRecipes:', + ' - id: cloud-sandbox', + ' name: Cloud Sandbox', + ` create: ${script('start.js', printResult)}`, + ` suspend: ${script('suspend.js', '')}`, + ` resume: ${script('resume.js', printResult)}`, + ' destroy: none' + ].join('\n') + ) + connect.mockReset() + connect.mockImplementation(async ({ runtimeId }: { runtimeId: string }) => ({ + targetId: `runtime-ssh-${runtimeId}`, + target: { id: `runtime-ssh-${runtimeId}` }, + environmentId: 'env-managed-vm' + })) + const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only getRepo, getRepos and getSettings. + const store = { + getRepo: (id: string) => (id === repo.id ? repo : null), + getRepos: () => [repo], + getSettings: () => ({}) + } as unknown as Store + registerEphemeralVmHandlers(store) + return { userDataPath } +} + +async function provision(): Promise { + return handlers.get('ephemeralVm:provision')?.(null, { + repoId: 'repo-1', + recipeId: 'cloud-sandbox' + }) +} + +it("records the managed server that serves a recipe VM's SSH host", async () => { + setupManagedSshRecipe() + expect(await provision()).toMatchObject({ + ok: true, + connectionType: 'ssh', + environmentId: 'env-managed-vm', + runtime: { connectionMode: 'ssh', runtimeEnvironmentId: 'env-managed-vm' } + }) +}) + +it("reconnects a resumed VM's SSH host instead of asking for a pairing code", async () => { + const { userDataPath } = setupManagedSshRecipe() + const provisioned = await provision() + const [runtime] = listEphemeralVmRuntimes(userDataPath) + expect(provisioned).toMatchObject({ ok: true, runtime: { id: runtime.id } }) + const runtimeId = runtime.id + await handlers.get('ephemeralVm:attachWorkspace')?.(null, { runtimeId, workspaceId: 'ws-1' }) + await handlers.get('ephemeralVm:suspendWorkspace')?.(null, { workspaceId: 'ws-1' }) + connect.mockClear() + + const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { workspaceId: 'ws-1' }) + + expect(connect).toHaveBeenCalledTimes(1) + expect(resumed).toMatchObject({ + status: 'running', + connectionMode: 'ssh', + runtimeEnvironmentId: 'env-managed-vm' + }) +}) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 261c8050df8..95ab7bf6e64 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -9,10 +9,8 @@ import { getEphemeralVmRecipeResultConnection, getEphemeralVmRecipeResultPairingCode } from '../../shared/ephemeral-vm-recipes' -import { - removeEnvironment, - updateEnvironmentFromPairingCode -} from '../../shared/runtime-environment-store' +import { updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { releaseEphemeralVmRuntimeEnvironments } from '../ephemeral-vm-runtime-environment-release' import { cleanupEphemeralVmRuntime, resumeEphemeralVmRuntime, @@ -105,9 +103,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { runtimeId: runtime.id }) } - if (result.ok && runtime.runtimeEnvironmentId) { + if (result.ok) { try { - removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) + await releaseEphemeralVmRuntimeEnvironments(userDataPath, runtime) } catch { // Cleanup of provider resources matters more than hiding a stale local // environment row; users can still remove that manually. @@ -215,7 +213,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (!result.ok) { throw new Error(result.error) } - if (!result.skipped && runtime.runtimeEnvironmentId) { + const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) + // Why: an SSH VM's server is reached through its target, so it has no pairing to refresh. + if (!result.skipped && connection.type !== 'ssh' && runtime.runtimeEnvironmentId) { const pairingCode = getEphemeralVmRecipeResultPairingCode(result.runtime.recipeResult) if (!pairingCode) { throw new Error('Resume result did not include an Orca Server pairing code.') @@ -225,7 +225,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId) } - const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) if (!result.skipped && connection.type === 'ssh') { try { const ssh = await connectRuntimeOwnedSshTarget({ @@ -234,7 +233,8 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) return updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { connectionMode: 'ssh', - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) }) } catch (error) { updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index bb7ba5c8d66..5e8ec75b196 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -53,6 +53,8 @@ export type EphemeralVmProvisionIpcResult = connectionType: 'ssh' runtime: EphemeralVmRuntimeRecord sshTargetId: string + /** The managed Orca server the host runs; its workspace lives there. */ + environmentId?: string expectedRefHead?: string stderr: string warnings: EphemeralVmRecipeResultWarning[] @@ -213,7 +215,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin app.getPath('userData'), result.runtime.id, { - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) } ) return { @@ -221,6 +224,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin connectionType: 'ssh', runtime, sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { environmentId: ssh.environmentId } : {}), ...(expectedRefHead ? { expectedRefHead } : {}), stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr), warnings: getEphemeralVmRecipeResultWarnings(result.start.result) diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index b13a9bc256d..83fd1b2b84b 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -1,6 +1,9 @@ /** The live collaborators behind each connect's server decision. */ import { getAppEnvironment } from '../../shared/app-environment' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + getManagedOrcadFenceEnvironmentId, + managedOrcadServerName +} from '../../shared/managed-orcad-ssh-owner' import { listEnvironments } from '../../shared/runtime-environment-store' import { findOrcadMigrationSourceCutoverForTarget } from '../ssh/orcad-migration-cutover-journal' import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister' @@ -89,13 +92,13 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne }), deploy: (target) => createManagedOrcadEnvironment(userDataPath, { - name: target.orcadProvisioning?.name ?? target.label, + name: target.orcadProvisioning?.name ?? managedOrcadServerName(target), sshTargetId: target.id }), convert: (target, hostProof) => convertSshTargetToManagedOrcad(userDataPath, { sshTargetId: target.id, - name: target.label, + name: managedOrcadServerName(target), listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), // The connect's own census already answered; a conversion before any session reuses it. censusHost: (host) => diff --git a/src/main/provisioned-root-checkout.ts b/src/main/provisioned-root-checkout.ts new file mode 100644 index 00000000000..07b7a56e21e --- /dev/null +++ b/src/main/provisioned-root-checkout.ts @@ -0,0 +1,173 @@ +/** The host-independent half of adopting a recipe's provisioned root: checkout proof and metadata. */ +import { randomUUID } from 'node:crypto' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import type { CreateWorktreeArgs } from '../shared/worktree/create-types' +import type { ExecutionHostId } from '../shared/execution-host' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { AutomationWorkspaceProvenance, GitWorktreeInfo } from '../shared/worktree/types' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' +import { isTuiAgent } from '../shared/tui-agent-config' +import { + getWorktreeCreationLayout, + resolveWorktreeCreateDisplayNameMeta, + resolveWorktreeCreateDisplayNameRequest +} from './ipc/worktree-logic' + +export type ProvisionedRootRequest = Pick< + CreateWorktreeArgs, + | 'name' + | 'nameWasGenerated' + | 'displayName' + | 'displayNameKind' + | 'baseBranch' + | 'compareBaseRef' + | 'branchNameOverride' + | 'sparseCheckout' + | 'linkedIssue' + | 'linkedPR' + | 'linkedLinearIssue' + | 'linkedLinearIssueWorkspaceId' + | 'linkedLinearIssueOrganizationUrlKey' + | 'linkedGitLabIssue' + | 'linkedGitLabMR' + | 'linkedBitbucketPR' + | 'linkedAzureDevOpsPR' + | 'linkedGiteaPR' + | 'linkedWorkItem' + | 'linkedItems' + | 'linkedTaskSourceContext' + | 'pushTarget' + | 'workspaceStatus' + | 'manualOrder' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' +> & { + expectedRefHead?: string + automationProvenance?: AutomationWorkspaceProvenance +} + +export function provisionedRootPathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +/** The project root must be the repo's only, primary, non-sparse checkout on the requested branch and ref. */ +export function verifyProvisionedRootCheckout(args: { + worktrees: readonly GitWorktreeInfo[] + sparseCheckoutEnabled: boolean + projectRoot: string + request: Pick & { + expectedRefHead?: string + } +}): GitWorktreeInfo { + const { request } = args + const matches = args.worktrees.filter((worktree) => + provisionedRootPathsEqual(worktree.path, args.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 || args.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.") + } + return gitWorktree +} + +export type ProvisionedRootMetaStore = { + getProjectHostSetups?: Store['getProjectHostSetups'] + getSettings(): Parameters[1] +} + +export function buildProvisionedRootMeta( + store: ProvisionedRootMetaStore, + repo: Repo, + args: ProvisionedRootRequest, + options: { + branchName: string + now: number + /** Omitted on the host that runs the checkout, whose own rows carry no host id. */ + hostId?: ExecutionHostId + source: NonNullable + } +): Partial { + const { branchName, now } = options + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + false, + args.nameWasGenerated === true + ) + const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( + displayNameRequest.value, + branchName, + displayNameRequest.kind, + { requestedName: args.name, sanitizedName: args.name } + ) + return { + instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + ...(options.hostId ? { hostId: options.hostId } : {}), + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: displayNameMeta.displayName ?? args.name, + ...displayNameMeta, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: options.source, + 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.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + } +} diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 3f6565382d8..f90aef7df94 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -1,34 +1,29 @@ -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 { mergeWorktree } from './ipc/worktree-logic' import { - getWorktreeCreationLayout, - mergeWorktree, - resolveWorktreeCreateDisplayNameMeta, - resolveWorktreeCreateDisplayNameRequest -} from './ipc/worktree-logic' + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout +} from './provisioned-root-checkout' type AdoptionArgs = AdoptProvisionedRootArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -59,7 +54,10 @@ export async function adoptProvisionedRootSshCheckout(args: { connectionId ) const projectRoot = getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult) - if (!pathsEqual(request.expectedPath, projectRoot) || !pathsEqual(repo.path, projectRoot)) { + if ( + !provisionedRootPathsEqual(request.expectedPath, projectRoot) || + !provisionedRootPathsEqual(repo.path, projectRoot) + ) { throw new Error('The recipe projectRoot does not match the imported Git checkout root.') } @@ -81,30 +79,12 @@ export async function adoptProvisionedRootSshCheckout(args: { } 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 gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot, + request + }) const worktreeId = `${repo.id}::${gitWorktree.path}` attachEphemeralVmRuntimeToWorkspace({ @@ -115,13 +95,12 @@ export async function adoptProvisionedRootSshCheckout(args: { const now = Date.now() const meta = store.setWorktreeMeta( worktreeId, - buildProvisionedRootMeta( - store, - repo, - request, - gitWorktree.branch.replace(/^refs\/heads\//, ''), - now - ) + buildProvisionedRootMeta(store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now, + hostId: args.request.executionHostId, + source: 'ssh' + }) ) return { worktree: mergeWorktree(repo.id, gitWorktree, meta) } } @@ -157,77 +136,3 @@ function requireOwnedProvisionedRootRuntime( } return runtime } - -function pathsEqual(left: string, right: string): boolean { - return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) -} - -function buildProvisionedRootMeta( - store: Store, - repo: Repo, - args: AdoptionArgs, - branchName: string, - now: number -): Partial { - const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( - args.displayName, - args.displayNameKind, - args.name, - false, - args.nameWasGenerated === true - ) - const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( - displayNameRequest.value, - branchName, - displayNameRequest.kind, - { requestedName: args.name, sanitizedName: args.name } - ) - return { - instanceId: randomUUID(), - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - hostId: args.executionHostId, - ephemeralVmCheckoutMode: 'provisioned-root', - displayName: displayNameMeta.displayName ?? args.name, - ...displayNameMeta, - 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.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - } -} diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index e6993788827..29494f2f87f 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -16,6 +16,12 @@ import { } from './runtime-managed-worktree-metadata' import { resolveRuntimeGitHubWorktreeBase } from './runtime-github-worktree-base' import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base' +import { listWorktreesStrict } from '../git/worktree-listing' +import { + adoptRuntimeProvisionedRoot, + readLocalSparseCheckoutEnabled, + type RuntimeProvisionedRootRequest +} from './runtime-provisioned-root-adoption' export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithCreateManagedWorktree { protected createManagedRemoteWorktree( @@ -171,6 +177,27 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC }) } + adoptManagedProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return adoptRuntimeProvisionedRoot(repoSelector, request, { + store: this.store, + resolveRepo: (selector) => this.resolveRepoSelector(selector), + listWorktrees: (repoPath) => listWorktreesStrict(repoPath), + isSparseCheckoutEnabled: readLocalSparseCheckoutEnabled, + invalidateScan: (repoId) => { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + }, + notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId), + showWorktree: (selector) => this.showManagedWorktree(selector) + }) + } + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { if (!this.store) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts new file mode 100644 index 00000000000..b557ef2e33c --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts @@ -0,0 +1,21 @@ +import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' +import { WorktreeAdoptProvisionedRoot } from '../../../../shared/rpc-contract/worktree-provisioned-root-params' +import { defineMethod } from '../core' + +export const WORKTREE_PROVISIONED_ROOT_METHOD = defineMethod({ + name: 'worktree.adoptProvisionedRoot', + permission: 'workspace', + params: WorktreeAdoptProvisionedRoot, + handler: async (params, { runtime }) => { + const { repo, linkedIssue, linkedPR, linkedGitLabMR, linkedGitLabIssue, ...request } = params + // Why: null clears a link on update; a fresh adoption has nothing to clear. + const result = await runtime.adoptManagedProvisionedRoot(repo, { + ...request, + linkedIssue: linkedIssue ?? undefined, + linkedPR: linkedPR ?? undefined, + linkedGitLabMR: linkedGitLabMR ?? undefined, + linkedGitLabIssue: linkedGitLabIssue ?? undefined + }) + return { ...result, catalogVersion: getLocalWorktreeCatalogVersion(result.worktree.repoId) } + } +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9d044842673..bd2b4d7f5c3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -30,6 +30,7 @@ import { WorktreeTeardownMissingTerminalsParams } from '../../../../shared/rpc-contract/worktree-params' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' +import { WORKTREE_PROVISIONED_ROOT_METHOD } from './worktree-provisioned-root-method' import { readsWorktreeRemovalMarker } from '../worktree-removal-marker-projection' export const WORKTREE_METHODS = [ @@ -136,6 +137,7 @@ export const WORKTREE_METHODS = [ } }) }), + WORKTREE_PROVISIONED_ROOT_METHOD, defineMethod({ name: 'worktree.prefetchCreateBase', permission: 'workspace', diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts new file mode 100644 index 00000000000..5a6f3261a0e --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { + adoptRuntimeProvisionedRoot, + type RuntimeProvisionedRootPorts +} from './runtime-provisioned-root-adoption' + +const root = '/workspace/repo' +const repo: Repo = { + id: 'repo-vm', + path: root, + displayName: 'repo', + badgeColor: '#000', + addedAt: 1 +} + +function checkout(overrides: Partial = {}): GitWorktreeInfo { + return { + path: root, + head: 'abc123', + branch: 'refs/heads/fix-login', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function ports(worktrees: GitWorktreeInfo[], sparse = false) { + const setWorktreeMeta = vi.fn() + const notifyChanged = vi.fn() + const value: RuntimeProvisionedRootPorts = { + store: { + getSettings: () => ({ workspaceDir: '/workspaces', nestWorkspaces: false }), + setWorktreeMeta + }, + resolveRepo: async () => repo, + listWorktrees: async () => worktrees, + isSparseCheckoutEnabled: async () => sparse, + invalidateScan: vi.fn(), + notifyChanged, + showWorktree: async (selector) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption only returns the row it was handed; no Worktree field is read. + ({ id: selector.slice('id:'.length), repoId: repo.id, path: root }) as never + } + return { value, setWorktreeMeta, notifyChanged } +} + +describe('adoptRuntimeProvisionedRoot', () => { + it('adopts the primary checkout as the workspace and records it as provisioned-root', async () => { + const { value, setWorktreeMeta, notifyChanged } = ports([checkout()]) + const result = await adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', baseBranch: 'main', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + expect(result.worktree.id).toBe(`repo-vm::${root}`) + expect(setWorktreeMeta).toHaveBeenCalledWith( + `repo-vm::${root}`, + expect.objectContaining({ + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: 'fix-login', + orcaCreationSource: 'runtime', + baseRef: 'main' + }) + ) + expect(setWorktreeMeta.mock.calls[0][1]).not.toHaveProperty('hostId') + expect(notifyChanged).toHaveBeenCalledWith('repo-vm') + }) + + it.each<[string, GitWorktreeInfo[], boolean, string]>([ + ['another branch', [checkout({ branch: 'refs/heads/other' })], false, 'requested branch'], + ['another ref', [checkout({ head: 'def456' })], false, 'requested ref'], + ['a linked worktree', [checkout({ isMainWorktree: false })], false, 'primary checkout'], + ['a sparse checkout', [checkout()], true, 'sparse checkout'] + ])('refuses %s and records nothing', async (_label, worktrees, sparse, message) => { + const { value, setWorktreeMeta } = ports(worktrees, sparse) + await expect( + adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + ).rejects.toThrow(message) + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses a project whose root is not the recipe root', async () => { + const { value } = ports([checkout()]) + await expect( + adoptRuntimeProvisionedRoot('repo-vm', { name: 'fix-login', expectedPath: '/other' }, value) + ).rejects.toThrow('does not match') + }) +}) diff --git a/src/main/runtime/runtime-provisioned-root-adoption.ts b/src/main/runtime/runtime-provisioned-root-adoption.ts new file mode 100644 index 00000000000..3becab13b6a --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.ts @@ -0,0 +1,77 @@ +/** + * Adopts a recipe-provisioned project root that this host runs, as the desktop does for a direct + * SSH root: the root becomes the main worktree's workspace, and no linked worktree is created. + */ +import type { Repo } from '../../shared/repo-types' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import { getRepoSshConnectionId } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout, + type ProvisionedRootMetaStore, + type ProvisionedRootRequest +} from '../provisioned-root-checkout' +import { gitExecFileAsync } from '../git/runner' +import type { RuntimeStore } from './runtime-store-contract' + +export type RuntimeProvisionedRootRequest = ProvisionedRootRequest & { expectedPath: string } + +export type RuntimeProvisionedRootPorts = { + store: ProvisionedRootMetaStore & Pick + resolveRepo: (selector: string) => Promise + listWorktrees: (repoPath: string) => Promise + isSparseCheckoutEnabled: (checkoutPath: string) => Promise + invalidateScan: (repoId: string) => void + notifyChanged: (repoId: string) => void + showWorktree: (selector: string) => Promise +} + +export async function adoptRuntimeProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest, + ports: RuntimeProvisionedRootPorts +): Promise { + if (request.sparseCheckout) { + throw new Error('Provisioned-root recipes do not support sparse checkout.') + } + const repo = await ports.resolveRepo(repoSelector) + if (isFolderRepo(repo) || getRepoSshConnectionId(repo)) { + throw new Error('Provisioned-root adoption requires a Git checkout on this host.') + } + if (!provisionedRootPathsEqual(repo.path, request.expectedPath)) { + throw new Error('The recipe projectRoot does not match the imported Git checkout root.') + } + const [worktrees, sparseCheckoutEnabled] = await Promise.all([ + ports.listWorktrees(repo.path), + ports.isSparseCheckoutEnabled(repo.path) + ]) + const gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot: request.expectedPath, + request + }) + const worktreeId = `${repo.id}::${gitWorktree.path}` + ports.store.setWorktreeMeta( + worktreeId, + buildProvisionedRootMeta(ports.store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now: Date.now(), + source: 'runtime' + }) + ) + ports.invalidateScan(repo.id) + ports.notifyChanged(repo.id) + return { worktree: await ports.showWorktree(`id:${worktreeId}`) } +} + +export async function readLocalSparseCheckoutEnabled(checkoutPath: string): Promise { + const { stdout } = await gitExecFileAsync( + ['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'], + { cwd: checkoutPath } + ) + return stdout.trim() === 'true' +} diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts index 319e04da0ee..6be6d4ee209 100644 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ b/src/main/ssh/ssh-target-orcad-claims.test.ts @@ -80,6 +80,18 @@ describe('managed orcad SSH target claims', () => { expect(current().orcadProvisioning).toBeUndefined() }) + it("claims a recipe VM's target: its runtime owns the host's lifecycle, not its server", () => { + const { claims } = setup({ + target: { owner: { type: 'on-demand-runtime', runtimeId: 'vm-1' } } + }) + expect(claims.preflight('ssh-1')).toMatchObject({ claimable: true, blockers: [] }) + expect( + getManagedOrcadFenceEnvironmentId( + claims.claim('ssh-1', 'environment-1', { deployName: 'VM', ownerRecorded: false }) + ) + ).toBe('environment-1') + }) + it('claims for SSH access without recording a provisioning intent', () => { const { claims } = setup({ target: { generation: 1 } }) expect( diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts index f2fbe916448..af73895bbad 100644 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ b/src/main/ssh/ssh-target-orcad-claims.ts @@ -5,7 +5,10 @@ * leases: moving a direct SSH host's state into a managed server is the catalog migration. */ import type { Store } from '../persistence' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + getManagedOrcadFenceEnvironmentId, + isEphemeralRuntimeSshOwner +} from '../../shared/managed-orcad-ssh-owner' import type { OrcadMigrationBlocker, OrcadMigrationPreflight @@ -234,11 +237,12 @@ export function collectTargetCatalogBlockers( return blockers } -/** Who holds the target exclusively: an ephemeral runtime or a managed Orca server. */ +/** Who holds the target exclusively: a managed Orca server, or an unmigrated legacy owner. */ function targetHolder( target: SshTarget ): Extract['holder'] | null { - if (target.owner) { + // Why: a recipe VM's runtime owns when the host is created and destroyed, not which server runs it. + if (target.owner && !isEphemeralRuntimeSshOwner(target.owner)) { return { kind: 'runtime', runtimeId: target.owner.runtimeId } } const environmentId = getManagedOrcadFenceEnvironmentId(target) diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 5b2bfe3bd62..bacfb239f35 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -45,6 +45,8 @@ export type EphemeralVmApi = { connectionType: 'ssh' runtime: EphemeralVmRuntimeRecord sshTargetId: string + /** The managed Orca server the host runs; its workspace lives there. */ + environmentId?: string expectedRefHead?: string stderr: string warnings: EphemeralVmRecipeResultWarning[] diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx index 45c04b08a56..6e841f672d6 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx @@ -4,9 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => { - const worktreesByRepo: Record = {} + const worktreesByRepo: Record< + string, + { id: string; hostId?: string; ephemeralVmCheckoutMode?: 'provisioned-root' }[] + > = {} const tabsByWorktree: Record = {} const ptyIdsByTabId: Record = {} + const runtimeEnvironments: { id: string; orcadDeployment?: { sshTargetId: string } }[] = [] return { state: { activeModal: 'confirm-remove-folder' as string | null, @@ -16,6 +20,7 @@ const mocks = vi.hoisted(() => { hostId: 'ssh:target-1' } as Record, repos: [] as Repo[], + runtimeEnvironments, sshTargetLabels: new Map(), removedSshTargetLabels: new Map(), worktreesByRepo, @@ -82,6 +87,20 @@ describe('RemoveFolderDialog', () => { mocks.state.worktreesByRepo = {} mocks.state.tabsByWorktree = {} mocks.state.ptyIdsByTabId = {} + mocks.state.runtimeEnvironments = [] + }) + + it("warns about VM recipe cleanup for a project on a recipe VM's managed server", () => { + mocks.state.modalData.hostId = 'runtime:env-vm' + mocks.state.repos = [repo(null, 'runtime:env-vm')] + mocks.state.runtimeEnvironments = [ + { id: 'env-vm', orcadDeployment: { sshTargetId: 'runtime-ssh-runtime-1' } } + ] + + const html = renderToStaticMarkup() + + expect(html).toContain('Its VM recipe determines whether the environment') + expect(html).not.toContain('It is still on your disk') }) it('warns that VM recipe cleanup controls file deletion', () => { @@ -95,6 +114,18 @@ describe('RemoveFolderDialog', () => { expect(html).not.toContain('Its files stay on') }) + it('warns about VM recipe cleanup for an adopted provisioned root before the catalog loads', () => { + mocks.state.modalData.hostId = 'runtime:env-vm' + mocks.state.repos = [repo(null, 'runtime:env-vm')] + mocks.state.worktreesByRepo = { + 'repo-1': [ + { id: 'repo-1::/vm', hostId: 'runtime:env-vm', ephemeralVmCheckoutMode: 'provisioned-root' } + ] + } + + expect(renderToStaticMarkup()).toContain('Its VM recipe determines') + }) + it('keeps the file-preservation promise for ordinary SSH projects', () => { mocks.state.repos = [repo('target-1', 'ssh:target-1')] diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index f7b9776a6e8..9e6c5d9cf4d 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -16,6 +16,7 @@ import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal' +import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' // Why: interpolated into the sentence so locales control where the name sits; // U+0000 cannot appear in a real project name, so the split is unambiguous. @@ -54,6 +55,15 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { ) }) + const onRecipeVmServer = useAppStore( + (s) => + getRecipeVmSshTargetForHost(s.runtimeEnvironments, hostId) !== null || + (s.worktreesByRepo[repoId] ?? []).some( + (worktree) => + worktree.hostId === hostId && worktree.ephemeralVmCheckoutMode === 'provisioned-root' + ) + ) + const openTerminalCount = useAppStore((s) => repoId && hostId ? countOpenRepoTerminalTabs(s, repoId, hostId) : 0 ) @@ -74,23 +84,24 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { // Why: fragment concatenation around the styled name cannot be reordered by // SOV locales (#9294). Translate one full sentence with the name as a // sentinel token, then split on it to re-apply the inline emphasis. - const description = isRuntimeOwnedSshTargetId(sshConnectionId) - ? translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe', - 'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.', - { name: NAME_TOKEN } - ) - : sshHostLabel + const description = + onRecipeVmServer || isRuntimeOwnedSshTargetId(sshConnectionId) ? translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh', - 'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.', - { name: NAME_TOKEN, host: sshHostLabel } - ) - : translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal', - 'This only removes {{name}} from Orca. It is still on your disk.', + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe', + 'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.', { name: NAME_TOKEN } ) + : sshHostLabel + ? translate( + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh', + 'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.', + { name: NAME_TOKEN, host: sshHostLabel } + ) + : translate( + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal', + 'This only removes {{name}} from Orca. It is still on your disk.', + { name: NAME_TOKEN } + ) const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN) const handleConfirm = useCallback(() => { diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts index cbf0cf51134..1408fcbe206 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts @@ -174,6 +174,71 @@ describe('prepareEphemeralVmWorkspaceTarget', () => { expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled() }) + it("imports a provisioned root on the managed server that runs the VM's SSH host", async () => { + vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({ + ok: true, + connectionType: 'ssh', + stderr: '', + warnings: [], + sshTargetId: 'runtime-ssh-runtime-1', + environmentId: 'env-vm', + expectedRefHead: 'abc123', + runtime: { + id: 'runtime-1', + repoId: 'repo-1', + recipeId: 'cloud-sandbox', + connectionMode: 'ssh', + sshTargetId: 'runtime-ssh-runtime-1', + runtimeEnvironmentId: 'env-vm', + status: 'running', + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 2, + checkoutMode: 'provisioned-root', + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'Sandbox', host: 'sandbox.example.com', port: 22, username: 'root' } + } + } + } + }) + const setupResult = { + project: { id: 'project-1' }, + setup: { id: 'setup-1', hostId: 'local' }, + repo: { id: 'repo-runtime' } + } as ProjectHostSetupResult + const setupExistingFolder = vi.fn( + async () => setupResult + ) + + const result = await prepareEphemeralVmWorkspaceTarget({ + repoId: 'repo-1', + recipeId: 'cloud-sandbox', + projectId: 'project-1', + workspaceName: 'Fix Login Race', + setupExistingFolder + }) + + expect(assertRuntimeEnvironmentCapability).toHaveBeenCalledWith( + 'env-vm', + 'worktree.provisioned-root.v1', + expect.any(String) + ) + expect(setupExistingFolder).toHaveBeenCalledWith( + expect.objectContaining({ hostId: 'runtime:env-vm', path: '/workspace/repo' }) + ) + expect(result).toMatchObject({ + ok: true, + setup: { setup: { hostId: 'runtime:env-vm' } }, + checkoutMode: 'provisioned-root', + environmentId: 'env-vm', + expectedRefHead: 'abc123' + }) + }) + it('rejects and cleans up an Orca-server provisioned root before project import', async () => { vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({ ok: true, diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index 67bd053a082..05c7d813a40 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -1,4 +1,8 @@ -import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import type { ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult @@ -8,7 +12,10 @@ import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../../../shared/ephemeral-vm-recipe-diagnostics' -import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { translate } from '@/i18n/i18n' import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' @@ -71,18 +78,22 @@ export async function prepareEphemeralVmWorkspaceTarget( } } - const hostId = - provisioned.connectionType === 'ssh' - ? toSshExecutionHostId(provisioned.sshTargetId) - : toRuntimeExecutionHostId(provisioned.environment.id) + const { hostId, environmentId } = recipeWorkspaceHost(provisioned) - if (provisioned.connectionType === 'orca-server') { + if (environmentId) { try { await assertRuntimeEnvironmentCapability( - provisioned.environment.id, + environmentId, PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, 'The recipe-created Orca server does not support project setup.' ) + if (checkoutMode === 'provisioned-root') { + await assertRuntimeEnvironmentCapability( + environmentId, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, + 'The Orca server on this VM cannot adopt a provisioned root. Update Orca on the VM.' + ) + } } catch (error) { await cleanupProvisionedRuntime(provisioned.runtime.id) return { @@ -92,7 +103,6 @@ export async function prepareEphemeralVmWorkspaceTarget( } } } - let setup: ProjectHostSetupResult | null try { setup = await args.setupExistingFolder({ @@ -144,9 +154,28 @@ export async function prepareEphemeralVmWorkspaceTarget( warnings: provisioned.warnings } satisfies PrepareEphemeralVmWorkspaceTargetResult - return provisioned.connectionType === 'orca-server' - ? { ...success, environmentId: provisioned.environment.id } - : success + return environmentId ? { ...success, environmentId } : success +} + +type ProvisionedVm = Extract< + Awaited>, + { ok: true } +> + +/** A VM whose SSH host runs a managed server keeps its workspace on that server. */ +function recipeWorkspaceHost(provisioned: ProvisionedVm): { + hostId: ExecutionHostId + environmentId?: string +} { + if (provisioned.connectionType === 'orca-server') { + const environmentId = provisioned.environment.id + return { hostId: toRuntimeExecutionHostId(environmentId), environmentId } + } + if (provisioned.environmentId) { + const { environmentId } = provisioned + return { hostId: toRuntimeExecutionHostId(environmentId), environmentId } + } + return { hostId: toSshExecutionHostId(provisioned.sshTargetId) } } async function cleanupProvisionedRuntime(runtimeId: string): Promise { diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 77a215d0fa7..5a910f040f1 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -71,6 +71,14 @@ export async function prepareRequestForCreate( return null } appendProvisioningWarnings(creationId, preparedTarget.warnings) + if (preparedTarget.environmentId) { + // Why: the VM's first connect may have just deployed this server; host badges and removal read it. + try { + useAppStore.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + } catch { + // A stale catalog only delays host badges; removal re-reads it. + } + } const preparedRequest: WorktreeCreationRequest = { ...request, repoId: preparedTarget.setup.repo.id, diff --git a/src/renderer/src/lib/recipe-vm-managed-host.test.ts b/src/renderer/src/lib/recipe-vm-managed-host.test.ts new file mode 100644 index 00000000000..8846c9e60cf --- /dev/null +++ b/src/renderer/src/lib/recipe-vm-managed-host.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getRecipeVmSshTargetForHost } from './recipe-vm-managed-host' + +const deployment = (sshTargetId: string) => ({ sshTargetId }) + +describe('getRecipeVmSshTargetForHost', () => { + const environments = [ + { id: 'env-vm', orcadDeployment: deployment('runtime-ssh-vm-1') }, + { id: 'env-host', orcadDeployment: deployment('ssh-builder') }, + { id: 'env-paired' } + ] + + it("names the VM target behind a recipe VM's managed server", () => { + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-vm')).toBe('runtime-ssh-vm-1') + }) + + it('ignores user SSH hosts, paired servers, and non-runtime hosts', () => { + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-host')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-paired')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, 'ssh:runtime-ssh-vm-1')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, null)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/recipe-vm-managed-host.ts b/src/renderer/src/lib/recipe-vm-managed-host.ts new file mode 100644 index 00000000000..b539cf9ec2b --- /dev/null +++ b/src/renderer/src/lib/recipe-vm-managed-host.ts @@ -0,0 +1,15 @@ +import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../shared/execution-host' + +/** The hidden SSH target of the recipe VM whose managed server is `hostId`, or null. */ +export function getRecipeVmSshTargetForHost( + environments: readonly { id: string; orcadDeployment?: { sshTargetId: string } }[], + hostId: string | null | undefined +): string | null { + const host = parseExecutionHostId(hostId) + if (host?.kind !== 'runtime') { + return null + } + const targetId = environments.find((entry) => entry.id === host.environmentId)?.orcadDeployment + ?.sshTargetId + return targetId && isRuntimeOwnedSshTargetId(targetId) ? targetId : null +} diff --git a/src/renderer/src/store/repos/recipe-vm-project-removal.ts b/src/renderer/src/store/repos/recipe-vm-project-removal.ts new file mode 100644 index 00000000000..429d7ecf078 --- /dev/null +++ b/src/renderer/src/store/repos/recipe-vm-project-removal.ts @@ -0,0 +1,66 @@ +import { + isRuntimeOwnedSshTargetId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { AppState } from '../types' +import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' + +/** + * Destroys the recipe VM behind a project before the project is removed, so it never leaks. + * `false` (synchronously) when no VM backs it; otherwise resolves true when the project lived + * on the VM's managed server, which the VM took with it. + */ +export function destroyRecipeVmBeforeProjectRemoval( + state: Pick, + ownerRepo: Pick, + ownerHostId: ExecutionHostId, + workspaceIds: string[] +): Promise | false { + const knownServerTarget = getRecipeVmSshTargetForHost(state.runtimeEnvironments, ownerHostId) + const hasProvisionedRoot = (state.worktreesByRepo[ownerRepo.id] ?? []).some( + (worktree) => + (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === ownerHostId && + worktree.ephemeralVmCheckoutMode === 'provisioned-root' + ) + const directTarget = + ownerRepo.connectionId && isRuntimeOwnedSshTargetId(ownerRepo.connectionId) + ? ownerRepo.connectionId + : null + if (!knownServerTarget && !hasProvisionedRoot && !directTarget) { + return false + } + return destroyRecipeVm(ownerHostId, workspaceIds, knownServerTarget, directTarget) +} + +async function destroyRecipeVm( + ownerHostId: ExecutionHostId, + workspaceIds: string[], + knownServerTarget: string | null, + directTarget: string | null +): Promise { + const serverTarget = + knownServerTarget ?? + // Why: the renderer's catalog may predate the server the VM's first connect deployed. + (ownerHostId.startsWith('runtime:') + ? getRecipeVmSshTargetForHost(await window.api.runtimeEnvironments.list(), ownerHostId) + : null) + const targetId = serverTarget ?? directTarget + if (!targetId) { + return false + } + // Why host-scoped: another VM's server can publish the same raw workspace id. + const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ + hostScopedWorkspaces: workspaceIds.map((workspaceId) => ({ + workspaceId, + executionHostId: ownerHostId + })), + runtimeOwnedSshTargetIds: [targetId] + }) + if (cleanup.retainedSshTargetIds.includes(targetId)) { + throw new Error('The cloud VM could not be destroyed. Retry cleanup before removing it.') + } + return serverTarget !== null +} diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 3695dbbe51a..bcbc8aaeda2 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -10,12 +10,8 @@ import { callRuntimeRpc, hasRuntimeRpcErrorCode } from '../../runtime/runtime-rp import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' -import { - getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, - LOCAL_EXECUTION_HOST_ID -} from '../../../../shared/execution-host' -import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { destroyRecipeVmBeforeProjectRemoval } from './recipe-vm-project-removal' import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' @@ -91,19 +87,14 @@ export function createRepoRemovalActions( return } const ownerHostId = getRepoExecutionHostId(ownerRepo) - const runtimeSshTargetId = ownerRepo.connectionId - // Why: an SSH per-workspace-env's workspace is the repo's main worktree, so removal routes here; tear down its ephemeral runtime first so it doesn't leak. - if (runtimeSshTargetId && isRuntimeOwnedSshTargetId(runtimeSshTargetId)) { - const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ - workspaceIds: getKnownRepoWorktreeIds(get(), projectId, ownerHostId), - runtimeOwnedSshTargetIds: [runtimeSshTargetId] - }) - if (cleanup.retainedSshTargetIds.includes(runtimeSshTargetId)) { - throw new Error( - 'The cloud VM could not be destroyed. Retry cleanup before removing it.' - ) - } - } + // Why only awaited for a VM: concurrent removals of one row must reach the host together. + const vmDestroy = destroyRecipeVmBeforeProjectRemoval( + get(), + ownerRepo, + ownerHostId, + getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + ) + const vmServerDestroyed = vmDestroy !== false && (await vmDestroy) // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. const target = runtimeTargetForOwnerHostId(ownerHostId) if (!target) { @@ -118,10 +109,13 @@ export function createRepoRemovalActions( })) ) try { - // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). - await (target.kind === 'local' - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + // Why: destroying the VM took its server, and the catalog holding this row, with it. + if (!vmServerDestroyed) { + // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). + await (target.kind === 'local' + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) + : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + } } catch (err) { // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). if (!hasRuntimeRpcErrorCode(err, 'repo_not_found')) { diff --git a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts index a6bceb41ef5..8cc9e532f13 100644 --- a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts +++ b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts @@ -2,11 +2,14 @@ import { expect, it, vi } from 'vitest' import { toast } from 'sonner' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import { ephemeralVmCleanup, ephemeralVmListRuntimes, installReposRuntimeRoutingHarness, reposRemoveForHost, + runtimeEnvironmentCall, + runtimeEnvironmentsList, sshRepo } from './repos-runtime-routing-fixture' import { createTestStore } from './store-test-helpers' @@ -51,3 +54,147 @@ it('retains a runtime-owned SSH project when VM cleanup fails', async () => { expect.objectContaining({ description: expect.stringContaining('Retry cleanup') }) ) }) + +const vmServerRepo: Repo = { + ...sshRepo, + id: 'vm-repo', + connectionId: null, + executionHostId: 'runtime:env-vm' +} +const vmServer: PublicKnownRuntimeEnvironment = { + id: 'env-vm', + name: 'Docker root VM', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://127.0.0.1:41000' } + ], + preferredEndpointId: 'ws', + orcadDeployment: { + sshTargetId: 'runtime-ssh-runtime-1', + sshTargetGeneration: 1, + localPort: 41_000, + remotePort: 6768 + } +} + +it('destroys a recipe VM whose managed server holds the project, without asking the gone server', async () => { + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [vmServer] + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.rm' }) + ) + expect(store.getState().repos).toEqual([]) +}) + +it("retains a recipe VM's managed-server project when VM cleanup fails", async () => { + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ + status: 'cleanup_failed', + cleanupStatus: 'failed', + sshTargetId: 'runtime-ssh-runtime-1' + }) + const store = createTestStore() + store.setState({ repos: [vmServerRepo], runtimeEnvironments: [vmServer] }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(store.getState().repos).toEqual([vmServerRepo]) +}) + +it('never destroys another VM whose server publishes the same workspace id', async () => { + const sharedWorkspaceId = `${vmServerRepo.id}::/home/orca/project` + ephemeralVmListRuntimes.mockResolvedValue([ + { + id: 'runtime-1', + cleanupStatus: 'not_started', + sshTargetId: 'runtime-ssh-runtime-1', + runtimeEnvironmentId: 'env-vm', + workspaceId: sharedWorkspaceId + }, + { + id: 'runtime-2', + cleanupStatus: 'not_started', + sshTargetId: 'runtime-ssh-runtime-2', + runtimeEnvironmentId: 'env-vm-2', + workspaceId: sharedWorkspaceId + } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [vmServer], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only each row's id and hostId. + worktreesByRepo: { + [vmServerRepo.id]: [ + { + id: sharedWorkspaceId, + repoId: vmServerRepo.id, + path: '/home/orca/project', + hostId: 'runtime:env-vm' + } + ] + } as never + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledTimes(1) + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) +}) + +it("destroys the VM when the renderer's catalog predates the VM's server", async () => { + runtimeEnvironmentsList.mockResolvedValue([vmServer]) + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only id, hostId and ephemeralVmCheckoutMode. + worktreesByRepo: { + [vmServerRepo.id]: [ + { + id: `${vmServerRepo.id}::/home/orca/project`, + hostId: 'runtime:env-vm', + ephemeralVmCheckoutMode: 'provisioned-root' + } + ] + } as never + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) + expect(store.getState().repos).toEqual([]) +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index 9879516226e..b61e956b2d0 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -58,6 +58,7 @@ export const orcaProfileFindProjectProfiles: Mock = vi.fn() export const uiSet: Mock = vi.fn() export const ephemeralVmListRuntimes: Mock = vi.fn() export const ephemeralVmCleanup: Mock = vi.fn() +export const runtimeEnvironmentsList: Mock = vi.fn() // Registers the per-test reset + window stub. Call once inside the suite's module scope. export function installReposRuntimeRoutingHarness(): void { @@ -90,6 +91,7 @@ export function installReposRuntimeRoutingHarness(): void { uiSet.mockResolvedValue(undefined) ephemeralVmListRuntimes.mockReset().mockResolvedValue([]) ephemeralVmCleanup.mockReset() + runtimeEnvironmentsList.mockReset().mockResolvedValue([]) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -120,7 +122,10 @@ export function installReposRuntimeRoutingHarness(): void { findProjectProfiles: orcaProfileFindProjectProfiles }, pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + }, ephemeralVm: { listRuntimes: ephemeralVmListRuntimes, cleanup: ephemeralVmCleanup diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 653f5ea9967..42df8ef91f9 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,6 +1,9 @@ import { runtimeTargetForExecutionHostId } from '@/runtime/runtime-client-target' import { repoHostId } from '../listing/worktree-host-ownership' -import { isUnresolvedOwnerHostId } from '../../../../../../shared/execution-host' +import { + isUnresolvedOwnerHostId, + toRuntimeExecutionHostId +} from '../../../../../../shared/execution-host' import { adoptFromEndpoint } from '../../../adopt-from-endpoint' import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' @@ -22,6 +25,7 @@ import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/loca import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast' import { applyCreatedWorktree } from './created-worktree-state-merge' +import { adoptRuntimeProvisionedRoot } from './runtime-provisioned-root-adoption' import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors' import { buildLocalWorktreeCreateArgs, @@ -53,10 +57,17 @@ async function runCreateAttempt( parentWorkspace: WorktreeCreateAttempt['parentWorkspace'] ): Promise => provisionedRoot - ? await window.api.worktrees.adoptProvisionedRoot({ - ...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }), - ...provisionedRoot - }) + ? target.kind === 'environment' + ? await adoptRuntimeProvisionedRoot( + target, + request, + { ...attempt, parentWorkspace }, + provisionedRoot + ) + : await window.api.worktrees.adoptProvisionedRoot({ + ...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }), + ...provisionedRoot + }) : target.kind === 'local' ? // Why local can still reject on the parent: paired web clients route this API to their host. await window.api.worktrees.create( @@ -200,8 +211,12 @@ export function createCreateWorktree( 'Update the remote runtime to link Jira' ) } - if (options?.provisionedRoot && target.kind !== 'local') { - throw new Error('Provisioned-root recipes currently require a direct SSH connection.') + if ( + options?.provisionedRoot && + target.kind === 'environment' && + options.provisionedRoot.executionHostId !== toRuntimeExecutionHostId(target.environmentId) + ) { + throw new Error("The provisioned root's host does not match the workspace host.") } for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { try { diff --git a/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts new file mode 100644 index 00000000000..9fc1fc7d03a --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../../runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) + +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { adoptRuntimeProvisionedRoot } from './runtime-provisioned-root-adoption' + +describe('adoptRuntimeProvisionedRoot', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { ephemeralVm: { attachWorkspace: vi.fn(async () => ({})) } } + }) + }) + + it("adopts the root on the VM's server, then records the workspace on the VM runtime", async () => { + vi.mocked(callRuntimeRpc).mockResolvedValue({ + worktree: { id: 'repo-vm::/workspace/repo', repoId: 'repo-vm' } + }) + const result = await adoptRuntimeProvisionedRoot( + { kind: 'environment', environmentId: 'env-vm' }, + { repoId: 'repo-vm', name: 'fix-login', baseBranch: 'main' }, + { name: 'fix-login' }, + { + runtimeId: 'runtime-1', + executionHostId: 'runtime:env-vm', + expectedPath: '/workspace/repo', + expectedRefHead: 'abc123' + } + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-vm' }, + 'worktree.adoptProvisionedRoot', + expect.objectContaining({ + repo: 'repo-vm', + name: 'fix-login', + baseBranch: 'main', + expectedPath: '/workspace/repo', + expectedRefHead: 'abc123' + }), + expect.anything() + ) + expect(window.api.ephemeralVm.attachWorkspace).toHaveBeenCalledWith({ + runtimeId: 'runtime-1', + workspaceId: 'repo-vm::/workspace/repo' + }) + expect(result.worktree.id).toBe('repo-vm::/workspace/repo') + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts new file mode 100644 index 00000000000..9abf665cde9 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts @@ -0,0 +1,36 @@ +import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { + buildRuntimeWorktreeCreateParams, + type CreateWorktreeCallOptions, + type WorktreeCreateAttempt, + type WorktreeCreateRequest +} from './worktree-create-payload' + +/** A recipe VM whose SSH host runs a managed server: that server adopts the root it checked out. */ +export async function adoptRuntimeProvisionedRoot( + target: Extract, + request: WorktreeCreateRequest, + attempt: WorktreeCreateAttempt, + provisionedRoot: NonNullable +): Promise { + const result = await callRuntimeRpc( + target, + 'worktree.adoptProvisionedRoot', + { + ...buildRuntimeWorktreeCreateParams(request, attempt), + expectedPath: provisionedRoot.expectedPath, + ...(provisionedRoot.expectedRefHead + ? { expectedRefHead: provisionedRoot.expectedRefHead } + : {}) + }, + { timeoutMs: 60_000 } + ) + // Why: removing the workspace later finds the VM to destroy through this desktop record. + await window.api.ephemeralVm.attachWorkspace({ + runtimeId: provisionedRoot.runtimeId, + workspaceId: result.worktree.id + }) + return result +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index 4ea9232927c..76901272bf1 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -22,6 +22,7 @@ export type CreateWorktreeCallOptions = { runtimeId: string executionHostId: ExecutionHostId expectedPath: string + expectedRefHead?: string } } diff --git a/src/shared/managed-orcad-ssh-owner.test.ts b/src/shared/managed-orcad-ssh-owner.test.ts index 2bf77630804..cf814aa9193 100644 --- a/src/shared/managed-orcad-ssh-owner.test.ts +++ b/src/shared/managed-orcad-ssh-owner.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { getLegacyManagedOrcadOwnerEnvironmentId, getManagedOrcadFenceEnvironmentId, - isEphemeralRuntimeSshOwner + isEphemeralRuntimeSshOwner, + managedOrcadServerName } from './managed-orcad-ssh-owner' describe('managed orcad SSH fence', () => { @@ -25,4 +26,10 @@ describe('managed orcad SSH fence', () => { expect(getLegacyManagedOrcadOwnerEnvironmentId(owner)).toBeNull() expect(isEphemeralRuntimeSshOwner(owner)).toBe(true) }) + + it("names a recipe VM's server after its runtime, so two VMs of one recipe never collide", () => { + const owner = { type: 'on-demand-runtime' as const, runtimeId: 'runtime-0123456789abcdef' } + expect(managedOrcadServerName({ label: 'Docker root', owner })).toBe('Docker root VM 89abcdef') + expect(managedOrcadServerName({ label: 'Builder' })).toBe('Builder') + }) }) diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts index d1d9a05a970..9f220376f35 100644 --- a/src/shared/managed-orcad-ssh-owner.ts +++ b/src/shared/managed-orcad-ssh-owner.ts @@ -27,3 +27,9 @@ export function isEphemeralRuntimeSshOwner(owner: SshTargetOwner | undefined): b owner?.type === 'on-demand-runtime' && getLegacyManagedOrcadOwnerEnvironmentId(owner) === null ) } + +/** Every VM a recipe makes shares its label, so the runtime id keeps their server names unique. */ +export function managedOrcadServerName(target: Pick): string { + const runtimeId = isEphemeralRuntimeSshOwner(target.owner) ? target.owner?.runtimeId : undefined + return runtimeId ? `${target.label} VM ${runtimeId.slice(-8)}` : target.label +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c87fc2d0988..075e6a26d29 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -83,6 +83,7 @@ export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const +export const WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY = 'worktree.provisioned-root.v1' as const export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = 'worktree.linked-work-item-context.v1' as const export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY = @@ -374,6 +375,7 @@ export const RUNTIME_CAPABILITIES = [ PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, ...WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 33463e3a7ad..c3aa9708862 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -581,6 +581,7 @@ import { WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams } from './worktree-params' +import { WorktreeAdoptProvisionedRoot } from './worktree-provisioned-root-params' import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract' import { SkillDeleteRequestSchema } from '../skill-delete-contract' import { @@ -1266,6 +1267,7 @@ export const RPC_PARAMS_BY_METHOD = { 'workspacePorts.scan': WorkspacePortScanParams, 'workspacePorts.scanHost': WorkspacePortScanHostParams, 'worktree.activate': WorktreeActivate, + 'worktree.adoptProvisionedRoot': WorktreeAdoptProvisionedRoot, 'worktree.create': WorktreeCreate, 'worktree.detectedList': WorktreeDetectedListParams, 'worktree.forceDeleteBranch': WorktreeForceDeleteBranch, diff --git a/src/shared/rpc-contract/worktree-provisioned-root-params.ts b/src/shared/rpc-contract/worktree-provisioned-root-params.ts new file mode 100644 index 00000000000..1fe901dfc10 --- /dev/null +++ b/src/shared/rpc-contract/worktree-provisioned-root-params.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { OptionalString } from './rpc-param-primitives' +import { WorktreeCreate } from './worktree-create-params' + +const create = WorktreeCreate.shape + +/** A recipe's provisioned project root, adopted on its host as the main worktree's workspace. */ +export const WorktreeAdoptProvisionedRoot = z.object({ + repo: create.repo, + nameWasGenerated: create.nameWasGenerated, + displayName: create.displayName, + displayNameKind: create.displayNameKind, + baseBranch: create.baseBranch, + compareBaseRef: create.compareBaseRef, + branchNameOverride: create.branchNameOverride, + sparseCheckout: create.sparseCheckout, + linkedIssue: create.linkedIssue, + linkedPR: create.linkedPR, + linkedLinearIssue: create.linkedLinearIssue, + linkedLinearIssueWorkspaceId: create.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: create.linkedLinearIssueOrganizationUrlKey, + linkedGitLabMR: create.linkedGitLabMR, + linkedGitLabIssue: create.linkedGitLabIssue, + linkedBitbucketPR: create.linkedBitbucketPR, + linkedAzureDevOpsPR: create.linkedAzureDevOpsPR, + linkedGiteaPR: create.linkedGiteaPR, + linkedWorkItem: create.linkedWorkItem, + linkedItems: create.linkedItems, + linkedTaskSourceContext: create.linkedTaskSourceContext, + workspaceStatus: create.workspaceStatus, + manualOrder: create.manualOrder, + pushTarget: create.pushTarget, + createdWithAgent: create.createdWithAgent, + name: z.string().min(1, 'Missing workspace name'), + expectedPath: z.string().min(1, 'Missing provisioned project root'), + expectedRefHead: OptionalString +}) diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts index bea66842227..af04ff2453b 100644 --- a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -30,7 +30,8 @@ test.use({ test('adopts a recipe-provisioned SSH root without creating a linked worktree', async ({ orcaPage }, testInfo) => { - test.setTimeout(240_000) + // The first connect deploys the VM's managed server before the root is adopted. + test.setTimeout(420_000) let target: DockerSshRelayTarget | null = null const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-')) try { @@ -59,27 +60,32 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree', await expect(dialog).toBeHidden({ timeout: 60_000 }) await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({ - timeout: 60_000 + timeout: 240_000 }) - await ensureTerminalVisible(orcaPage) - const adopted = await orcaPage.evaluate( - ({ sourceRepoId, workspaceName }) => { - const state = window.__store!.getState() - return Object.values(state.worktreesByRepo) - .flat() - .find( - (worktree) => worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId - ) - }, - { sourceRepoId, workspaceName } - ) + const findAdopted = () => + orcaPage.evaluate( + ({ sourceRepoId, workspaceName }) => + Object.values(window.__store!.getState().worktreesByRepo) + .flat() + .find( + (worktree) => + worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId + ) ?? null, + { sourceRepoId, workspaceName } + ) + await expect + .poll(findAdopted, { timeout: 60_000, message: 'the provisioned root was never adopted' }) + .not.toBeNull() + const adopted = await findAdopted() + await ensureTerminalVisible(orcaPage) expect(adopted).toMatchObject({ path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, isMainWorktree: true, ephemeralVmCheckoutMode: 'provisioned-root' }) - expect(adopted?.hostId).toMatch(/^(?:ssh:runtime-ssh-|runtime:)/) + // The VM's managed server holds the workspace; no relay serves it. + expect(adopted?.hostId).toMatch(/^runtime:/) expect( execDockerSshRelayTargetCommand( target,