mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
fix(vm): harden provisioned root compatibility UX
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user