fix(vm): harden provisioned root compatibility UX

This commit is contained in:
Jinwoo-H
2026-08-11 21:43:10 -04:00
parent ce23f1d244
commit 4075fb624c
9 changed files with 284 additions and 21 deletions
+9 -1
View File
@@ -345,6 +345,14 @@ resource_id="$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.writ
# destroy: provider remove "$resource_id" (or set destroy: none in orca.yaml)
```
For a provisioned-root `resume` script, require the negotiated schema **before** waking the resource;
omit this gate from `suspend`/`destroy` and from orca-worktree recipes:
```bash
[ "${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-1}" = 2 ] \
|| { echo "Upgrade Orca before resuming this provisioned-root recipe" >&2; exit 1; }
```
### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).
### 7f. Worked example — Vercel Sandbox (all three phases)
@@ -636,7 +644,7 @@ Windows runs recipes through its command shell; invoke it explicitly in `orca.ya
```powershell
#requires -Version 5
$ErrorActionPreference = 'Stop'
# Provisioned-root scripts only; omit this block for orca-worktree recipes.
# Provisioned-root create/resume only; omit this block from suspend/destroy and orca-worktree recipes.
if ($env:ORCA_RECIPE_RESULT_SCHEMA_VERSION -ne '2') {
throw 'Upgrade Orca before using this provisioned-root recipe'
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
state: {
activeModal: 'confirm-remove-folder' as string | null,
modalData: { repoId: 'repo-1', displayName: 'Cloud workspace' } as Record<string, unknown>,
closeModal: vi.fn(),
removeProject: vi.fn(),
repos: [{ id: 'repo-1', connectionId: null as string | null }],
worktreesByRepo: {} as Record<string, { ephemeralVmCheckoutMode?: string }[]>,
sshTargetLabels: new Map<string, string>(),
removedSshTargetLabels: new Map<string, string>()
}
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactNode }) => (
<button {...props}>{children}</button>
)
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace('{{name}}', values?.name ?? '').replace('{{host}}', values?.host ?? '')
}))
describe('RemoveFolderDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.repos = [{ id: 'repo-1', connectionId: null }]
mocks.state.worktreesByRepo = {}
})
it('warns that removing a provisioned root runs recipe cleanup', async () => {
mocks.state.worktreesByRepo = {
'repo-1': [{ ephemeralVmCheckoutMode: 'provisioned-root' }]
}
const { default: RemoveFolderDialog } = await import('./RemoveFolderDialog')
const markup = renderToStaticMarkup(<RemoveFolderDialog />)
expect(markup).toContain('cleans up its provisioned environment according to the recipe')
expect(markup).not.toContain('still on your disk')
})
it('keeps the local-project retention copy', async () => {
const { default: RemoveFolderDialog } = await import('./RemoveFolderDialog')
const markup = renderToStaticMarkup(<RemoveFolderDialog />)
expect(markup).toContain('still on your disk')
})
})
@@ -24,6 +24,11 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const isOpen = activeModal === 'confirm-remove-folder'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : ''
const isProvisionedRoot = useAppStore((s) =>
(s.worktreesByRepo[repoId] ?? []).some(
(worktree) => worktree.ephemeralVmCheckoutMode === 'provisioned-root'
)
)
// Why: for an SSH project the files live on the remote host's disk, not the
// user's — "still on your disk" would be misleading. Name the host (using the
@@ -44,17 +49,23 @@ 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 = sshHostLabel
const description = isProvisionedRoot
? 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.removeDescriptionProvisionedRoot',
'This removes {{name}} from Orca and cleans up its provisioned environment according to the recipe.',
{ 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(() => {
+1
View File
@@ -4399,6 +4399,7 @@
"4dc5b5065b": "Remove",
"d36883e046": "Cancel",
"b79b39d865": "Remove Project",
"removeDescriptionProvisionedRoot": "This removes {{name}} from Orca and cleans up its provisioned environment according to the recipe.",
"removeDescriptionSsh": "This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.",
"removeDescriptionLocal": "This only removes {{name}} from Orca. It is still on your disk."
},
@@ -243,6 +243,36 @@ describe('repo slice project host setup lifecycle', () => {
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([])
})
it('rejects failed setup deletion without mutating imported project state', async () => {
const attachedSetup = { ...runtimeSetup, repoId: runtimeRepo.id }
const worktree = makeWorktree({
id: `${runtimeRepo.id}::${runtimeRepo.path}`,
repoId: runtimeRepo.id,
path: runtimeRepo.path,
hostId: 'runtime:env-1'
})
runtimeEnvironmentCall.mockRejectedValue(new Error('runtime unavailable'))
const store = createTestStore()
store.setState({
projects: [project],
projectHostSetups: [attachedSetup],
repos: [runtimeRepo],
worktreesByRepo: { [runtimeRepo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [] },
settings: { activeRuntimeEnvironmentId: null } as never
})
await expect(
store.getState().deleteProjectHostSetup({ setupId: attachedSetup.id })
).rejects.toThrow('runtime unavailable')
expect(store.getState().projects).toEqual([project])
expect(store.getState().projectHostSetups).toEqual([attachedSetup])
expect(store.getState().repos).toEqual([runtimeRepo])
expect(store.getState().worktreesByRepo[runtimeRepo.id]).toEqual([worktree])
expect(store.getState().tabsByWorktree[worktree.id]).toEqual([])
})
it('preserves runtime-fetched setup-only states during repo hydration', async () => {
const pendingSetup: ProjectHostSetup = {
...runtimeSetup,
+1 -1
View File
@@ -3300,7 +3300,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
description: message,
duration: ERROR_TOAST_DURATION
})
return null
throw err
}
},
@@ -487,6 +487,90 @@ describe('fetchWorktrees', () => {
expect(result).toBe(true)
})
it('restores provisioned-root ownership when an older host strips the metadata field', async () => {
const store = createTestStore()
const provisioned = makeWorktree({
id: 'repo1::/workspace/provisioned',
repoId: 'repo1',
path: '/workspace/provisioned',
isMainWorktree: true,
branch: 'refs/heads/feature'
})
const reusable = makeWorktree({
id: 'repo1::/workspace/reusable',
repoId: 'repo1',
path: '/workspace/reusable',
isMainWorktree: true,
branch: 'refs/heads/main'
})
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('repo1', [provisioned, reusable])
)
mockApi.ephemeralVm.listRuntimes.mockResolvedValueOnce([
{
id: 'runtime-provisioned',
workspaceId: provisioned.id,
status: 'running',
recipeResult: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
pairingCode: 'pairing-provisioned',
projectRoot: provisioned.path
}
},
{
id: 'runtime-reusable',
workspaceId: reusable.id,
status: 'running',
recipeResult: {
schemaVersion: 1,
pairingCode: 'pairing-reusable',
projectRoot: reusable.path
}
}
])
await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([
expect.objectContaining({
id: provisioned.id,
ephemeralVmCheckoutMode: 'provisioned-root'
}),
expect.objectContaining({
id: reusable.id
})
])
expect(store.getState().worktreesByRepo.repo1[1]).not.toHaveProperty('ephemeralVmCheckoutMode')
expect(store.getState().detectedWorktreesByRepo.repo1.worktrees[0]).toMatchObject({
id: provisioned.id,
ephemeralVmCheckoutMode: 'provisioned-root'
})
})
it('preserves provisioned-root ownership across a stripped refresh when runtime lookup fails', async () => {
const store = createTestStore()
const existing = makeWorktree({
id: 'repo1::/workspace/provisioned',
repoId: 'repo1',
path: '/workspace/provisioned',
isMainWorktree: true,
branch: 'refs/heads/feature',
ephemeralVmCheckoutMode: 'provisioned-root'
})
const stripped = { ...existing, ephemeralVmCheckoutMode: undefined }
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [stripped]))
mockApi.ephemeralVm.listRuntimes.mockRejectedValueOnce(new Error('older desktop API'))
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
id: existing.id,
ephemeralVmCheckoutMode: 'provisioned-root'
})
})
it('retains catalog and row references for a cloned unchanged runtime payload', async () => {
const store = createTestStore()
const first = makeWorktree({
+67 -10
View File
@@ -40,6 +40,7 @@ import {
} from './stale-runtime-host-rows'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { getEphemeralVmRecipeResultCheckoutMode } from '../../../../shared/ephemeral-vm-recipes'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry'
import {
@@ -2835,6 +2836,44 @@ function preserveConcurrentManualOrder<T extends Worktree>(
})
}
async function listProvisionedRootRuntimeWorkspaceIds(): Promise<ReadonlySet<string>> {
try {
const runtimes = await window.api.ephemeralVm.listRuntimes()
return new Set(
runtimes
.filter(
(runtime) =>
runtime.status !== 'cleaned' &&
getEphemeralVmRecipeResultCheckoutMode(runtime.recipeResult) === 'provisioned-root' &&
runtime.workspaceId
)
.map((runtime) => runtime.workspaceId as string)
)
} catch {
return new Set()
}
}
function restoreProvisionedRootCheckoutModes<T extends Worktree>(
incoming: readonly T[],
current: readonly Worktree[] | undefined,
runtimeWorkspaceIds: ReadonlySet<string> | undefined
): T[] {
const currentModes = new Map(
current?.map((worktree) => [worktree.id, worktree.ephemeralVmCheckoutMode])
)
return incoming.map((worktree) => {
if (
worktree.ephemeralVmCheckoutMode ||
(currentModes.get(worktree.id) !== 'provisioned-root' &&
!runtimeWorkspaceIds?.has(worktree.id))
) {
return worktree
}
return { ...worktree, ephemeralVmCheckoutMode: 'provisioned-root' }
})
}
type FencedWorktreeMergeArgs = {
repoId: string
hostId: ExecutionHostId
@@ -2843,6 +2882,7 @@ type FencedWorktreeMergeArgs = {
requestStartedWorktrees: readonly Worktree[] | undefined
setup?: ProjectHostSetup
refresh: AdmittedDetectedWorktreeRefresh
provisionedRootRuntimeWorkspaceIds?: ReadonlySet<string>
purgeRemovedWorktrees?: boolean
}
@@ -2871,11 +2911,15 @@ function mergeFetchedWorktrees(
const currentWorktrees = s.worktreesByRepo[args.repoId]
const refreshResult = {
...args.refresh.result,
worktrees: preserveConcurrentManualOrder(
args.refresh.result.worktrees,
args.requestStartedWorktrees,
worktrees: restoreProvisionedRootCheckoutModes(
preserveConcurrentManualOrder(
args.refresh.result.worktrees,
args.requestStartedWorktrees,
currentWorktrees,
(worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions)
),
currentWorktrees,
(worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions)
args.provisionedRootRuntimeWorkspaceIds
)
}
let incoming = toVisibleWorktrees(refreshResult, args.hostId, args.setup)
@@ -3131,6 +3175,7 @@ async function runKnownSshWorktreeFetch(
worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id))
}
: result.result
const provisionedRootRuntimeWorkspaceIds = await listProvisionedRootRuntimeWorkspaceIds()
let admitted = false
set((state) => {
// Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees.
@@ -3143,8 +3188,11 @@ async function runKnownSshWorktreeFetch(
admitted = true
const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId)
const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId)
const incomingDetected = known.worktrees.map((worktree) =>
withRepoHostOwnership(worktree, executionHostId, setup)
const currentWorktrees = state.worktreesByRepo[repoId]
const incomingDetected = restoreProvisionedRootCheckoutModes(
known.worktrees.map((worktree) => withRepoHostOwnership(worktree, executionHostId, setup)),
currentWorktrees,
provisionedRootRuntimeWorkspaceIds
)
const priorDetected = state.detectedWorktreesByRepo[repoId]
// Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's
@@ -3159,8 +3207,12 @@ async function runKnownSshWorktreeFetch(
)
}
const worktrees = appendMissingWorktreesForHost(
state.worktreesByRepo[repoId],
toVisibleWorktrees(known, executionHostId, setup),
currentWorktrees,
restoreProvisionedRootCheckoutModes(
toVisibleWorktrees(known, executionHostId, setup),
currentWorktrees,
provisionedRootRuntimeWorkspaceIds
),
executionHostId,
matchOptions
)
@@ -3383,6 +3435,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const directCallerAuthority =
options && 'directSshAuthority' in options ? options.directSshAuthority : undefined
try {
const provisionedRootRuntimeWorkspaceIdsPromise = listProvisionedRootRuntimeWorkspaceIds()
const ownerState = get()
const requestStartedWorktrees = ownerState.worktreesByRepo[repoId]
const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId)
@@ -3448,7 +3501,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: undefined,
requestStartedWorktrees,
setup,
refresh
refresh,
provisionedRootRuntimeWorkspaceIds: await provisionedRootRuntimeWorkspaceIdsPromise
})
if (!admitted) {
return directCallerAuthority
@@ -3472,6 +3526,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
fetchAllWorktrees: async (options) => {
const { repos } = get()
const provisionedRootRuntimeWorkspaceIds = await listProvisionedRootRuntimeWorkspaceIds()
// Why: after the one-shot hydration purge, later calls only refresh cached lists — no IPC double-probe for the per-repo success signal.
if (get().hasHydratedWorktreePurge) {
@@ -3507,7 +3562,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
ownerWasMissingAtStart: false,
requestStartedWorktrees,
setup,
refresh
refresh,
provisionedRootRuntimeWorkspaceIds
})
} catch (err) {
if (notifyRuntimeScopeForbiddenIfNeeded(err)) {
@@ -3564,6 +3620,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
requestStartedWorktrees,
setup,
refresh,
provisionedRootRuntimeWorkspaceIds,
purgeRemovedWorktrees: false
})
if (!admitted) {