From bcc50483bb6cc712e8bf7bc8aea7232887f34c81 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 21:39:23 -0700 Subject: [PATCH] fix(vm): a recipe VM's SSH root runs on a managed Orca server (#27366) * fix(vm): a recipe VM's SSH root runs on a managed Orca server A recipe VM's hidden SSH target was counted as owned by another runtime, so every connect refused to deploy a managed server and fell back to the relay. Its runtime owner only records when the VM is created and destroyed, so it no longer blocks the deploy. The VM's workspace then lives on that server: the server adopts the provisioned root as its main worktree, and removing the project destroys the VM and forgets its server. * fix(vm): release the server of a relay-era VM that converted on connect * fix(vm): resume reconnects an SSH VM's server; removal stays on its own VM * fix(vm): removal and the remove dialog find a VM's server before the catalog loads * fix(vm): a catalog refresh failure never blocks a VM create --------- Co-authored-by: m4air --- ...ral-vm-runtime-environment-release.test.ts | 65 +++++++ ...phemeral-vm-runtime-environment-release.ts | 26 +++ src/main/ephemeral-vm-runtime-ssh.test.ts | 72 ++++++++ src/main/ephemeral-vm-runtime-ssh.ts | 12 +- src/main/ipc/ephemeral-vm-managed-ssh.test.ts | 127 +++++++++++++ src/main/ipc/ephemeral-vm-runtime-handlers.ts | 18 +- src/main/ipc/ephemeral-vm.ts | 6 +- .../ipc/ssh-host-server-on-connect-wiring.ts | 9 +- src/main/provisioned-root-checkout.ts | 173 ++++++++++++++++++ src/main/provisioned-root-ssh-adoption.ts | 137 +++----------- ...-runtime-create-managed-remote-worktree.ts | 27 +++ .../worktree-provisioned-root-method.ts | 21 +++ src/main/runtime/rpc/methods/worktree.ts | 2 + .../runtime-provisioned-root-adoption.test.ts | 94 ++++++++++ .../runtime-provisioned-root-adoption.ts | 77 ++++++++ src/main/ssh/ssh-target-orcad-claims.test.ts | 12 ++ src/main/ssh/ssh-target-orcad-claims.ts | 10 +- src/preload/api/ephemeral-vm-api.ts | 2 + .../sidebar/RemoveFolderDialog.test.tsx | 33 +++- .../components/sidebar/RemoveFolderDialog.tsx | 39 ++-- .../lib/ephemeral-vm-workspace-target.test.ts | 65 +++++++ .../src/lib/ephemeral-vm-workspace-target.ts | 53 ++++-- .../src/lib/ephemeral-vm-worktree-creation.ts | 8 + .../src/lib/recipe-vm-managed-host.test.ts | 23 +++ .../src/lib/recipe-vm-managed-host.ts | 15 ++ .../store/repos/recipe-vm-project-removal.ts | 66 +++++++ src/renderer/src/store/repos/repo-removal.ts | 40 ++-- ...pos-ephemeral-vm-cleanup-retention.test.ts | 147 +++++++++++++++ .../slices/repos-runtime-routing-fixture.ts | 7 +- .../worktrees/create/create-worktree.ts | 29 ++- .../runtime-provisioned-root-adoption.test.ts | 49 +++++ .../runtime-provisioned-root-adoption.ts | 36 ++++ .../create/worktree-create-payload.ts | 1 + src/shared/managed-orcad-ssh-owner.test.ts | 9 +- src/shared/managed-orcad-ssh-owner.ts | 6 + src/shared/protocol-version.ts | 2 + .../rpc-params-catalog.generated.ts | 2 + .../worktree-provisioned-root-params.ts | 37 ++++ .../e2e/ssh-orcad-vm-provisioned-root.spec.ts | 36 ++-- 39 files changed, 1385 insertions(+), 208 deletions(-) create mode 100644 src/main/ephemeral-vm-runtime-environment-release.test.ts create mode 100644 src/main/ephemeral-vm-runtime-environment-release.ts create mode 100644 src/main/ephemeral-vm-runtime-ssh.test.ts create mode 100644 src/main/ipc/ephemeral-vm-managed-ssh.test.ts create mode 100644 src/main/provisioned-root-checkout.ts create mode 100644 src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts create mode 100644 src/main/runtime/runtime-provisioned-root-adoption.test.ts create mode 100644 src/main/runtime/runtime-provisioned-root-adoption.ts create mode 100644 src/renderer/src/lib/recipe-vm-managed-host.test.ts create mode 100644 src/renderer/src/lib/recipe-vm-managed-host.ts create mode 100644 src/renderer/src/store/repos/recipe-vm-project-removal.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts create mode 100644 src/shared/rpc-contract/worktree-provisioned-root-params.ts 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,