mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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 <m4air@Mac.localdomain>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<EphemeralVmRuntimeRecord, 'runtimeEnvironmentId' | 'sshTargetId'>
|
||||
): Promise<void> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<string, Handler>(),
|
||||
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<unknown> {
|
||||
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'
|
||||
})
|
||||
})
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<ProvisionedRootRequest, 'name' | 'branchNameOverride' | 'baseBranch'> & {
|
||||
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<typeof getWorktreeCreationLayout>[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<WorktreeMeta['orcaCreationSource']>
|
||||
}
|
||||
): Partial<WorktreeMeta> {
|
||||
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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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<WorktreeMeta> {
|
||||
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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateWorktreeResult> {
|
||||
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')
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<RuntimeStore, 'setWorktreeMeta'>
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
listWorktrees: (repoPath: string) => Promise<GitWorktreeInfo[]>
|
||||
isSparseCheckoutEnabled: (checkoutPath: string) => Promise<boolean>
|
||||
invalidateScan: (repoId: string) => void
|
||||
notifyChanged: (repoId: string) => void
|
||||
showWorktree: (selector: string) => Promise<Worktree>
|
||||
}
|
||||
|
||||
export async function adoptRuntimeProvisionedRoot(
|
||||
repoSelector: string,
|
||||
request: RuntimeProvisionedRootRequest,
|
||||
ports: RuntimeProvisionedRootPorts
|
||||
): Promise<CreateWorktreeResult> {
|
||||
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<boolean> {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'],
|
||||
{ cwd: checkoutPath }
|
||||
)
|
||||
return stdout.trim() === 'true'
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<OrcadMigrationBlocker, { code: 'orcad_migration_target_owned' }>['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)
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<string, { id: string; hostId?: string }[]> = {}
|
||||
const worktreesByRepo: Record<
|
||||
string,
|
||||
{ id: string; hostId?: string; ephemeralVmCheckoutMode?: 'provisioned-root' }[]
|
||||
> = {}
|
||||
const tabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
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<string, unknown>,
|
||||
repos: [] as Repo[],
|
||||
runtimeEnvironments,
|
||||
sshTargetLabels: new Map<string, string>(),
|
||||
removedSshTargetLabels: new Map<string, string>(),
|
||||
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(<RemoveFolderDialog />)
|
||||
|
||||
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(<RemoveFolderDialog />)).toContain('Its VM recipe determines')
|
||||
})
|
||||
|
||||
it('keeps the file-preservation promise for ordinary SSH projects', () => {
|
||||
mocks.state.repos = [repo('target-1', 'ssh:target-1')]
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<PrepareEphemeralVmWorkspaceTargetArgs['setupExistingFolder']>(
|
||||
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,
|
||||
|
||||
@@ -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<ReturnType<typeof window.api.ephemeralVm.provision>>,
|
||||
{ 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<void> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<AppState, 'runtimeEnvironments' | 'worktreesByRepo'>,
|
||||
ownerRepo: Pick<Repo, 'id' | 'connectionId'>,
|
||||
ownerHostId: ExecutionHostId,
|
||||
workspaceIds: string[]
|
||||
): Promise<boolean> | 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<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CreateWorktreeResult> =>
|
||||
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 {
|
||||
|
||||
+49
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<RuntimeClientTarget, { kind: 'environment' }>,
|
||||
request: WorktreeCreateRequest,
|
||||
attempt: WorktreeCreateAttempt,
|
||||
provisionedRoot: NonNullable<CreateWorktreeCallOptions['provisionedRoot']>
|
||||
): Promise<CreateWorktreeResult> {
|
||||
const result = await callRuntimeRpc<CreateWorktreeResult>(
|
||||
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
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type CreateWorktreeCallOptions = {
|
||||
runtimeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
expectedPath: string
|
||||
expectedRefHead?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SshTarget, 'label' | 'owner'>): string {
|
||||
const runtimeId = isEphemeralRuntimeSshOwner(target.owner) ? target.owner?.runtimeId : undefined
|
||||
return runtimeId ? `${target.label} VM ${runtimeId.slice(-8)}` : target.label
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user