mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(vm): create workspaces from provisioned SSH roots (#14359)
* feat(vm): use recipe-provisioned SSH roots * fix(vm): preserve ordinary create failure timing * test(vm): prepare provisioned root SSH fixture * ci(vm): enable SSH setup for provisioned root E2E
This commit is contained in:
@@ -240,7 +240,11 @@ jobs:
|
||||
echo "Changed startup-readiness specs are owned by the dedicated live lane."
|
||||
exit 0
|
||||
fi
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \
|
||||
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
|
||||
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
|
||||
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
|
||||
fi
|
||||
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
|
||||
@@ -40,6 +40,11 @@ in the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no se
|
||||
`connection.type:"ssh"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`
|
||||
output shape and half the templates.
|
||||
|
||||
Keep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and
|
||||
let Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly
|
||||
wants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires
|
||||
direct SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.
|
||||
|
||||
**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,
|
||||
git auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the
|
||||
base-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire
|
||||
@@ -60,6 +65,8 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
|
||||
- **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs
|
||||
`orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to
|
||||
the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.
|
||||
- **Checkout ownership:** do not ask by default. Only when the user requires the environment to
|
||||
create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.
|
||||
- **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also
|
||||
ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or
|
||||
`<cli> --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.
|
||||
@@ -462,6 +469,24 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
|
||||
|
||||
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
|
||||
|
||||
For an explicitly requested one-VM-per-workspace checkout, the create script must read
|
||||
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
|
||||
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"checkoutMode": "provisioned-root",
|
||||
"connection": {
|
||||
"type": "ssh",
|
||||
"projectRoot": "/abs/repo",
|
||||
"target": { "label": "my-box", "host": "192.0.2.10", "port": 22, "username": "ubuntu" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
|
||||
|
||||
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
|
||||
`orca serve` URL in SSH mode):
|
||||
|
||||
@@ -618,6 +643,11 @@ and `userData` are optional.
|
||||
**SSH mode** — do **not** run `orca serve`; print the `connection.type:"ssh"` block instead (full shape +
|
||||
worked script in §7g). `pairingCode` is **not** used in SSH mode.
|
||||
|
||||
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
|
||||
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
|
||||
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
|
||||
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
|
||||
|
||||
Lifecycle hooks (all run locally):
|
||||
|
||||
- `create`: required. Prints recipe result JSON.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-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.
|
||||
@@ -24,13 +25,16 @@ 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 hostId = typeof modalData.hostId === 'string' ? (modalData.hostId as ExecutionHostId) : null
|
||||
|
||||
// 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
|
||||
// removed-target label when it's a ghost) so the user knows where it remains
|
||||
// and that re-adding that host recovers it.
|
||||
const sshHostLabel = useAppStore((s) => {
|
||||
const connectionId = s.repos.find((r) => r.id === repoId)?.connectionId?.trim()
|
||||
const connectionId = s.repos
|
||||
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
|
||||
?.connectionId?.trim()
|
||||
if (!connectionId) {
|
||||
return null
|
||||
}
|
||||
@@ -59,10 +63,13 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (repoId) {
|
||||
void removeProject(repoId, { errorFeedback: 'toast' })
|
||||
void removeProject(repoId, {
|
||||
...(hostId ? { hostId } : {}),
|
||||
errorFeedback: 'toast'
|
||||
})
|
||||
}
|
||||
closeModal()
|
||||
}, [closeModal, removeProject, repoId])
|
||||
}, [closeModal, hostId, removeProject, repoId])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
||||
@@ -6176,7 +6176,8 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
(repo: Repo) => {
|
||||
openModal('confirm-remove-folder', {
|
||||
repoId: repo.id,
|
||||
displayName: repo.displayName
|
||||
displayName: repo.displayName,
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
})
|
||||
},
|
||||
[openModal]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
/** Keeps provisioned roots visible because they are the recipe-created workspace, not a source-repo row. */
|
||||
export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
|
||||
return (
|
||||
worktree.isMainWorktree &&
|
||||
worktree.branch.trim() !== '' &&
|
||||
worktree.ephemeralVmCheckoutMode !== 'provisioned-root'
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,10 @@ const mocks = vi.hoisted(() => {
|
||||
path: string
|
||||
displayName: string
|
||||
isMainWorktree: boolean
|
||||
hostId?: string
|
||||
}
|
||||
>(),
|
||||
repos: [] as { id: string; displayName: string }[],
|
||||
repos: [] as { id: string; displayName: string; connectionId?: string }[],
|
||||
worktreeLineageById: {},
|
||||
allWorktrees: () => Array.from(state.worktreeMap.values()),
|
||||
clearWorktreeDeleteState: vi.fn((worktreeId: string) => {
|
||||
@@ -88,6 +89,7 @@ function setWorktrees(
|
||||
path?: string
|
||||
displayName?: string
|
||||
isMainWorktree?: boolean
|
||||
hostId?: string
|
||||
}[]
|
||||
): void {
|
||||
mocks.state.worktreeMap = new Map(
|
||||
@@ -99,7 +101,8 @@ function setWorktrees(
|
||||
repoId: worktree.repoId ?? 'repo-1',
|
||||
path: worktree.path ?? `/workspaces/${worktree.id}`,
|
||||
displayName: worktree.displayName ?? worktree.id,
|
||||
isMainWorktree: worktree.isMainWorktree ?? false
|
||||
isMainWorktree: worktree.isMainWorktree ?? false,
|
||||
...(worktree.hostId ? { hostId: worktree.hostId } : {})
|
||||
}
|
||||
])
|
||||
)
|
||||
@@ -493,7 +496,33 @@ describe('delete worktree flow', () => {
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-remove-folder', {
|
||||
repoId: 'repo-1',
|
||||
displayName: 'orca'
|
||||
displayName: 'orca',
|
||||
hostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes primary workspace removal to its exact SSH host', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([
|
||||
{
|
||||
id: 'main',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'main',
|
||||
isMainWorktree: true,
|
||||
hostId: 'ssh:runtime-ssh-one'
|
||||
}
|
||||
])
|
||||
mocks.state.repos = [
|
||||
{ id: 'repo-1', displayName: 'local orca' },
|
||||
{ id: 'repo-1', displayName: 'provisioned orca', connectionId: 'runtime-ssh-one' }
|
||||
]
|
||||
|
||||
runWorktreeDelete('main')
|
||||
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-remove-folder', {
|
||||
repoId: 'repo-1',
|
||||
displayName: 'provisioned orca',
|
||||
hostId: 'ssh:runtime-ssh-one'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
resolveWorktreeBatchDeleteTargets,
|
||||
toWorktreeDeleteIdentities,
|
||||
@@ -44,11 +45,16 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
return
|
||||
}
|
||||
if (target.isMainWorktree) {
|
||||
const repo = state.repos.find((entry) => entry.id === target.repoId)
|
||||
const repo = findRepoForHost(state.repos, target.repoId, {
|
||||
hostId: target.hostId,
|
||||
settings: state.settings
|
||||
})
|
||||
const hostId = repo ? getRepoExecutionHostId(repo) : target.hostId
|
||||
// Why: git refuses to delete the primary checkout; users can still remove the owning project from Orca (disk contents kept).
|
||||
state.openModal('confirm-remove-folder', {
|
||||
repoId: target.repoId,
|
||||
displayName: repo?.displayName ?? target.displayName
|
||||
displayName: repo?.displayName ?? target.displayName,
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ describe('isDefaultBranchWorkspace', () => {
|
||||
const feature = makeWorktree('feature')
|
||||
expect(isDefaultBranchWorkspace(feature)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a provisioned root visible as the recipe-created workspace', () => {
|
||||
const provisionedRoot = makeWorktree('provisioned-root')
|
||||
provisionedRoot.isMainWorktree = true
|
||||
provisionedRoot.ephemeralVmCheckoutMode = 'provisioned-root'
|
||||
expect(isDefaultBranchWorkspace(provisionedRoot)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebarHasActiveFilters', () => {
|
||||
|
||||
@@ -26,19 +26,9 @@ import {
|
||||
getPairedDeviceIdsByEnvironment,
|
||||
isWorkspaceFromOtherDevice
|
||||
} from './workspace-creator-visibility'
|
||||
import { isDefaultBranchWorkspace } from './default-branch-workspace'
|
||||
|
||||
/**
|
||||
* Whether a worktree represents the repo's default-branch row that the
|
||||
* "Hide Default Branch Workspace" setting targets. Folder-mode projects are
|
||||
* main worktrees with branch === '' and are intentionally preserved.
|
||||
*
|
||||
* Why a shared helper: this predicate gates visibility in both the sidebar
|
||||
* pipeline (computeVisibleWorktreeIds) and the Cmd+J jump palette. Keeping
|
||||
* the definition in one place prevents the two surfaces from drifting.
|
||||
*/
|
||||
export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
|
||||
return worktree.isMainWorktree && worktree.branch.trim() !== ''
|
||||
}
|
||||
export { isDefaultBranchWorkspace } from './default-branch-workspace'
|
||||
|
||||
/**
|
||||
* Whether the "Hide sleeping" sweep must keep this row (#8873).
|
||||
|
||||
@@ -4477,10 +4477,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
if (vmRecipeTrustDecision === 'skip') {
|
||||
return
|
||||
}
|
||||
const selectedRecipe = ephemeralVmRecipes.find(
|
||||
(recipe) => recipe.id === activeEphemeralVmRecipeId
|
||||
)
|
||||
ephemeralVmRecipe = {
|
||||
sourceRepoId: repoId,
|
||||
recipeId: activeEphemeralVmRecipeId,
|
||||
projectId: selectedWorkspaceTarget.target.projectId
|
||||
projectId: selectedWorkspaceTarget.target.projectId,
|
||||
...(selectedRecipe?.checkoutMode ? { checkoutMode: selectedRecipe.checkoutMode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4630,6 +4634,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
setupConfig,
|
||||
setupPolicy,
|
||||
selectedRepoHookContextKey,
|
||||
ephemeralVmRecipes,
|
||||
isProjectGroupTarget,
|
||||
submitFolderTarget,
|
||||
createMultiple,
|
||||
|
||||
@@ -717,7 +717,8 @@
|
||||
"wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace"
|
||||
},
|
||||
"ephemeralVmWorkspaceTarget": {
|
||||
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime."
|
||||
"projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime.",
|
||||
"provisionedRootRequiresSsh": "Provisioned-root recipes currently require a direct SSH connection."
|
||||
},
|
||||
"blocked": {
|
||||
"notification": {
|
||||
@@ -789,6 +790,9 @@
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ephemeralVmWorktreeCreation": {
|
||||
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import { cleanupFailedEphemeralVmWorkspace } from './ephemeral-vm-failed-create-cleanup'
|
||||
|
||||
function request(): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:runtime-ssh-1',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('cleanupFailedEphemeralVmWorkspace', () => {
|
||||
it('removes the imported project setup before destroying its VM', async () => {
|
||||
const order: string[] = []
|
||||
await cleanupFailedEphemeralVmWorkspace(request(), {
|
||||
deleteProjectHostSetup: vi.fn(async () => {
|
||||
order.push('setup')
|
||||
}),
|
||||
cleanupRuntime: vi.fn(async () => {
|
||||
order.push('runtime')
|
||||
}),
|
||||
reportSetupError: vi.fn(),
|
||||
reportRuntimeError: vi.fn()
|
||||
})
|
||||
|
||||
expect(order).toEqual(['setup', 'runtime'])
|
||||
})
|
||||
|
||||
it('still destroys the VM when setup deletion fails', async () => {
|
||||
const cleanupRuntime = vi.fn().mockResolvedValue(undefined)
|
||||
const reportSetupError = vi.fn()
|
||||
await cleanupFailedEphemeralVmWorkspace(request(), {
|
||||
deleteProjectHostSetup: vi.fn().mockRejectedValue(new Error('setup delete failed')),
|
||||
cleanupRuntime,
|
||||
reportSetupError,
|
||||
reportRuntimeError: vi.fn()
|
||||
})
|
||||
|
||||
expect(reportSetupError).toHaveBeenCalledOnce()
|
||||
expect(cleanupRuntime).toHaveBeenCalledWith('runtime-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
|
||||
type FailedCreateCleanupActions = {
|
||||
deleteProjectHostSetup: (setupId: string) => Promise<unknown>
|
||||
cleanupRuntime: (runtimeId: string) => Promise<unknown>
|
||||
reportSetupError: (error: unknown) => void
|
||||
reportRuntimeError: (error: unknown) => void
|
||||
}
|
||||
|
||||
export async function cleanupFailedEphemeralVmWorkspace(
|
||||
request: WorktreeCreationRequest,
|
||||
actions: FailedCreateCleanupActions
|
||||
): Promise<void> {
|
||||
if (!request.ephemeralVmRuntimeId) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
request.ephemeralVmCheckoutMode === 'provisioned-root' &&
|
||||
request.workspaceRunContext?.projectHostSetupId
|
||||
) {
|
||||
try {
|
||||
await actions.deleteProjectHostSetup(request.workspaceRunContext.projectHostSetupId)
|
||||
} catch (error) {
|
||||
actions.reportSetupError(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await actions.cleanupRuntime(request.ephemeralVmRuntimeId)
|
||||
} catch (error) {
|
||||
actions.reportRuntimeError(error)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
|
||||
setup: { ...setupResult.setup, hostId: 'runtime:env-1' }
|
||||
},
|
||||
runtimeId: 'runtime-1',
|
||||
checkoutMode: 'orca-worktree',
|
||||
environmentId: 'env-1',
|
||||
stderr: 'creating sandbox',
|
||||
warnings: []
|
||||
@@ -163,12 +164,64 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
|
||||
setup: { ...setupResult.setup, hostId: 'ssh:runtime-ssh-runtime-1' }
|
||||
},
|
||||
runtimeId: 'runtime-1',
|
||||
checkoutMode: 'orca-worktree',
|
||||
stderr: 'creating sandbox',
|
||||
warnings: []
|
||||
})
|
||||
expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects and cleans up an Orca-server provisioned root before project import', async () => {
|
||||
vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({
|
||||
ok: true,
|
||||
connectionType: 'orca-server',
|
||||
stderr: 'creating sandbox',
|
||||
warnings: [],
|
||||
environment: {
|
||||
id: 'env-1',
|
||||
name: 'Repo VM',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [{ id: 'ws-env-1', kind: 'websocket', label: 'WebSocket', endpoint: 'wss://x' }],
|
||||
preferredEndpointId: 'ws-env-1'
|
||||
},
|
||||
runtime: {
|
||||
id: 'runtime-1',
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
status: 'running',
|
||||
cleanupStatus: 'not_started',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
recipeResult: {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
pairingCode: 'orca://pair?code=test',
|
||||
projectRoot: '/workspace/repo'
|
||||
}
|
||||
}
|
||||
})
|
||||
const setupExistingFolder = vi.fn()
|
||||
|
||||
const result = await prepareEphemeralVmWorkspaceTarget({
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
projectId: 'project-1',
|
||||
workspaceName: 'Fix Login Race',
|
||||
setupExistingFolder
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Provisioned-root recipes currently require a direct SSH connection.'
|
||||
})
|
||||
expect(window.api.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
|
||||
expect(setupExistingFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the runtime when required project setup capability is missing', async () => {
|
||||
vi.mocked(assertRuntimeEnvironmentCapability).mockRejectedValue(
|
||||
new Error('The recipe-created Orca server does not support project setup.')
|
||||
|
||||
@@ -3,7 +3,10 @@ import type {
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult
|
||||
} from '../../../shared/types'
|
||||
import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes'
|
||||
import {
|
||||
getEphemeralVmRecipeResultCheckoutMode,
|
||||
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 { translate } from '@/i18n/i18n'
|
||||
@@ -14,6 +17,8 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = {
|
||||
recipeId: string
|
||||
projectId: string
|
||||
workspaceName: string
|
||||
branch?: string
|
||||
ref?: string
|
||||
provisionId?: string
|
||||
setupExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
@@ -25,6 +30,7 @@ export type PrepareEphemeralVmWorkspaceTargetResult =
|
||||
ok: true
|
||||
setup: ProjectHostSetupResult
|
||||
runtimeId: string
|
||||
checkoutMode: 'orca-worktree' | 'provisioned-root'
|
||||
environmentId?: string
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
@@ -43,12 +49,27 @@ export async function prepareEphemeralVmWorkspaceTarget(
|
||||
recipeId: args.recipeId,
|
||||
projectId: args.projectId,
|
||||
workspaceName: args.workspaceName,
|
||||
...(args.branch ? { branch: args.branch } : {}),
|
||||
...(args.ref ? { ref: args.ref } : {}),
|
||||
...(args.provisionId ? { provisionId: args.provisionId } : {})
|
||||
})
|
||||
if (!provisioned.ok) {
|
||||
return { ok: false, error: provisioned.error, stderr: provisioned.stderr }
|
||||
}
|
||||
|
||||
const checkoutMode = getEphemeralVmRecipeResultCheckoutMode(provisioned.runtime.recipeResult)
|
||||
if (checkoutMode === 'provisioned-root' && provisioned.connectionType !== 'ssh') {
|
||||
await cleanupProvisionedRuntime(provisioned.runtime.id)
|
||||
return {
|
||||
ok: false,
|
||||
error: translate(
|
||||
'auto.lib.ephemeralVmWorkspaceTarget.provisionedRootRequiresSsh',
|
||||
'Provisioned-root recipes currently require a direct SSH connection.'
|
||||
),
|
||||
stderr: provisioned.stderr
|
||||
}
|
||||
}
|
||||
|
||||
const hostId =
|
||||
provisioned.connectionType === 'ssh'
|
||||
? toSshExecutionHostId(provisioned.sshTargetId)
|
||||
@@ -112,6 +133,7 @@ export async function prepareEphemeralVmWorkspaceTarget(
|
||||
ok: true,
|
||||
setup,
|
||||
runtimeId: provisioned.runtime.id,
|
||||
checkoutMode,
|
||||
stderr: provisioned.stderr,
|
||||
warnings: provisioned.warnings
|
||||
} satisfies PrepareEphemeralVmWorkspaceTargetResult
|
||||
|
||||
@@ -4,6 +4,8 @@ import { prepareEphemeralVmWorkspaceTarget } from '@/lib/ephemeral-vm-workspace-
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import { getProjectIdentityKey } from '../../../shared/project-host-setup-projection'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
|
||||
|
||||
const MAX_PROVISIONING_LOG_CHARS = 12_000
|
||||
|
||||
@@ -15,6 +17,13 @@ export async function prepareRequestForCreate(
|
||||
return request
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
if (request.ephemeralVmRecipe.checkoutMode === 'provisioned-root' && request.sparseCheckout) {
|
||||
store.updatePendingWorktreeCreation(creationId, {
|
||||
status: 'error',
|
||||
error: getProvisionedRootSparseCheckoutError()
|
||||
})
|
||||
return null
|
||||
}
|
||||
store.updatePendingWorktreeCreation(creationId, {
|
||||
phase: 'provisioning-vm',
|
||||
provisioningLog: ''
|
||||
@@ -36,6 +45,12 @@ export async function prepareRequestForCreate(
|
||||
projectId:
|
||||
resolvePortableEphemeralVmProjectId(sourceRepo) ?? request.ephemeralVmRecipe.projectId,
|
||||
workspaceName: request.name,
|
||||
...(request.ephemeralVmRecipe.checkoutMode === 'provisioned-root'
|
||||
? {
|
||||
branch: request.branchNameOverride ?? request.name,
|
||||
...(request.baseBranch ? { ref: request.baseBranch } : {})
|
||||
}
|
||||
: {}),
|
||||
provisionId: creationId,
|
||||
setupExistingFolder: store.setupProjectExistingFolder
|
||||
})
|
||||
@@ -59,8 +74,11 @@ export async function prepareRequestForCreate(
|
||||
const preparedRequest: WorktreeCreationRequest = {
|
||||
...request,
|
||||
repoId: preparedTarget.setup.repo.id,
|
||||
...getEphemeralVmPortableBaseSelection(request),
|
||||
...(preparedTarget.checkoutMode === 'provisioned-root'
|
||||
? { baseBranch: request.baseBranch, compareBaseRef: request.compareBaseRef }
|
||||
: getEphemeralVmPortableBaseSelection(request)),
|
||||
ephemeralVmRuntimeId: preparedTarget.runtimeId,
|
||||
ephemeralVmCheckoutMode: preparedTarget.checkoutMode,
|
||||
...(preparedTarget.environmentId
|
||||
? { ephemeralVmRuntimeEnvironmentId: preparedTarget.environmentId }
|
||||
: {}),
|
||||
@@ -142,7 +160,7 @@ export async function attachEphemeralVmRuntimeToWorkspace(
|
||||
request: WorktreeCreationRequest,
|
||||
workspaceId: string
|
||||
): Promise<void> {
|
||||
if (!request.ephemeralVmRuntimeId) {
|
||||
if (!request.ephemeralVmRuntimeId || request.ephemeralVmCheckoutMode === 'provisioned-root') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -175,12 +193,22 @@ function resolvePortableEphemeralVmProjectId(repo: Repo | undefined): string | n
|
||||
export async function cleanupEphemeralVmRuntimeForFailedCreate(
|
||||
request: WorktreeCreationRequest
|
||||
): Promise<void> {
|
||||
if (!request.ephemeralVmRuntimeId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.api.ephemeralVm.cleanup({ runtimeId: request.ephemeralVmRuntimeId })
|
||||
} catch (error) {
|
||||
console.error('Failed to clean up ephemeral VM runtime after workspace creation failed:', error)
|
||||
}
|
||||
await cleanupFailedEphemeralVmWorkspace(request, {
|
||||
deleteProjectHostSetup: (setupId) => useAppStore.getState().deleteProjectHostSetup({ setupId }),
|
||||
cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }),
|
||||
reportSetupError: (error) =>
|
||||
console.error('Failed to remove provisioned-root project setup:', error),
|
||||
reportRuntimeError: (error) =>
|
||||
console.error(
|
||||
'Failed to clean up ephemeral VM runtime after workspace creation failed:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getProvisionedRootSparseCheckoutError(): string {
|
||||
return translate(
|
||||
'auto.lib.ephemeralVmWorktreeCreation.sparseCheckoutUnsupported',
|
||||
'Provisioned-root recipes do not support sparse checkout.'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,12 +45,15 @@ export type WorktreeCreationRequest = {
|
||||
/** Runtime environment created from the VM's pairing code. Used to refresh
|
||||
* live status immediately after the workspace takes ownership. */
|
||||
ephemeralVmRuntimeEnvironmentId?: string
|
||||
/** Checkout ownership selected by the provisioned recipe. */
|
||||
ephemeralVmCheckoutMode?: 'orca-worktree' | 'provisioned-root'
|
||||
/** Recipe to provision before creating the worktree. Kept serializable so
|
||||
* retry can rerun the recipe after a failed create. */
|
||||
ephemeralVmRecipe?: {
|
||||
sourceRepoId: string
|
||||
recipeId: string
|
||||
projectId: string
|
||||
checkoutMode?: 'orca-worktree' | 'provisioned-root'
|
||||
}
|
||||
/** Captured from the repo/run owner at submit time so Retry keeps the same
|
||||
* local-vs-runtime progress behavior even if the focused runtime changes. */
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
import { getProvisionedRootCreateOptions } from './provisioned-root-create-options'
|
||||
|
||||
function request(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getProvisionedRootCreateOptions', () => {
|
||||
it('leaves ordinary workspace creation unchanged', () => {
|
||||
expect(getProvisionedRootCreateOptions(request())).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the main-owned adoption identity', () => {
|
||||
expect(
|
||||
getProvisionedRootCreateOptions(
|
||||
request({
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:runtime-ssh-one',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-runtime',
|
||||
path: '/workspace/repo'
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
runtimeId: 'runtime-1',
|
||||
executionHostId: 'ssh:runtime-ssh-one',
|
||||
expectedPath: '/workspace/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects incomplete provisioned-root identity', () => {
|
||||
expect(() =>
|
||||
getProvisionedRootCreateOptions(
|
||||
request({ ephemeralVmCheckoutMode: 'provisioned-root', ephemeralVmRuntimeId: 'runtime-1' })
|
||||
)
|
||||
).toThrow('identity is incomplete')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
|
||||
export type ProvisionedRootCreateOptions = {
|
||||
runtimeId: string
|
||||
executionHostId: NonNullable<WorktreeCreationRequest['workspaceRunContext']>['hostId']
|
||||
expectedPath: string
|
||||
}
|
||||
|
||||
export function getProvisionedRootCreateOptions(
|
||||
request: WorktreeCreationRequest
|
||||
): ProvisionedRootCreateOptions | null {
|
||||
if (request.ephemeralVmCheckoutMode !== 'provisioned-root') {
|
||||
return null
|
||||
}
|
||||
if (!request.ephemeralVmRuntimeId || !request.workspaceRunContext) {
|
||||
throw new Error('Provisioned-root workspace identity is incomplete.')
|
||||
}
|
||||
return {
|
||||
runtimeId: request.ephemeralVmRuntimeId,
|
||||
executionHostId: request.workspaceRunContext.hostId,
|
||||
expectedPath: request.workspaceRunContext.path
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
cleanupEphemeralVmRuntimeForFailedCreate,
|
||||
prepareRequestForCreate
|
||||
} from '@/lib/ephemeral-vm-worktree-creation'
|
||||
import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-options'
|
||||
import {
|
||||
formatWorkspaceCreateError,
|
||||
getWorkspaceCreateErrorToastMessage
|
||||
@@ -105,7 +106,8 @@ async function executeWorktreeCreation(
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
const backendStartup = resolveBackendDraftStartup(preparedRequest)
|
||||
const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest)
|
||||
const backendStartup = provisionedRoot ? undefined : resolveBackendDraftStartup(preparedRequest)
|
||||
result = await useAppStore
|
||||
.getState()
|
||||
.createWorktree(
|
||||
@@ -144,7 +146,8 @@ async function executeWorktreeCreation(
|
||||
// Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell.
|
||||
...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt
|
||||
? { startupDraft: preparedRequest.launchDraftPrompt }
|
||||
: {})
|
||||
: {}),
|
||||
...(provisionedRoot ? { provisionedRoot } : {})
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -153,7 +156,9 @@ async function executeWorktreeCreation(
|
||||
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
|
||||
return
|
||||
}
|
||||
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
|
||||
if (preparedRequest.ephemeralVmRuntimeId) {
|
||||
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
|
||||
}
|
||||
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
|
||||
// Why: an error must stay on the same creation surface that owns the faux
|
||||
// tab strip, rather than falling back to stale previous-workspace tabs.
|
||||
@@ -171,11 +176,11 @@ async function executeWorktreeCreation(
|
||||
}
|
||||
|
||||
const worktree = result.worktree
|
||||
// Why: if the user dismissed/cancelled while the create was in flight, the entry
|
||||
// is gone. Git already made the worktree on disk, but don't auto-provision (trust
|
||||
// write, terminal, agent, note) work they abandoned — it surfaces as a plain row
|
||||
// via worktrees:changed and provisions lazily on first open.
|
||||
// Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM.
|
||||
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
|
||||
if (preparedRequest.ephemeralVmRuntimeId) {
|
||||
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
|
||||
|
||||
@@ -3399,10 +3399,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project host setup:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
toast.error(
|
||||
translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'),
|
||||
{
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
}
|
||||
)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -209,6 +209,11 @@ export type WorktreeSlice = {
|
||||
linkedTaskSourceContext?: TaskSourceContext | null
|
||||
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
|
||||
startupDraft?: string
|
||||
provisionedRoot?: {
|
||||
runtimeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
expectedPath: string
|
||||
}
|
||||
}
|
||||
) => Promise<CreateWorktreeResult>
|
||||
/** Register an in-flight background creation and make it the active surface. */
|
||||
|
||||
@@ -122,6 +122,7 @@ const forgetRemovedForExecutionHostMock = vi.fn<
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
create: vi.fn(),
|
||||
adoptProvisionedRoot: vi.fn(),
|
||||
prefetchCreateBase: vi.fn().mockResolvedValue(undefined),
|
||||
list: worktreeListMock,
|
||||
listDetected: listDetectedMock,
|
||||
@@ -197,6 +198,8 @@ function resetRemoteRuntimeMocks() {
|
||||
// earlier describe would silently suppress a row here. Reset for every case, not just the fetch suites.
|
||||
beforeEach(() => {
|
||||
resetAuthoritativelyRemovedWorktreeMemoryForTests()
|
||||
mockApi.worktrees.create.mockReset()
|
||||
mockApi.worktrees.adoptProvisionedRoot.mockReset()
|
||||
})
|
||||
|
||||
function createTestStore() {
|
||||
@@ -4927,6 +4930,69 @@ describe('createWorktree base status merge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts an explicit provisioned root without calling ordinary worktree create', async () => {
|
||||
const store = createTestStore()
|
||||
const adopted = makeWorktree({
|
||||
id: 'repo1::/workspace/repo',
|
||||
repoId: 'repo1',
|
||||
path: '/workspace/repo',
|
||||
hostId: 'ssh:runtime-ssh-runtime-1',
|
||||
isMainWorktree: true,
|
||||
ephemeralVmCheckoutMode: 'provisioned-root'
|
||||
})
|
||||
mockApi.worktrees.adoptProvisionedRoot.mockResolvedValue({ worktree: adopted })
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.createWorktree(
|
||||
'repo1',
|
||||
'feature',
|
||||
undefined,
|
||||
'inherit',
|
||||
undefined,
|
||||
'sidebar',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
provisionedRoot: {
|
||||
runtimeId: 'runtime-1',
|
||||
executionHostId: 'ssh:runtime-ssh-runtime-1',
|
||||
expectedPath: '/workspace/repo'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockApi.worktrees.adoptProvisionedRoot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoId: 'repo1',
|
||||
runtimeId: 'runtime-1',
|
||||
executionHostId: 'ssh:runtime-ssh-runtime-1',
|
||||
expectedPath: '/workspace/repo'
|
||||
})
|
||||
)
|
||||
expect(mockApi.worktrees.create).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo.repo1).toContainEqual(
|
||||
expect.objectContaining({ id: adopted.id, ephemeralVmCheckoutMode: 'provisioned-root' })
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const created = makeWorktree({
|
||||
@@ -10461,21 +10527,34 @@ describe('pending worktree creation state', () => {
|
||||
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removePendingWorktreeCreation cleans up a provisioned VM runtime', () => {
|
||||
it('removePendingWorktreeCreation cleans up a provisioned-root setup and VM runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const deleteProjectHostSetup = vi.mocked(store.getState().deleteProjectHostSetup)
|
||||
store.getState().beginPendingWorktreeCreation(
|
||||
makePendingCreation('c1', {
|
||||
phase: 'fetching',
|
||||
request: {
|
||||
...makePendingCreation('c1').request,
|
||||
ephemeralVmRuntimeId: 'runtime-1'
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:runtime-ssh-1',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
store.getState().removePendingWorktreeCreation('c1')
|
||||
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
|
||||
expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
|
||||
await vi.waitFor(() =>
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
|
||||
)
|
||||
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry'
|
||||
import {
|
||||
@@ -3927,6 +3928,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const linkedWorkItem = options?.linkedWorkItem
|
||||
const linkedTaskSourceContext = options?.linkedTaskSourceContext
|
||||
const startupDraft = options?.startupDraft
|
||||
const provisionedRoot = options?.provisionedRoot
|
||||
try {
|
||||
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(name, attempt)
|
||||
@@ -3991,8 +3993,15 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
'Update the remote runtime to link Jira'
|
||||
)
|
||||
}
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
if (provisionedRoot && target.kind !== 'local') {
|
||||
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
|
||||
}
|
||||
const result = provisionedRoot
|
||||
? await window.api.worktrees.adoptProvisionedRoot({
|
||||
...createArgs,
|
||||
...provisionedRoot
|
||||
})
|
||||
: target.kind === 'local'
|
||||
? await window.api.worktrees.create(createArgs)
|
||||
: await callRuntimeRpc<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
|
||||
target,
|
||||
@@ -4150,34 +4159,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
},
|
||||
|
||||
removePendingWorktreeCreation: (creationId, options) => {
|
||||
let removedEntry: AppState['pendingWorktreeCreations'][string] | undefined
|
||||
set((s) => {
|
||||
const entry = s.pendingWorktreeCreations[creationId]
|
||||
if (!entry) {
|
||||
return {}
|
||||
}
|
||||
const cleanupVm = options?.cleanupVm ?? true
|
||||
if (
|
||||
cleanupVm &&
|
||||
entry.phase === 'provisioning-vm' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.api?.ephemeralVm?.cancelProvision
|
||||
) {
|
||||
void window.api.ephemeralVm.cancelProvision({ provisionId: creationId }).catch(() => {
|
||||
// Best effort: dismissing the pending surface shouldn't block on a finished or unreachable provisioning process.
|
||||
})
|
||||
}
|
||||
if (
|
||||
cleanupVm &&
|
||||
entry.request.ephemeralVmRuntimeId &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.api?.ephemeralVm?.cleanup
|
||||
) {
|
||||
void window.api.ephemeralVm
|
||||
.cleanup({ runtimeId: entry.request.ephemeralVmRuntimeId })
|
||||
.catch(() => {
|
||||
// Best effort: cancellation shouldn't block on provider cleanup; Settings still exposes retry/manual cleanup.
|
||||
})
|
||||
}
|
||||
removedEntry = entry
|
||||
const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations
|
||||
return {
|
||||
pendingWorktreeCreations: rest,
|
||||
@@ -4185,6 +4173,25 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
...(s.activePendingCreationId === creationId ? { activePendingCreationId: null } : {})
|
||||
}
|
||||
})
|
||||
if (!removedEntry || options?.cleanupVm === false || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (removedEntry.phase === 'provisioning-vm' && window.api?.ephemeralVm?.cancelProvision) {
|
||||
void window.api.ephemeralVm
|
||||
.cancelProvision({ provisionId: creationId })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (!removedEntry.request.ephemeralVmRuntimeId || !window.api?.ephemeralVm?.cleanup) {
|
||||
return
|
||||
}
|
||||
void cleanupFailedEphemeralVmWorkspace(removedEntry.request, {
|
||||
deleteProjectHostSetup: (setupId) => get().deleteProjectHostSetup({ setupId }),
|
||||
cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }),
|
||||
reportSetupError: (error) =>
|
||||
console.error('Failed to remove cancelled provisioned-root project setup:', error),
|
||||
reportRuntimeError: (error) =>
|
||||
console.error('Failed to clean up cancelled ephemeral VM runtime:', error)
|
||||
})
|
||||
},
|
||||
|
||||
setActivePendingWorktreeCreation: (creationId) => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
execDockerSshRelayTargetCommand,
|
||||
shellQuote,
|
||||
startDockerSshRelayTarget,
|
||||
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
|
||||
|
||||
test.use({ seedTestRepo: false })
|
||||
|
||||
test('adopts a recipe-provisioned SSH root without creating a linked worktree', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000)
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-'))
|
||||
try {
|
||||
ensureDockerSshRelayImage(process.cwd())
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
seedRecipeRepo(sourceRepo, target)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click()
|
||||
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('combobox', { name: 'Run on' }).click()
|
||||
await orcaPage.getByRole('option', { name: /Per-Workspace Environment/ }).click()
|
||||
await orcaPage
|
||||
.getByRole('listbox', { name: 'Per-Workspace Environment' })
|
||||
.getByText('Docker provisioned root', { exact: true })
|
||||
.click()
|
||||
|
||||
const workspaceName = `provisioned-root-${Date.now()}`
|
||||
await dialog.getByPlaceholder(/Type a name/i).fill(workspaceName)
|
||||
await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
|
||||
const trustDialog = orcaPage.getByRole('dialog', { name: /Run VM recipe/ })
|
||||
await expect(trustDialog).toBeVisible()
|
||||
await trustDialog.getByRole('button', { name: 'Run hooks' }).click()
|
||||
|
||||
await expect(dialog).toBeHidden({ timeout: 60_000 })
|
||||
await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({
|
||||
timeout: 60_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 }
|
||||
)
|
||||
expect(adopted).toMatchObject({
|
||||
path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
isMainWorktree: true,
|
||||
ephemeralVmCheckoutMode: 'provisioned-root'
|
||||
})
|
||||
expect(adopted?.hostId).toMatch(/^ssh:runtime-ssh-/)
|
||||
expect(
|
||||
execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} worktree list --porcelain | grep -c '^worktree '`
|
||||
)
|
||||
).toBe('1')
|
||||
expect(
|
||||
execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} branch --show-current`
|
||||
)
|
||||
).toBe(workspaceName)
|
||||
|
||||
await orcaPage
|
||||
.getByRole('option', { name: new RegExp(workspaceName) })
|
||||
.click({ button: 'right' })
|
||||
await orcaPage.getByRole('menuitem', { name: 'Remove Project from Orca' }).click()
|
||||
const removeDialog = orcaPage.getByRole('dialog', { name: 'Remove Project' })
|
||||
await expect(removeDialog).toBeVisible()
|
||||
await removeDialog.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
(repoId) => window.__store!.getState().repos.some((repo) => repo.id === repoId),
|
||||
adopted!.repoId
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(false)
|
||||
expect(() => execDockerSshRelayTargetCommand(target, 'true')).toThrow()
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
rmSync(sourceRepo, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function addRecipeRepo(page: Parameters<typeof waitForSessionReady>[0], repoPath: string) {
|
||||
return page.evaluate(async (pathValue) => {
|
||||
const result = await window.api.repos.add({ path: pathValue })
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
const store = window.__store!
|
||||
await store.getState().fetchRepos()
|
||||
await store.getState().updateSettings({ experimentalEphemeralVms: true })
|
||||
store.getState().setActiveRepo(result.repo.id)
|
||||
return result.repo.id
|
||||
}, repoPath)
|
||||
}
|
||||
|
||||
function seedRecipeRepo(repoPath: string, target: DockerSshRelayTarget): void {
|
||||
const createScript = path.join(repoPath, 'create.sh')
|
||||
const destroyScript = path.join(repoPath, 'destroy.sh')
|
||||
writeFileSync(
|
||||
createScript,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[ "\${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-}" = 2 ]
|
||||
[ -n "\${ORCA_REPO_URL:-}" ]
|
||||
[ -n "\${ORCA_REPO_BRANCH:-}" ]
|
||||
docker exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" >&2
|
||||
node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} ${shellQuote(target.host)} ${target.port} ${shellQuote(target.identityFile)}
|
||||
`
|
||||
)
|
||||
writeFileSync(
|
||||
destroyScript,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat >/dev/null
|
||||
docker rm -f ${shellQuote(target.containerName)} >/dev/null
|
||||
`
|
||||
)
|
||||
chmodSync(createScript, 0o755)
|
||||
chmodSync(destroyScript, 0o755)
|
||||
writeFileSync(
|
||||
path.join(repoPath, 'orca.yaml'),
|
||||
`environmentRecipes:
|
||||
- id: docker-provisioned-root
|
||||
name: Docker provisioned root
|
||||
checkoutMode: provisioned-root
|
||||
create: ./create.sh
|
||||
destroy: ./destroy.sh
|
||||
`
|
||||
)
|
||||
execFileSync('git', ['init'], { cwd: repoPath })
|
||||
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { cwd: repoPath })
|
||||
execFileSync('git', ['config', 'user.name', 'Orca E2E'], { cwd: repoPath })
|
||||
execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/stablyai/orca.git'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
execFileSync('git', ['add', '.'], { cwd: repoPath })
|
||||
execFileSync('git', ['commit', '-m', 'seed recipe'], { cwd: repoPath })
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
@@ -51,3 +51,14 @@ export function prepareDockerSshRelayImage(root: string): void {
|
||||
{ stdio: 'inherit', timeout: 300_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export function ensureDockerSshRelayImage(root: string): void {
|
||||
if (process.env.ORCA_E2E_SSH_DOCKER_IMAGE) {
|
||||
return
|
||||
}
|
||||
const image = fixtureImage(root)
|
||||
if (spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' }).status === 0) {
|
||||
return
|
||||
}
|
||||
prepareDockerSshRelayImage(root)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user