fix(vm): harden provisioned root lifecycle

This commit is contained in:
E2E Test
2026-08-11 19:33:04 -04:00
parent 512cfa4226
commit ce23f1d244
43 changed files with 1292 additions and 213 deletions
+40 -13
View File
@@ -80,7 +80,7 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
- **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
- **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
token`; §5).
token`; §5).
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
place before any paid step.
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
@@ -226,9 +226,9 @@ reserve stdout for the final JSON and log progress to stderr. Include a shared `
- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so
bash is fine there regardless of the user's OS.
### 7a. Base-snapshot (`<provider>-base-snapshot.sh`) — Phase 2
@@ -276,6 +276,9 @@ set -euo pipefail
```bash
#!/usr/bin/env bash
set -euo pipefail
# provisioned-root only — run before step 1; omit for orca-worktree recipes:
[ "${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-1}" = 2 ] \
|| { echo "Upgrade Orca before using this provisioned-root recipe" >&2; exit 1; }
# read authenticated snapshotId/scope/project/port/project_root from state; for provisioned-root source
# intent, read ORCA_REPO_URL/ORCA_REPO_REF/ORCA_REPO_BRANCH from the inherited environment first
# fail clearly if snapshotId is missing (point back to Phases 2–3)
@@ -311,7 +314,11 @@ There is **no `--host` flag**. `--project-root` must be an absolute directory on
keeps serving:
```json
{ "schemaVersion": 1, "pairingCode": "<orca pairing URL>", "projectRoot": "<the --project-root you passed>" }
{
"schemaVersion": 1,
"pairingCode": "<orca pairing URL>",
"projectRoot": "<the --project-root you passed>"
}
```
`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
@@ -331,6 +338,8 @@ payload="$(cat)" # Orca passes lifecycle JSON on stdin
resource_id="$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? "")' "$payload")"
[ -n "$resource_id" ] || { echo "No resource id in lifecycle payload" >&2; exit 1; }
# suspend: provider suspend "$resource_id"
# resume (provisioned-root only): before waking the provider, require
# [ "${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-1}" = 2 ] or fail with an upgrade message.
# resume: provider resume "$resource_id"; then RE-EMIT fresh recipe JSON (pairing may change). A
# provisioned-root result must keep the exact same projectRoot and v2 checkoutMode handshake.
# destroy: provider remove "$resource_id" (or set destroy: none in orca.yaml)
@@ -384,6 +393,8 @@ new_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail
```bash
#!/usr/bin/env bash
set -euo pipefail
[ "${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-1}" = 2 ] \
|| { echo "Upgrade Orca before using this provisioned-root recipe" >&2; exit 1; }
# resolve snapshot_id/scope/project/port/project_root from state. Resolve repo_url/repo_ref from
# ORCA_REPO_URL/ORCA_REPO_REF first, then state; repo_branch comes from ORCA_REPO_BRANCH and is required.
vercel_args=(); [ -n "$scope" ] && vercel_args+=(--scope "$scope"); [ -n "$project" ] && vercel_args+=(--project "$project")
@@ -459,7 +470,7 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
- The result uses a `connection` block with `type: "ssh"` and a `target`, **not** the flat
`pairingCode`/`projectRoot` shape. The reusable-host `orca-worktree` example below uses schema version
1. A one-machine-per-workspace provisioned root uses the same `connection` block under the v2 fields
shown after the example.
shown after the example.
```json
{
@@ -499,7 +510,7 @@ For an ephemeral machine that is itself the workspace, configure `checkoutMode:
}
```
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no
`orca serve` URL in SSH mode):
- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
@@ -512,7 +523,7 @@ For an ephemeral machine that is itself the workspace, configure `checkoutMode:
reconnect grace window.
**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
recipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and
the §7f Phase-3 `<agent> login --device-auth` **directly over SSH on the host** (interactive, e.g.
`ssh -t user@host '<agent> login --device-auth'`). After that the host stays ready across workspaces.
@@ -625,6 +636,10 @@ 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.
if ($env:ORCA_RECIPE_RESULT_SCHEMA_VERSION -ne '2') {
throw 'Upgrade Orca before using this provisioned-root recipe'
}
# resolve env→state→fallback; run the provider CLI / ssh the same way;
# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.
# First build either the Orca-server or SSH connection fields (see §7c/§7g).
@@ -634,7 +649,7 @@ $ErrorActionPreference = 'Stop'
# progress/errors → Write-Error / the error stream, never stdout.
```
The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.
The remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.
---
@@ -666,9 +681,11 @@ removing the workspace removes its project and invokes `destroy`.
Orca exports source intent to every recipe command:
- `ORCA_REPO_URL`: source repository remote URL.
- `ORCA_REPO_URL`: source repository remote URL; Orca strips embedded HTTP credentials, all query
parameters, and fragments, so configure clone/fetch authentication separately.
- `ORCA_REPO_REF`: requested starting ref (branch, remote ref, or commit).
- `ORCA_REPO_BRANCH`: branch name the new workspace should have.
- `ORCA_RECIPE_RESULT_SCHEMA_VERSION`: result schema supported for this recipe (`1` or `2`).
- `ORCA_VERSION`: creating Orca version, for diagnostics or an explicit compatibility gate.
For provisioned-root, fetch `ORCA_REPO_REF` and check it out as `ORCA_REPO_BRANCH`; do not use the ref
@@ -681,6 +698,9 @@ Provisioned-root `create` and `resume` results must use `schemaVersion: 2` and i
`checkoutMode: "provisioned-root"`. This explicit result handshake makes older Orca versions reject the
recipe instead of silently creating a child worktree. `resume` must return the same `projectRoot` as
`create`; pairing/SSH connection details may change. Other recipes continue to use schema version 1.
Before provisioning or resuming a paid resource, a provisioned-root script must also require
`ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`; older Orca versions do not export it, so this prevents creating or
waking a resource that the old desktop cannot adopt.
`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends
on the connection mode chosen in §1:
@@ -718,7 +738,10 @@ externally reachable address so the emitted `pairingCode` is reachable; tunnelin
script's job.
Backward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.
Prefer the lifecycle names.
Prefer the lifecycle names. Existing schema-v1 recipes continue to work on newer Orca versions.
Provisioned-root recipes require a desktop that exports `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`; do not
downgrade Orca while one of their runtimes is active because older versions cannot read schema-v2
runtime records.
---
@@ -741,16 +764,20 @@ environment back down** (so the test leaves nothing running, as long as `destroy
cloud money, so get the user's OK **once** before starting — that one approval covers the whole loop
below; do not re-ask before each run.
For provisioned-root, set `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH` in the doctor's
environment to representative source values. The doctor forwards them through the same credential
stripping and recipe environment contract as desktop provisioning.
On failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of
each stage so you can self-diagnose without asking the user to relay logs:
```json
{
"ok": false,
"checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
"checks": [{ "id": "recipe.provision", "status": "fail", "message": "…" }],
"provisionTranscript": {
"provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
}
}
```
@@ -788,7 +815,7 @@ startup-only `docker run` before the full clone/install path.
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
'logged in'`, which also matches "not logged in".
'logged in'`, which also matches "not logged in".
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
URL + code the user opens on the host.
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -20,6 +20,7 @@ import {
runEphemeralVmRecipeStart
} from '../../shared/ephemeral-vm-recipe-runner'
import type { OrcaVmRecipe } from '../../shared/types'
import { stripGitRemoteUrlCredentials } from '../../shared/git-remote-identity'
export const VM_HANDLERS: Record<string, CommandHandler> = {
'vm recipe doctor': async ({ flags, cwd, json }) => {
@@ -144,7 +145,16 @@ async function doctorRecipeWithProvision(
return baseline
}
const start = await runEphemeralVmRecipeStart({ repoPath, recipe })
const repoUrl = process.env.ORCA_REPO_URL
? (stripGitRemoteUrlCredentials(process.env.ORCA_REPO_URL) ?? undefined)
: undefined
const context = {
repoUrl,
branch: process.env.ORCA_REPO_BRANCH,
ref: process.env.ORCA_REPO_REF,
orcaVersion: process.env.ORCA_VERSION
}
const start = await runEphemeralVmRecipeStart({ repoPath, recipe, context })
if (!start.ok) {
const cleanup = start.recipeResult
? await runEphemeralVmRecipeCleanup({
+24
View File
@@ -2291,6 +2291,11 @@ describe('orca cli worktree awareness', () => {
it('runs vm recipe doctor provision mode and invokes cleanup', async () => {
const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-provision-'))
const originalSourceEnv = {
url: process.env.ORCA_REPO_URL,
branch: process.env.ORCA_REPO_BRANCH,
ref: process.env.ORCA_REPO_REF
}
const pairingCode = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'ws://sandbox.example.com:6767',
@@ -2365,6 +2370,9 @@ describe('orca cli worktree awareness', () => {
return cleanupChild
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
process.env.ORCA_REPO_URL = 'https://token@git.example.com/team/repo.git'
process.env.ORCA_REPO_BRANCH = 'doctor-workspace'
process.env.ORCA_REPO_REF = 'origin/main'
await main([
'vm',
@@ -2400,6 +2408,11 @@ describe('orca cli worktree awareness', () => {
// The transcript carries both stages so the agent can self-diagnose.
expect(output.provisionTranscript?.provision.exitCode).toBe(0)
expect(output.provisionTranscript?.destroy?.exitCode).toBe(0)
expect(spawnMock.mock.calls[0]?.[1]?.env).toMatchObject({
ORCA_REPO_URL: 'https://git.example.com/team/repo.git',
ORCA_REPO_BRANCH: 'doctor-workspace',
ORCA_REPO_REF: 'origin/main'
})
const cleanupPayload = JSON.parse(
String(vi.mocked(cleanupChild.stdin.end).mock.calls[0]?.[0])
) as { recipeId: string; recipeResult: { projectRoot: string } }
@@ -2408,6 +2421,17 @@ describe('orca cli worktree awareness', () => {
recipeResult: { projectRoot: '/workspace/repo' }
})
} finally {
for (const [name, value] of Object.entries({
ORCA_REPO_URL: originalSourceEnv.url,
ORCA_REPO_BRANCH: originalSourceEnv.branch,
ORCA_REPO_REF: originalSourceEnv.ref
})) {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
rmSync(repoPath, { recursive: true, force: true })
}
})
@@ -42,6 +42,42 @@ function nodeCommand(scriptPath: string): string {
}
describe('runEphemeralVmRecipeStart', () => {
it.each([
{ checkoutMode: undefined, expected: '1' },
{ checkoutMode: 'provisioned-root' as const, expected: '2' }
])(
'advertises result schema $expected for checkout mode $checkoutMode',
async ({ checkoutMode, expected }) => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
writeFileSync(
scriptPath,
[
'console.log(JSON.stringify({',
' schemaVersion: Number(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION),',
' ...(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION === "2"',
' ? { checkoutMode: "provisioned-root" }',
' : {}),',
` pairingCode: ${JSON.stringify(makePairingCode())},`,
" projectRoot: '/workspace/repo'",
'}))'
].join('\n')
)
const result = await runEphemeralVmRecipeStart({
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode,
create: nodeCommand(scriptPath)
}
})
expect(result).toMatchObject({ ok: true, result: { schemaVersion: Number(expected) } })
}
)
it('runs a recipe from the repo root and parses its JSON result', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
+83
View File
@@ -0,0 +1,83 @@
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import { updateEphemeralVmRuntimeStatus } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import type { EphemeralVmRecipeResult } from '../shared/ephemeral-vm-recipes'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultProjectRoot
} from '../shared/ephemeral-vm-recipes'
import type { EphemeralVmRecipeStartSuccess } from '../shared/ephemeral-vm-recipe-runner'
export type EphemeralVmResumeIntegrityResult =
| { ok: true }
| { ok: false; runtime: EphemeralVmRuntimeRecord; error: string }
type EphemeralVmResumeIntegrityFailure = Extract<EphemeralVmResumeIntegrityResult, { ok: false }>
export function validateEphemeralVmResumeIntegrity(args: {
userDataPath: string
existing: EphemeralVmRuntimeRecord
resume: EphemeralVmRecipeStartSuccess
}): EphemeralVmResumeIntegrityResult {
const { existing, resume } = args
const resumedConnection = getEphemeralVmRecipeResultConnection(resume.result)
if (existing.connectionMode && resumedConnection.type !== existing.connectionMode) {
return failResumeIntegrity(
args.userDataPath,
existing,
resume,
'An ephemeral VM recipe must keep its connection type stable after resume.'
)
}
const provisionedRoot =
existing.provisionedProjectRoot ?? getEphemeralVmRecipeResultProjectRoot(existing.recipeResult)
if (
existing.recipe?.checkoutMode === 'provisioned-root' &&
normalizeRuntimePathForComparison(provisionedRoot) !==
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resume.result))
) {
return failResumeIntegrity(
args.userDataPath,
existing,
resume,
'A provisioned-root recipe must keep projectRoot stable after resume.',
provisionedRoot
)
}
return { ok: true }
}
export function persistFailedEphemeralVmResume(args: {
userDataPath: string
existing: EphemeralVmRuntimeRecord
error: string
recipeResult?: EphemeralVmRecipeResult
}): EphemeralVmResumeIntegrityFailure {
const provisionedProjectRoot =
args.existing.recipe?.checkoutMode === 'provisioned-root'
? (args.existing.provisionedProjectRoot ??
getEphemeralVmRecipeResultProjectRoot(args.existing.recipeResult))
: undefined
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, args.existing.id, {
status: 'resume_failed',
...(args.recipeResult ? { recipeResult: args.recipeResult } : {}),
...(provisionedProjectRoot ? { provisionedProjectRoot } : {}),
updatedAt: Date.now()
})
return { ok: false, runtime, error: args.error }
}
function failResumeIntegrity(
userDataPath: string,
existing: EphemeralVmRuntimeRecord,
resume: EphemeralVmRecipeStartSuccess,
error: string,
provisionedProjectRoot?: string
): EphemeralVmResumeIntegrityResult {
const runtime = updateEphemeralVmRuntimeStatus(userDataPath, existing.id, {
status: 'resume_failed',
recipeResult: resume.result,
...(provisionedProjectRoot ? { provisionedProjectRoot } : {}),
updatedAt: Date.now()
})
return { ok: false, runtime, error }
}
@@ -0,0 +1,25 @@
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
export function attachEphemeralVmRuntimeToWorkspace(args: {
userDataPath: string
runtimeId: string
workspaceId: string
}): EphemeralVmRuntimeRecord {
const runtime = listEphemeralVmRuntimes(args.userDataPath).find(
(entry) => entry.id === args.runtimeId
)
if (!runtime) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (['cleanup_pending', 'cleanup_failed', 'cleaned'].includes(runtime.status)) {
throw new Error(`Cannot attach cleaned ephemeral VM runtime: ${args.runtimeId}`)
}
return updateEphemeralVmRuntimeStatus(args.userDataPath, args.runtimeId, {
status: 'running',
workspaceId: args.workspaceId
})
}
@@ -0,0 +1,30 @@
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner'
import type { OrcaVmRecipe } from '../shared/types'
export function getPersistedEphemeralVmRecipe(recipe: OrcaVmRecipe): OrcaVmRecipe {
if (recipe.checkoutMode !== 'orca-worktree') {
return recipe
}
const persisted = { ...recipe }
delete persisted.checkoutMode
return persisted
}
export function getEphemeralVmRuntimeRecipeContext(
repoPath: string,
runtime: EphemeralVmRuntimeRecord
): EphemeralVmRecipeContext {
return {
instanceId: runtime.id,
recipeId: runtime.recipeId,
projectId: runtime.projectId,
workspaceId: runtime.workspaceId,
workspaceName: runtime.workspaceName,
repoUrl: runtime.repoUrl,
branch: runtime.branch,
ref: runtime.ref,
orcaVersion: runtime.orcaVersion,
repoPath
}
}
@@ -0,0 +1,201 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import {
listEphemeralVmRuntimes,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
const { resumeRecipe } = vi.hoisted(() => ({ resumeRecipe: vi.fn() }))
vi.mock('./ephemeral-vm-recipe-runner', () => ({
runEphemeralVmRecipeCleanup: vi.fn(),
runEphemeralVmRecipeResume: resumeRecipe,
runEphemeralVmRecipeStart: vi.fn(),
runEphemeralVmRecipeSuspend: vi.fn()
}))
import { resumeEphemeralVmRuntime } from './ephemeral-vm-runtime-service'
const tempDirs: string[] = []
function makeDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-vm-resume-integrity-'))
tempDirs.push(dir)
return dir
}
function pairingCode(): string {
return encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'wss://runtime.example.com',
deviceToken: 'token',
publicKeyB64: 'public-key'
})
}
function provisionedResult(projectRoot: string, resourceId: string) {
return {
schemaVersion: 2 as const,
checkoutMode: 'provisioned-root' as const,
pairingCode: pairingCode(),
projectRoot,
userData: { resourceId }
}
}
function persistRuntime(userDataPath: string, withImmutableRoot = true): void {
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: 'cloud-sandbox',
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: './create.sh',
resume: './resume.sh',
checkoutMode: 'provisioned-root'
},
repoId: 'repo-1',
...(withImmutableRoot ? { provisionedProjectRoot: '/workspace/repo' } : {}),
connectionMode: 'orca-server',
status: 'suspended',
cleanupStatus: 'not_started',
createdAt: 1,
updatedAt: 1,
recipeResult: provisionedResult('/workspace/repo', 'old-resource')
})
}
describe('ephemeral VM resume integrity', () => {
beforeEach(() => resumeRecipe.mockReset())
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('retains a parsed failure result as the newest cleanup handle', async () => {
const userDataPath = makeDir()
persistRuntime(userDataPath, false)
resumeRecipe.mockResolvedValue({
ok: false,
skipped: false,
context: {},
error: 'checkout mode mismatch',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
recipeResult: {
schemaVersion: 1,
pairingCode: pairingCode(),
projectRoot: '/workspace/repo',
userData: { resourceId: 'replacement-resource' }
}
})
const result = await resumeEphemeralVmRuntime({
userDataPath,
repoPath: '/repo',
recipe: listEphemeralVmRuntimes(userDataPath)[0]!.recipe!,
runtimeId: 'runtime-1'
})
expect(result).toMatchObject({
ok: false,
runtime: {
status: 'resume_failed',
provisionedProjectRoot: '/workspace/repo',
recipeResult: { userData: { resourceId: 'replacement-resource' } }
}
})
resumeRecipe.mockResolvedValue({
ok: true,
skipped: false,
context: {},
result: provisionedResult('/workspace/moved', 'next-resource'),
stdout: '',
stderr: ''
})
await expect(
resumeEphemeralVmRuntime({
userDataPath,
repoPath: '/repo',
recipe: listEphemeralVmRuntimes(userDataPath)[0]!.recipe!,
runtimeId: 'runtime-1'
})
).resolves.toMatchObject({ ok: false, error: expect.stringContaining('projectRoot stable') })
})
it('rejects a moved provisioned root without ever persisting running status', async () => {
const userDataPath = makeDir()
persistRuntime(userDataPath)
resumeRecipe.mockResolvedValue({
ok: true,
skipped: false,
context: {},
result: provisionedResult('/workspace/moved', 'new-resource'),
stdout: '',
stderr: ''
})
const result = await resumeEphemeralVmRuntime({
userDataPath,
repoPath: '/repo',
recipe: listEphemeralVmRuntimes(userDataPath)[0]!.recipe!,
runtimeId: 'runtime-1'
})
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining('projectRoot stable'),
runtime: {
status: 'resume_failed',
provisionedProjectRoot: '/workspace/repo',
recipeResult: { projectRoot: '/workspace/moved' }
}
})
})
it('rejects a connection-type change and retains its cleanup handle', async () => {
const userDataPath = makeDir()
persistRuntime(userDataPath)
resumeRecipe.mockResolvedValue({
ok: true,
skipped: false,
context: {},
result: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'box', host: 'host', port: 22, username: 'orca' }
},
userData: { resourceId: 'ssh-resource' }
},
stdout: '',
stderr: ''
})
const result = await resumeEphemeralVmRuntime({
userDataPath,
repoPath: '/repo',
recipe: listEphemeralVmRuntimes(userDataPath)[0]!.recipe!,
runtimeId: 'runtime-1'
})
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining('connection type stable'),
runtime: {
status: 'resume_failed',
recipeResult: { userData: { resourceId: 'ssh-resource' } }
}
})
})
})
@@ -0,0 +1,46 @@
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import type {
EphemeralVmRecipeStartFailure,
EphemeralVmRecipeStartSuccess
} from './ephemeral-vm-recipe-runner'
import type { OrcaVmRecipe } from '../shared/types'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
repoPath: string
recipe: OrcaVmRecipe
repoId?: string
projectId?: string
workspaceId?: string
workspaceName?: string
repoUrl?: string
branch?: string
ref?: string
orcaVersion?: string
now?: number
signal?: AbortSignal
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
export type ProvisionEphemeralVmRuntimeResult =
| { ok: true; start: EphemeralVmRecipeStartSuccess; runtime: EphemeralVmRuntimeRecord }
| { ok: false; start: EphemeralVmRecipeStartFailure }
export type CleanupEphemeralVmRuntimeArgs = {
userDataPath: string
repoPath: string
recipe: OrcaVmRecipe
runtimeId: string
now?: number
signal?: AbortSignal
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
export type CleanupEphemeralVmRuntimeResult =
| { ok: true; runtime: EphemeralVmRuntimeRecord; skipped: boolean }
| { ok: false; runtime: EphemeralVmRuntimeRecord; error: string }
export type SuspendEphemeralVmRuntimeResult = CleanupEphemeralVmRuntimeResult
export type ResumeEphemeralVmRuntimeResult = CleanupEphemeralVmRuntimeResult
+34 -5
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -8,6 +8,7 @@ import {
cleanupEphemeralVmRuntime,
provisionEphemeralVmRuntime
} from './ephemeral-vm-runtime-service'
import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment'
import type { OrcaVmRecipe } from '../shared/types'
const tempDirs: string[] = []
@@ -63,7 +64,10 @@ describe('ephemeral VM runtime service', () => {
' schemaVersion: 1,',
` pairingCode: ${JSON.stringify(makePairingCode())},`,
" projectRoot: '/workspace/repo',",
' userData: { providerResourceId: process.env.ORCA_VM_INSTANCE_ID }',
' userData: {',
' providerResourceId: process.env.ORCA_VM_INSTANCE_ID,',
' repoUrl: process.env.ORCA_REPO_URL',
' }',
'}))'
].join('\n')
)
@@ -76,6 +80,7 @@ describe('ephemeral VM runtime service', () => {
' const payload = JSON.parse(input)',
' if (payload.recipeResult.projectRoot !== "/workspace/repo") process.exit(12)',
' if (!payload.recipeResult.userData.providerResourceId) process.exit(13)',
" require('fs').appendFileSync('cleanup-count.txt', 'x')",
' console.error(`cleanup:${payload.instanceId}`)',
'})'
].join('\n')
@@ -83,6 +88,7 @@ describe('ephemeral VM runtime service', () => {
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'orca-worktree',
// Repo-owned recipes predate plugin bounds; snapshotting must not fail
// after create has already provisioned external resources.
description: 'x'.repeat(2_048),
@@ -97,6 +103,7 @@ describe('ephemeral VM runtime service', () => {
repoId: 'repo-1',
projectId: 'project-1',
workspaceName: 'Fix Login Race',
repoUrl: 'https://token@git.example.com/team/repo.git?signature=secret',
now: 1_000
})
@@ -107,24 +114,32 @@ describe('ephemeral VM runtime service', () => {
expect(provisioned.runtime).toMatchObject({
id: provisioned.start.context.instanceId,
recipeId: 'cloud-sandbox',
recipe,
repoId: 'repo-1',
projectId: 'project-1',
workspaceName: 'Fix Login Race',
repoUrl: 'https://git.example.com/team/repo.git',
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1_000,
updatedAt: 1_000
})
expect(provisioned.runtime.recipeResult.userData).toMatchObject({
repoUrl: 'https://git.example.com/team/repo.git'
})
expect(provisioned.runtime.recipe).not.toHaveProperty('checkoutMode')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([provisioned.runtime])
const cleanup = await cleanupEphemeralVmRuntime({
const cleanupArgs = {
userDataPath,
repoPath,
recipe,
runtimeId: provisioned.runtime.id,
now: 2_000
})
}
const [cleanup] = await Promise.all([
cleanupEphemeralVmRuntime(cleanupArgs),
cleanupEphemeralVmRuntime(cleanupArgs)
])
expect(cleanup).toMatchObject({
ok: true,
@@ -136,6 +151,20 @@ describe('ephemeral VM runtime service', () => {
cleanupLastAttemptAt: 2_000
}
})
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
await expect(cleanupEphemeralVmRuntime(cleanupArgs)).resolves.toMatchObject({
ok: true,
runtime: { status: 'cleaned' }
})
expect(() =>
attachEphemeralVmRuntimeToWorkspace({
userDataPath,
runtimeId: provisioned.runtime.id,
workspaceId: 'late-workspace'
})
).toThrow('Cannot attach cleaned ephemeral VM runtime')
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
})
it('does not persist a runtime when recipe output cannot be parsed', async () => {
+77 -111
View File
@@ -1,100 +1,43 @@
import type { OrcaVmRecipe } from '../shared/types'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultProjectRoot
} from '../shared/ephemeral-vm-recipes'
import {
persistFailedEphemeralVmResume,
validateEphemeralVmResumeIntegrity
} from './ephemeral-vm-resume-integrity'
import { stripGitRemoteUrlCredentials } from '../shared/git-remote-identity'
import {
runEphemeralVmRecipeCleanup,
runEphemeralVmRecipeResume,
runEphemeralVmRecipeSuspend,
runEphemeralVmRecipeStart,
type EphemeralVmRecipeContext,
type EphemeralVmRecipeStartFailure,
type EphemeralVmRecipeStartSuccess
runEphemeralVmRecipeStart
} from './ephemeral-vm-recipe-runner'
import {
getEphemeralVmRuntimeRecipeContext,
getPersistedEphemeralVmRecipe
} from './ephemeral-vm-runtime-recipe-context'
import type {
CleanupEphemeralVmRuntimeArgs,
CleanupEphemeralVmRuntimeResult,
ProvisionEphemeralVmRuntimeArgs,
ProvisionEphemeralVmRuntimeResult,
ResumeEphemeralVmRuntimeResult,
SuspendEphemeralVmRuntimeResult
} from './ephemeral-vm-runtime-service-types'
export type * from './ephemeral-vm-runtime-service-types'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
repoPath: string
recipe: OrcaVmRecipe
repoId?: string
projectId?: string
workspaceId?: string
workspaceName?: string
repoUrl?: string
branch?: string
ref?: string
orcaVersion?: string
now?: number
signal?: AbortSignal
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
export type ProvisionEphemeralVmRuntimeResult =
| {
ok: true
start: EphemeralVmRecipeStartSuccess
runtime: EphemeralVmRuntimeRecord
}
| {
ok: false
start: EphemeralVmRecipeStartFailure
}
export type CleanupEphemeralVmRuntimeArgs = {
userDataPath: string
repoPath: string
recipe: OrcaVmRecipe
runtimeId: string
now?: number
signal?: AbortSignal
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
export type CleanupEphemeralVmRuntimeResult =
| {
ok: true
runtime: EphemeralVmRuntimeRecord
skipped: boolean
}
| {
ok: false
runtime: EphemeralVmRuntimeRecord
error: string
}
export type SuspendEphemeralVmRuntimeResult =
| {
ok: true
runtime: EphemeralVmRuntimeRecord
skipped: boolean
}
| {
ok: false
runtime: EphemeralVmRuntimeRecord
error: string
}
export type ResumeEphemeralVmRuntimeResult =
| {
ok: true
runtime: EphemeralVmRuntimeRecord
skipped: boolean
}
| {
ok: false
runtime: EphemeralVmRuntimeRecord
error: string
}
const cleanupInFlight = new Map<string, Promise<CleanupEphemeralVmRuntimeResult>>()
export async function provisionEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs
): Promise<ProvisionEphemeralVmRuntimeResult> {
const repoUrl = stripGitRemoteUrlCredentials(args.repoUrl ?? '') ?? undefined
const start = await runEphemeralVmRecipeStart({
repoPath: args.repoPath,
recipe: args.recipe,
@@ -102,7 +45,7 @@ export async function provisionEphemeralVmRuntime(
projectId: args.projectId,
workspaceId: args.workspaceId,
workspaceName: args.workspaceName,
repoUrl: args.repoUrl,
repoUrl,
branch: args.branch,
ref: args.ref,
orcaVersion: args.orcaVersion
@@ -131,15 +74,18 @@ export async function provisionEphemeralVmRuntime(
const runtime = upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
recipe: getPersistedEphemeralVmRecipe(args.recipe),
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
...(args.repoUrl ? { repoUrl: args.repoUrl } : {}),
...(repoUrl ? { repoUrl } : {}),
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(args.orcaVersion ? { orcaVersion: args.orcaVersion } : {}),
...(args.recipe.checkoutMode === 'provisioned-root'
? { provisionedProjectRoot: getEphemeralVmRecipeResultProjectRoot(start.result) }
: {}),
status: 'running',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
@@ -152,7 +98,26 @@ export async function provisionEphemeralVmRuntime(
return { ok: true, start, runtime }
}
export async function cleanupEphemeralVmRuntime(
export function cleanupEphemeralVmRuntime(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const key = `${args.userDataPath}\0${args.runtimeId}`
const existing = cleanupInFlight.get(key)
if (existing) {
return existing
}
const cleanup = cleanupEphemeralVmRuntimeOnce(args)
cleanupInFlight.set(key, cleanup)
const forget = (): void => {
if (cleanupInFlight.get(key) === cleanup) {
cleanupInFlight.delete(key)
}
}
void cleanup.then(forget, forget)
return cleanup
}
async function cleanupEphemeralVmRuntimeOnce(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const existing = listEphemeralVmRuntimes(args.userDataPath).find(
@@ -161,6 +126,13 @@ export async function cleanupEphemeralVmRuntime(
if (!existing) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (existing.status === 'cleaned') {
return {
ok: true,
runtime: existing,
skipped: existing.cleanupStatus === 'disabled'
}
}
const now = args.now ?? Date.now()
const running = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
@@ -173,7 +145,7 @@ export async function cleanupEphemeralVmRuntime(
const cleanup = await runEphemeralVmRecipeCleanup({
repoPath: args.repoPath,
recipe: args.recipe,
context: contextFromRuntime(args.repoPath, running),
context: getEphemeralVmRuntimeRecipeContext(args.repoPath, running),
recipeResult: running.recipeResult,
signal: args.signal,
onStdout: args.onStdout,
@@ -211,7 +183,7 @@ export async function suspendEphemeralVmRuntime(
const suspend = await runEphemeralVmRecipeSuspend({
repoPath: args.repoPath,
recipe: args.recipe,
context: contextFromRuntime(args.repoPath, existing),
context: getEphemeralVmRuntimeRecipeContext(args.repoPath, existing),
recipeResult: existing.recipeResult,
signal: args.signal,
onStdout: args.onStdout,
@@ -245,7 +217,7 @@ export async function resumeEphemeralVmRuntime(
const resume = await runEphemeralVmRecipeResume({
repoPath: args.repoPath,
recipe: args.recipe,
context: contextFromRuntime(args.repoPath, existing),
context: getEphemeralVmRuntimeRecipeContext(args.repoPath, existing),
recipeResult: existing.recipeResult,
signal: args.signal,
onStdout: args.onStdout,
@@ -253,11 +225,23 @@ export async function resumeEphemeralVmRuntime(
})
if (!resume.ok) {
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'resume_failed',
updatedAt: Date.now()
return persistFailedEphemeralVmResume({
userDataPath: args.userDataPath,
existing,
error: resume.error,
recipeResult: resume.recipeResult
})
return { ok: false, runtime: failed, error: resume.error }
}
if (!resume.skipped) {
const integrity = validateEphemeralVmResumeIntegrity({
userDataPath: args.userDataPath,
existing,
resume
})
if (!integrity.ok) {
return integrity
}
}
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
@@ -267,21 +251,3 @@ export async function resumeEphemeralVmRuntime(
})
return { ok: true, runtime, skipped: resume.skipped }
}
function contextFromRuntime(
repoPath: string,
runtime: EphemeralVmRuntimeRecord
): EphemeralVmRecipeContext {
return {
instanceId: runtime.id,
recipeId: runtime.recipeId,
projectId: runtime.projectId,
workspaceId: runtime.workspaceId,
workspaceName: runtime.workspaceName,
repoUrl: runtime.repoUrl,
branch: runtime.branch,
ref: runtime.ref,
orcaVersion: runtime.orcaVersion,
repoPath
}
}
+5 -19
View File
@@ -7,10 +7,8 @@ import {
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultPairingCode,
getEphemeralVmRecipeResultProjectRoot
getEphemeralVmRecipeResultPairingCode
} from '../../shared/ephemeral-vm-recipes'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import {
removeEnvironment,
updateEnvironmentFromPairingCode
@@ -20,6 +18,7 @@ import {
resumeEphemeralVmRuntime,
suspendEphemeralVmRuntime
} from '../ephemeral-vm-runtime-service'
import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm-runtime-attachment'
import {
buildEphemeralVmRecipeCleanupCommand,
buildEphemeralVmRecipeCleanupPayload
@@ -55,8 +54,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
ipcMain.handle(
'ephemeralVm:attachWorkspace',
(_event, args: { runtimeId: string; workspaceId: string }): EphemeralVmRuntimeRecord => {
return updateEphemeralVmRuntimeStatus(app.getPath('userData'), args.runtimeId, {
status: 'running',
return attachEphemeralVmRuntimeToWorkspace({
userDataPath: app.getPath('userData'),
runtimeId: args.runtimeId,
workspaceId: args.workspaceId
})
}
@@ -175,20 +175,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
if (!result.ok) {
throw new Error(result.error)
}
if (
runtime.recipe?.checkoutMode === 'provisioned-root' &&
normalizeRuntimePathForComparison(
getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)
) !==
normalizeRuntimePathForComparison(
getEphemeralVmRecipeResultProjectRoot(result.runtime.recipeResult)
)
) {
updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, {
status: 'resume_failed'
})
throw new Error('A provisioned-root recipe must keep projectRoot stable after resume.')
}
if (!result.skipped && runtime.runtimeEnvironmentId) {
const pairingCode = getEphemeralVmRecipeResultPairingCode(result.runtime.recipeResult)
if (!pairingCode) {
+12 -8
View File
@@ -698,17 +698,21 @@ describe('registerEphemeralVmHandlers', () => {
].join('\n')
)
await expect(
handlers.get('ephemeralVm:resumeWorkspace')?.(null, {
workspaceId: 'workspace-1'
} as never)
).rejects.toThrow('must keep projectRoot stable')
const resumeWorkspace = handlers.get('ephemeralVm:resumeWorkspace')!
await expect(resumeWorkspace(null, { workspaceId: 'workspace-1' } as never)).rejects.toThrow(
'must keep projectRoot stable'
)
const runtimes = (await handlers.get('ephemeralVm:listRuntimes')?.(
null,
undefined as never
)) as { id: string; status: string }[]
expect(runtimes.find((runtime) => runtime.id === provisioned.runtime.id)?.status).toBe(
'resume_failed'
)) as { id: string; recipeResult: { projectRoot: string } }[]
expect(runtimes.find((runtime) => runtime.id === provisioned.runtime.id)).toMatchObject({
status: 'resume_failed',
provisionedProjectRoot: '/workspace/repo',
recipeResult: { projectRoot: '/workspace/moved' }
})
await expect(resumeWorkspace(null, { workspaceId: 'workspace-1' } as never)).rejects.toThrow(
'must keep projectRoot stable'
)
})
+3
View File
@@ -24,6 +24,9 @@ export function mergeWorktree(
...(meta?.projectHostSetupId !== undefined
? { projectHostSetupId: meta.projectHostSetupId }
: {}),
...(meta?.ephemeralVmCheckoutMode !== undefined
? { ephemeralVmCheckoutMode: meta.ephemeralVmCheckoutMode }
: {}),
...(creatorProvenance ? { creatorProvenance } : {}),
path: git.path,
head: git.head,
+1
View File
@@ -1908,6 +1908,7 @@ describe('OrcaRuntimeService', () => {
expect(status.capabilities).toContain('worktree.create-idempotency.v1')
expect(status.capabilities).toContain('files.mutation-ownership.v1')
expect(status.capabilities).toContain('project-host-setup.v1')
expect(status.capabilities).toContain('worktree.ephemeral-vm-checkout-mode.v1')
expect(status.capabilities).toContain('linear.issue-attribute-filter.v1')
expect(status.capabilities).not.toContain('browser.screencast.v1')
expect(typeof status.protocolVersion).toBe('number')
@@ -247,6 +247,7 @@ export const WorktreeSet = WorktreeSelector.extend({
manualOrder: OptionalFiniteNumber,
lastActivityAt: OptionalFiniteNumber,
createdAt: OptionalFiniteNumber,
ephemeralVmCheckoutMode: z.enum(['orca-worktree', 'provisioned-root']).optional(),
sparseDirectories: z.array(z.string()).optional(),
sparseBaseRef: OptionalString,
sparsePresetId: OptionalString,
@@ -755,7 +755,8 @@ describe('worktree RPC methods', () => {
worktree: 'id:wt-1',
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
linkedLinearIssueOrganizationUrlKey: 'stably',
ephemeralVmCheckoutMode: 'provisioned-root'
})
)
@@ -765,7 +766,8 @@ describe('worktree RPC methods', () => {
expect.objectContaining({
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'stably'
linkedLinearIssueOrganizationUrlKey: 'stably',
ephemeralVmCheckoutMode: 'provisioned-root'
})
)
})
+1
View File
@@ -222,6 +222,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
manualOrder: params.manualOrder,
lastActivityAt: params.lastActivityAt,
createdAt: params.createdAt,
ephemeralVmCheckoutMode: params.ephemeralVmCheckoutMode,
sparseDirectories: params.sparseDirectories,
sparseBaseRef: params.sparseBaseRef,
sparsePresetId: params.sparsePresetId,
@@ -400,6 +400,20 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([folder.id])
})
it('keeps provisioned-root feature workspaces visible when default branches are hidden', () => {
const provisioned = makeWorktree('provisioned')
provisioned.isMainWorktree = true
provisioned.ephemeralVmCheckoutMode = 'provisioned-root'
const result = computeVisibleWorktreeIds(
{ repo1: [provisioned] },
[provisioned.id],
visibleOptions({ hideDefaultBranchWorkspace: true })
)
expect(result).toEqual([provisioned.id])
})
it('filters worktrees to a selected SSH host scope', () => {
const local = makeWorktree('local', 'repo1')
const remote = makeWorktree('remote', 'repo2')
@@ -37,7 +37,11 @@ import {
* the definition in one place prevents the two surfaces from drifting.
*/
export function isDefaultBranchWorkspace(worktree: Worktree): boolean {
return worktree.isMainWorktree && worktree.branch.trim() !== ''
return (
worktree.isMainWorktree &&
worktree.branch.trim() !== '' &&
worktree.ephemeralVmCheckoutMode !== 'provisioned-root'
)
}
/**
@@ -71,13 +75,9 @@ export function isSleepingSweepExemptionNarrowingList(
return !showSleepingWorkspaces && alwaysShowDefaultBranchWorkspace === false
}
export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean {
return worktree.automationProvenance?.kind === 'created-by-automation'
}
export function isCliCreatedWorkspace(worktree: Worktree): boolean {
return worktree.cliProvenance?.kind === 'created-by-cli'
}
export const isAutomationGeneratedWorkspace = (worktree: Worktree): boolean =>
worktree.automationProvenance?.kind === 'created-by-automation'
export const isCliCreatedWorkspace = (w: Worktree) => w.cliProvenance?.kind === 'created-by-cli'
/**
* Whether a worktree sits on a detached HEAD (a commit, not a branch).
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
const store = {
deleteProjectHostSetup: vi.fn()
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
import { cleanupEphemeralVmRuntimeForFailedCreate } from './ephemeral-vm-worktree-creation'
function makeRequest(): WorktreeCreationRequest {
return {
repoId: 'repo-runtime',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
ephemeralVmRuntimeId: 'runtime-1',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'runtime:env-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-runtime',
path: '/workspace/repo'
}
}
}
describe('failed ephemeral VM create cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
store.deleteProjectHostSetup.mockResolvedValue({})
globalThis.window = {
api: { ephemeralVm: { cleanup: vi.fn() } }
} as never
})
it('removes the imported setup before destroying the runtime', async () => {
const cleanup = vi.mocked(window.api.ephemeralVm.cleanup)
await cleanupEphemeralVmRuntimeForFailedCreate(makeRequest())
expect(store.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
expect(store.deleteProjectHostSetup.mock.invocationCallOrder[0]).toBeLessThan(
cleanup.mock.invocationCallOrder[0]!
)
})
it('keeps the runtime when imported setup deletion fails', async () => {
store.deleteProjectHostSetup.mockResolvedValue(null)
await cleanupEphemeralVmRuntimeForFailedCreate(makeRequest())
expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled()
})
})
@@ -92,6 +92,7 @@ describe('provisioned-root adoption', () => {
'repo-runtime::C:\\workspace\\repo',
expect.objectContaining({
displayName: 'Feature workspace',
ephemeralVmCheckoutMode: 'provisioned-root',
baseRef: 'origin/main',
linkedIssue: 13044,
workspaceStatus: 'in-progress'
@@ -28,7 +28,7 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
expect(cleanup).toHaveBeenCalledTimes(1)
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'rt-1' })
expect(destroyed).toEqual(['runtime-ssh-a'])
expect(destroyed).toEqual({ runtimeIds: ['rt-1'], sshTargetIds: ['runtime-ssh-a'] })
})
it('cleans a runtime matched only by its runtime-owned SSH target id', async () => {
@@ -43,7 +43,10 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
})
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'rt-1' })
expect(destroyed).toEqual(['runtime-ssh-orca-1'])
expect(destroyed).toEqual({
runtimeIds: ['rt-1'],
sshTargetIds: ['runtime-ssh-orca-1']
})
})
it('ignores non-runtime-owned target ids and already-cleaned runtimes', async () => {
@@ -58,13 +61,13 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
})
expect(cleanup).not.toHaveBeenCalled()
expect(destroyed).toEqual([])
expect(destroyed).toEqual({ runtimeIds: [], sshTargetIds: [] })
})
it('swallows listRuntimes failures', async () => {
listRuntimes.mockRejectedValue(new Error('boom'))
await expect(cleanupEphemeralVmRuntimesForDeleted({ workspaceIds: ['wt-1'] })).resolves.toEqual(
[]
{ runtimeIds: [], sshTargetIds: [] }
)
})
})
@@ -10,19 +10,23 @@ import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
* routes through project removal, which must not leak the live Docker/VM and its
* hidden SSH target.
*
* Returns the runtime-owned SSH target ids that were destroyed so the caller can
* purge the now-orphaned project that pointed at them.
* Returns matched runtime ids plus destroyed SSH target ids for caller-side state cleanup.
*/
export type EphemeralVmRuntimeCleanupMatches = {
runtimeIds: string[]
sshTargetIds: string[]
}
export async function cleanupEphemeralVmRuntimesForDeleted(args: {
workspaceIds?: readonly string[]
// Raw runtime-owned SSH target ids (e.g. a removed repo's connectionId) whose
// backing runtime should also be torn down, even if no workspace id matched.
runtimeOwnedSshTargetIds?: readonly string[]
}): Promise<string[]> {
const destroyedSshTargetIds: string[] = []
}): Promise<EphemeralVmRuntimeCleanupMatches> {
const matches: EphemeralVmRuntimeCleanupMatches = { runtimeIds: [], sshTargetIds: [] }
try {
if (!window.api.ephemeralVm?.listRuntimes) {
return destroyedSshTargetIds
return matches
}
const workspaceIdSet = new Set(args.workspaceIds ?? [])
const sshTargetIdSet = new Set(
@@ -36,13 +40,14 @@ export async function cleanupEphemeralVmRuntimesForDeleted(args: {
(runtime.sshTargetId !== undefined && sshTargetIdSet.has(runtime.sshTargetId)))
)
for (const runtime of matchingRuntimes) {
matches.runtimeIds.push(runtime.id)
if (runtime.sshTargetId) {
destroyedSshTargetIds.push(runtime.sshTargetId)
matches.sshTargetIds.push(runtime.sshTargetId)
}
await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
}
} catch (error) {
console.error('Failed to clean up ephemeral VM runtime for deleted workspace:', error)
}
return destroyedSshTargetIds
return matches
}
@@ -165,18 +165,14 @@ export async function attachEphemeralVmRuntimeToWorkspace(
if (!request.ephemeralVmRuntimeId) {
return
}
try {
await window.api.ephemeralVm.attachWorkspace({
runtimeId: request.ephemeralVmRuntimeId,
workspaceId
})
if (request.ephemeralVmRuntimeEnvironmentId) {
void useAppStore
.getState()
.refreshRuntimeEnvironmentStatus(request.ephemeralVmRuntimeEnvironmentId)
}
} catch (error) {
console.error('Failed to attach ephemeral VM runtime to workspace:', error)
await window.api.ephemeralVm.attachWorkspace({
runtimeId: request.ephemeralVmRuntimeId,
workspaceId
})
if (request.ephemeralVmRuntimeEnvironmentId) {
void useAppStore
.getState()
.refreshRuntimeEnvironmentStatus(request.ephemeralVmRuntimeEnvironmentId)
}
}
@@ -214,6 +210,7 @@ export async function adoptEphemeralVmProvisionedRoot(
const now = Date.now()
const metadata: Partial<WorktreeMeta> = {
displayName: request.displayName ?? request.name,
ephemeralVmCheckoutMode: 'provisioned-root',
lastActivityAt: now,
createdAt: now,
...(refreshedStore.sortBy === 'manual' ? { manualOrder: now } : {}),
@@ -285,6 +282,18 @@ export async function cleanupEphemeralVmRuntimeForFailedCreate(
if (!request.ephemeralVmRuntimeId) {
return
}
const setupId = request.workspaceRunContext?.projectHostSetupId
if (setupId) {
try {
const deleted = await useAppStore.getState().deleteProjectHostSetup({ setupId })
if (!deleted) {
return
}
} catch (error) {
console.error('Failed to remove ephemeral VM project setup after creation failed:', error)
return
}
}
try {
await window.api.ephemeralVm.cleanup({ runtimeId: request.ephemeralVmRuntimeId })
} catch (error) {
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
PendingWorktreeCreation,
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
const { prepareTarget } = vi.hoisted(() => ({ prepareTarget: vi.fn() }))
const store = {
settings: {},
activeView: 'terminal',
activePendingCreationId: null as string | null,
repos: [{ id: 'repo-1', connectionId: null }],
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
store.pendingWorktreeCreations[entry.creationId] = entry
}),
updatePendingWorktreeCreation: vi.fn(
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
const entry = store.pendingWorktreeCreations[creationId]
if (entry) {
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
}
}
),
removePendingWorktreeCreation: vi.fn((creationId: string) => {
delete store.pendingWorktreeCreations[creationId]
}),
createWorktree: vi.fn(),
setupProjectExistingFolder: vi.fn(),
deleteProjectHostSetup: vi.fn(),
refreshRuntimeEnvironmentStatus: vi.fn(),
setActivePendingWorktreeCreation: vi.fn(),
setActiveView: vi.fn(),
setSidebarOpen: vi.fn(),
updateWorktreeMeta: vi.fn(),
seedNativeChatLaunchDraft: vi.fn(),
setTabViewMode: vi.fn(),
tabsByWorktree: {},
unifiedTabsByWorktree: {}
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'creation-1' }))
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
prepareEphemeralVmWorkspaceTarget: prepareTarget
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(() => false),
ensureWorktreeHasInitialTerminal: vi.fn(() => 'tab-1')
}))
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
queueWorkspaceActivationTerminalFocus: vi.fn()
}))
vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
function request(): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
ephemeralVmRecipe: {
sourceRepoId: 'repo-1',
recipeId: 'cloud-sandbox',
projectId: 'project-1'
}
}
}
function preparedTarget(): void {
prepareTarget.mockResolvedValue({
ok: true,
runtimeId: 'runtime-1',
environmentId: 'env-1',
checkoutMode: 'orca-worktree',
stderr: '',
warnings: [],
setup: {
project: { id: 'project-1' },
setup: { id: 'setup-1', projectId: 'project-1', hostId: 'runtime:env-1' },
repo: { id: 'repo-runtime', path: '/workspace/repo' }
}
})
}
describe('worktree creation VM lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
store.pendingWorktreeCreations = {}
store.deleteProjectHostSetup.mockResolvedValue({})
preparedTarget()
globalThis.window = {
api: {
ephemeralVm: {
attachWorkspace: vi.fn(),
cleanup: vi.fn(),
onProvisionEvent: vi.fn(() => vi.fn())
}
}
} as never
})
it('cleans the runtime when workspace attachment fails', async () => {
store.createWorktree.mockResolvedValue({
worktree: { id: 'repo-runtime::/workspace/repo/worktree', repoId: 'repo-runtime' }
})
vi.mocked(window.api.ephemeralVm.attachWorkspace).mockRejectedValue(new Error('attach failed'))
runBackgroundWorktreeCreation(request())
await vi.waitFor(() =>
expect(window.api.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
)
expect(store.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith(
'creation-1',
expect.objectContaining({ status: 'error' })
)
})
it('does not attach a runtime after cancellation wins the create race', async () => {
let finishCreate!: (value: unknown) => void
store.createWorktree.mockReturnValue(new Promise((resolve) => (finishCreate = resolve)))
runBackgroundWorktreeCreation(request())
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalled())
delete store.pendingWorktreeCreations['creation-1']
finishCreate({
worktree: { id: 'repo-runtime::/workspace/repo/worktree', repoId: 'repo-runtime' }
})
await vi.waitFor(() =>
expect(window.api.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
)
expect(window.api.ephemeralVm.attachWorkspace).not.toHaveBeenCalled()
expect(store.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
})
})
@@ -151,6 +151,11 @@ async function executeWorktreeCreation(
: {})
}
)
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
return
}
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, result.worktree.id)
} catch (error) {
// Why: a missing entry means the user cancelled mid-flight — abandon
// silently rather than surfacing an error for work they already dismissed.
@@ -182,8 +187,6 @@ async function executeWorktreeCreation(
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
const backendSpawned = result.startupTerminal?.spawned === true
if (preparedRequest.startupPlan && !backendSpawned && !preparedRequest.startupPlan.launchToken) {
// Why: delayed delivery must target the exact pane spawned from this queued
@@ -5,7 +5,7 @@ import {
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
import { createTestStore, makeWorktree } from './store-test-helpers'
const projectsCreateHostSetup = vi.fn()
const projectsUpdateHostSetup = vi.fn()
@@ -179,6 +179,70 @@ describe('repo slice project host setup lifecycle', () => {
})
})
it('purges worktree state when deleting an imported runtime setup', 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.mockResolvedValue({
id: 'rpc-delete-imported-setup',
ok: true,
result: { result: { project, setup: attachedSetup, repo: runtimeRepo } },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
store.setState({
projects: [project],
projectHostSetups: [attachedSetup],
repos: [runtimeRepo],
worktreesByRepo: { [runtimeRepo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [] },
settings: { activeRuntimeEnvironmentId: null } as never
})
await store.getState().deleteProjectHostSetup({ setupId: attachedSetup.id })
expect(store.getState().repos).toEqual([])
expect(store.getState().worktreesByRepo[runtimeRepo.id]).toBeUndefined()
expect(store.getState().tabsByWorktree[worktree.id]).toBeUndefined()
})
it('preserves worktree state for a same-id checkout retained on another host', async () => {
const attachedSetup = { ...runtimeSetup, repoId: runtimeRepo.id }
const worktreeId = `${runtimeRepo.id}::${runtimeRepo.path}`
const runtimeWorktree = makeWorktree({
id: worktreeId,
repoId: runtimeRepo.id,
path: runtimeRepo.path,
hostId: 'runtime:env-1'
})
const localWorktree = { ...runtimeWorktree, hostId: 'local' as const }
const localRepo = { ...runtimeRepo, executionHostId: 'local' as const }
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-delete-host-collision',
ok: true,
result: { result: { project, setup: attachedSetup, repo: runtimeRepo } },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
store.setState({
projects: [project],
projectHostSetups: [attachedSetup],
repos: [runtimeRepo, localRepo],
worktreesByRepo: { [runtimeRepo.id]: [runtimeWorktree, localWorktree] },
tabsByWorktree: { [worktreeId]: [] },
settings: { activeRuntimeEnvironmentId: null } as never
})
await store.getState().deleteProjectHostSetup({ setupId: attachedSetup.id })
expect(store.getState().worktreesByRepo[runtimeRepo.id]).toEqual([localWorktree])
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([])
})
it('preserves runtime-fetched setup-only states during repo hydration', async () => {
const pendingSetup: ProjectHostSetup = {
...runtimeSetup,
@@ -61,4 +61,72 @@ describe('provisioned-root project cleanup', () => {
ephemeralVmCleanup.mock.invocationCallOrder[0]!
)
})
it('destroys the runtime when its remote project removal fails', async () => {
runtimeEnvironmentCall.mockRejectedValue(new Error('runtime unavailable'))
const worktreeId = `${remoteRepo.id}::${remoteRepo.path}`
ephemeralVmListRuntimes.mockResolvedValue([
{
id: 'recipe-runtime-1',
recipeId: 'cloud-sandbox',
workspaceId: worktreeId,
status: 'running',
cleanupStatus: 'not_started'
}
])
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
repos: [remoteRepo],
worktreesByRepo: {
[remoteRepo.id]: [
makeWorktree({
id: worktreeId,
repoId: remoteRepo.id,
path: remoteRepo.path,
isMainWorktree: true
})
]
}
})
await store.getState().removeProject(remoteRepo.id)
expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'recipe-runtime-1' })
expect(store.getState().repos).not.toContainEqual(
expect.objectContaining({ id: remoteRepo.id })
)
expect(store.getState().worktreesByRepo[remoteRepo.id]).toBeUndefined()
})
it('cancels an in-flight VM workspace before removing its imported project', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-remove-pending-project',
ok: true,
result: { removed: true },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
repos: [remoteRepo],
pendingWorktreeCreations: {
'creation-1': {
creationId: 'creation-1',
phase: 'fetching',
status: 'creating',
request: {
repoId: remoteRepo.id,
ephemeralVmRuntimeId: 'recipe-runtime-1',
workspaceRunContext: { hostId: 'local' }
}
} as never
}
})
await store.getState().removeProject(remoteRepo.id)
expect(store.getState().pendingWorktreeCreations).toEqual({})
expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'recipe-runtime-1' })
})
})
+66 -5
View File
@@ -3229,6 +3229,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
).result
const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined
const repoHostId = repo ? getRepoExecutionHostId(repo) : null
const removedWorktreeIds =
repo && repoHostId ? getKnownRepoWorktreeIds(get(), repo.id, repoHostId) : []
set((s) => {
const projectHostSetups = s.projectHostSetups.filter(
(setup) => setup.id !== result.setup.id
@@ -3243,13 +3245,53 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
: s.projects
const survivingRepoIds = new Set(repos.map((r) => r.id))
const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id)
const nextWorktreesByRepo = { ...s.worktreesByRepo }
const remainingWorktrees = (nextWorktreesByRepo[repo?.id ?? ''] ?? []).filter(
(worktree) => !repoHostId || !worktreeBelongsToHost(worktree, repoHostId)
)
if (repo) {
if (remainingWorktrees.length > 0) {
nextWorktreesByRepo[repo.id] = remainingWorktrees
} else {
delete nextWorktreesByRepo[repo.id]
}
}
const nextDetectedWorktreesByRepo = { ...s.detectedWorktreesByRepo }
if (repo && nextDetectedWorktreesByRepo[repo.id]) {
const detected = nextDetectedWorktreesByRepo[repo.id]
const remainingDetected = detected.worktrees.filter(
(worktree) => !repoHostId || !worktreeBelongsToHost(worktree, repoHostId)
)
if (remainingDetected.length > 0) {
nextDetectedWorktreesByRepo[repo.id] = { ...detected, worktrees: remainingDetected }
} else {
delete nextDetectedWorktreesByRepo[repo.id]
}
}
return {
repos,
projects,
projectHostSetups,
worktreesByRepo: nextWorktreesByRepo,
detectedWorktreesByRepo: nextDetectedWorktreesByRepo,
...omitSparsePresetsForRepos(s, removedRepoIds)
}
})
if (removedWorktreeIds.length > 0) {
const state = get()
const retainedWorktreeIds = new Set([
...Object.values(state.worktreesByRepo).flatMap((worktrees) =>
worktrees.map((worktree) => worktree.id)
),
...Object.values(state.detectedWorktreesByRepo).flatMap((result) =>
result.worktrees.map((worktree) => worktree.id)
)
])
const purgeIds = removedWorktreeIds.filter((id) => !retainedWorktreeIds.has(id))
if (purgeIds.length > 0) {
state.purgeWorktreeTerminalState(purgeIds)
}
}
return { ...result, repo }
} catch (err) {
console.error('Failed to delete project host setup:', err)
@@ -3388,6 +3430,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
return
}
const ownerHostId = getRepoExecutionHostId(ownerRepo)
for (const [creationId, pending] of Object.entries(get().pendingWorktreeCreations)) {
if (
pending.request.repoId === projectId &&
(pending.request.workspaceRunContext?.hostId ?? ownerHostId) === ownerHostId
) {
get().removePendingWorktreeCreation(creationId)
}
}
const initialWorktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId)
const runtimeOwnedSshTargetIds = isRuntimeOwnedSshTargetId(ownerRepo.connectionId)
? [ownerRepo.connectionId as string]
: undefined
// Why: derive the target from the owner's settings (via options.hostId) so an SSH host removal never routes repo.rm to the focused runtime.
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, options?.hostId))
// Why: repos:remove is id-only and would delete every host's row; scope local removal to the owning host so cross-host duplicates keep other rows.
@@ -3403,10 +3457,20 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
} 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')) {
throw err
const cleanup = await cleanupEphemeralVmRuntimesForDeleted({
workspaceIds: initialWorktreeIds,
...(runtimeOwnedSshTargetIds ? { runtimeOwnedSshTargetIds } : {})
})
if (cleanup.runtimeIds.length === 0) {
throw err
}
}
}
const worktreeIds = Array.from(
new Set([...initialWorktreeIds, ...getKnownRepoWorktreeIds(get(), projectId, ownerHostId)])
)
get().clearOrcaHookTrustForRepo(projectId)
const repoPath = get().repos.find((repo) =>
repoMatchesHostIdentity(repo, projectId, ownerHostId)
@@ -3416,7 +3480,6 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
clearRepoSlugCacheEntry(projectId)
// Kill PTYs for all worktrees belonging to this repo
const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId)
const killedTabIds = new Set<string>()
if (target.kind === 'environment') {
await Promise.allSettled(
@@ -3445,9 +3508,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Provisioned roots delete through the project row, so destroy their runtime after remote work stops.
await cleanupEphemeralVmRuntimesForDeleted({
workspaceIds: worktreeIds,
...(isRuntimeOwnedSshTargetId(ownerRepo.connectionId)
? { runtimeOwnedSshTargetIds: [ownerRepo.connectionId as string] }
: {})
...(runtimeOwnedSshTargetIds ? { runtimeOwnedSshTargetIds } : {})
})
// Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree.
@@ -6657,6 +6657,37 @@ describe('worktree remote runtime mutations', () => {
expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note')
})
it('fails provisioned-root metadata closed on an older remote runtime', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
const oldStatus = createCompatibleRuntimeStatusResponse('runtime-old')
if (!oldStatus.ok) {
throw new Error('Expected a compatible runtime status fixture')
}
oldStatus.result.capabilities = (oldStatus.result.capabilities ?? []).filter(
(capability) => capability !== 'worktree.ephemeral-vm-checkout-mode.v1'
)
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
args.method === 'status.get' ? oldStatus : runtimeEnvironmentCall(args)
)
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [wt] }
} as Partial<AppState>)
const result = await store
.getState()
.updateWorktreeMeta(wt.id, { ephemeralVmCheckoutMode: 'provisioned-root' })
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining('Update the remote runtime')
})
expect(runtimeEnvironmentCall.mock.calls.some(([args]) => args.method === 'worktree.set')).toBe(
false
)
})
it('force-deletes a preserved HUB-owned SSH branch through its HUB', async () => {
const store = createTestStore()
const wt = makeWorktree({
@@ -10267,24 +10298,65 @@ describe('pending worktree creation state', () => {
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
})
it('removePendingWorktreeCreation cleans up a provisioned VM runtime', () => {
it('removePendingWorktreeCreation removes the imported setup before its VM runtime', async () => {
const store = createTestStore()
const deleteProjectHostSetup = vi.fn().mockResolvedValue({})
store.setState({ deleteProjectHostSetup } as unknown as Partial<AppState>)
store.getState().beginPendingWorktreeCreation(
makePendingCreation('c1', {
phase: 'fetching',
request: {
...makePendingCreation('c1').request,
ephemeralVmRuntimeId: 'runtime-1'
ephemeralVmRuntimeId: 'runtime-1',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'runtime:env-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-runtime',
path: '/workspace/repo'
}
}
})
)
store.getState().removePendingWorktreeCreation('c1')
await vi.waitFor(() => expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalled())
expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
expect(deleteProjectHostSetup.mock.invocationCallOrder[0]).toBeLessThan(
mockApi.ephemeralVm.cleanup.mock.invocationCallOrder[0]!
)
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
})
it('keeps a cancelled VM runtime when imported setup deletion fails', async () => {
const store = createTestStore()
store.setState({ deleteProjectHostSetup: vi.fn().mockResolvedValue(null) } as never)
store.getState().beginPendingWorktreeCreation(
makePendingCreation('c1', {
request: {
...makePendingCreation('c1').request,
ephemeralVmRuntimeId: 'runtime-1',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'runtime:env-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-runtime',
path: '/workspace/repo'
}
}
})
)
store.getState().removePendingWorktreeCreation('c1')
await vi.waitFor(() => expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined())
expect(mockApi.ephemeralVm.cleanup).not.toHaveBeenCalled()
})
it('removePendingWorktreeCreation can drop a completed VM creation without cleanup', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(
+26 -7
View File
@@ -52,6 +52,7 @@ import {
} from '../../runtime/runtime-rpc-client'
import {
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_EPHEMERAL_VM_CHECKOUT_MODE_RUNTIME_CAPABILITY,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
@@ -1702,6 +1703,13 @@ async function persistWorktreeMeta(
'Update the remote runtime to link Linear issues'
)
}
if (target.kind === 'environment' && 'ephemeralVmCheckoutMode' in updates) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_EPHEMERAL_VM_CHECKOUT_MODE_RUNTIME_CAPABILITY,
'Update the remote runtime before using a provisioned-root recipe'
)
}
await callRuntimeRpc(
target,
'worktree.set',
@@ -4156,11 +4164,22 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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.
})
const runtimeId = entry.request.ephemeralVmRuntimeId
const cleanupCancelledRuntime = async (): Promise<void> => {
const setupId = entry.request.workspaceRunContext?.projectHostSetupId
if (setupId) {
const deleted = await get()
.deleteProjectHostSetup({ setupId })
.catch(() => null)
if (!deleted) {
return
}
}
await window.api.ephemeralVm.cleanup({ runtimeId })
}
void cleanupCancelledRuntime().catch(() => {
// Best effort: cancellation shouldn't block on provider cleanup; Settings still exposes retry/manual cleanup.
})
}
const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations
return {
@@ -4321,11 +4340,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
backendOwnsPtyTeardown: true
})
// Why: dispose the SSH relay AFTER terminal teardown so a still-mounted pane can't hit a gone relay and toast "SSH not active".
const destroyedRuntimeSshTargetIds = await cleanupEphemeralVmRuntimesForDeleted({
const runtimeCleanup = await cleanupEphemeralVmRuntimesForDeleted({
workspaceIds: [worktreeId]
})
// Remove the orphaned project for the destroyed SSH target so it can't surface as a dead project in the composer.
await purgeOrphanedRuntimeSshProjects(get, destroyedRuntimeSshTargetIds)
await purgeOrphanedRuntimeSshProjects(get, runtimeCleanup.sshTargetIds)
const tabs = get().tabsByWorktree[worktreeId] ?? []
const tabIds = new Set(tabs.map((t) => t.id))
@@ -2,6 +2,10 @@ import { getEphemeralVmRecipeResultCheckoutMode } from './ephemeral-vm-recipes'
import type { EphemeralVmRecipeResult } from './ephemeral-vm-recipes'
import type { OrcaVmRecipe } from './types'
export function getEphemeralVmRecipeResultSchemaVersion(recipe: OrcaVmRecipe): 1 | 2 {
return recipe.checkoutMode === 'provisioned-root' ? 2 : 1
}
export function getEphemeralVmRecipeCheckoutModeError(
recipe: OrcaVmRecipe,
result: EphemeralVmRecipeResult
@@ -38,6 +38,7 @@ describe('runRecipeCommand', () => {
command: nodeCommand(scriptPath),
repoPath,
mode: 'create',
resultSchemaVersion: 1,
context: {
recipeId: 'cloud-sandbox',
repoPath
@@ -71,6 +72,7 @@ describe('runRecipeCommand', () => {
command: nodeCommand(scriptPath),
repoPath,
mode: 'create',
resultSchemaVersion: 1,
context: {
recipeId: 'cloud-sandbox',
repoPath
+5 -2
View File
@@ -25,6 +25,7 @@ export async function runRecipeCommand(args: {
repoPath: string
context: EphemeralVmRecipeContext
mode: 'create' | 'suspend' | 'resume' | 'destroy'
resultSchemaVersion: 1 | 2
stdin?: string
env?: NodeJS.ProcessEnv
maxCaptureBytes?: number
@@ -42,7 +43,7 @@ export async function runRecipeCommand(args: {
child = spawnCommand(args.command, {
cwd: args.repoPath,
detached: process.platform !== 'win32',
env: buildRecipeEnv(args.env, args.mode, args.context),
env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion),
shell: true,
windowsHide: true
}) as ChildProcessWithoutNullStreams
@@ -123,7 +124,8 @@ function killRecipeProcess(child: ChildProcessWithoutNullStreams): void {
function buildRecipeEnv(
env: NodeJS.ProcessEnv | undefined,
mode: 'create' | 'suspend' | 'resume' | 'destroy',
context: EphemeralVmRecipeContext
context: EphemeralVmRecipeContext,
resultSchemaVersion: 1 | 2
): NodeJS.ProcessEnv {
return {
...process.env,
@@ -138,6 +140,7 @@ function buildRecipeEnv(
ORCA_REPO_URL: context.repoUrl ?? '',
ORCA_REPO_BRANCH: context.branch ?? '',
ORCA_REPO_REF: context.ref ?? '',
ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion),
ORCA_VERSION: context.orcaVersion ?? ''
}
}
+8 -1
View File
@@ -3,7 +3,10 @@ import { randomUUID } from 'node:crypto'
import { statSync } from 'node:fs'
import type { OrcaVmRecipe } from './types'
import { parseEphemeralVmRecipeResult, type EphemeralVmRecipeResult } from './ephemeral-vm-recipes'
import { getEphemeralVmRecipeCheckoutModeError } from './ephemeral-vm-recipe-checkout-mode'
import {
getEphemeralVmRecipeCheckoutModeError,
getEphemeralVmRecipeResultSchemaVersion
} from './ephemeral-vm-recipe-checkout-mode'
import { runRecipeCommand } from './ephemeral-vm-recipe-process'
import {
buildEphemeralVmRecipeCleanupPayload,
@@ -110,6 +113,7 @@ export async function runEphemeralVmRecipeStart(
repoPath: args.repoPath,
context,
mode: 'create',
resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe),
env: args.env,
maxCaptureBytes: args.maxCaptureBytes,
signal: args.signal,
@@ -170,6 +174,7 @@ export async function runEphemeralVmRecipeCleanup(
repoPath: args.repoPath,
context: args.context,
mode: 'destroy',
resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe),
stdin: `${JSON.stringify(payload)}\n`,
env: args.env,
maxCaptureBytes: args.maxCaptureBytes,
@@ -205,6 +210,7 @@ export async function runEphemeralVmRecipeSuspend(
repoPath: args.repoPath,
context: args.context,
mode: 'suspend',
resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe),
stdin: `${JSON.stringify(payload)}\n`,
env: args.env,
maxCaptureBytes: args.maxCaptureBytes,
@@ -246,6 +252,7 @@ export async function runEphemeralVmRecipeResume(
repoPath: args.repoPath,
context: args.context,
mode: 'resume',
resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe),
stdin: `${JSON.stringify(payload)}\n`,
env: args.env,
maxCaptureBytes: args.maxCaptureBytes,
+2
View File
@@ -65,6 +65,7 @@ export function updateEphemeralVmRuntimeStatus(
runtimeEnvironmentId?: string
sshTargetId?: string | null
recipeResult?: EphemeralVmRuntimeRecord['recipeResult']
provisionedProjectRoot?: string
updatedAt?: number
}
): EphemeralVmRuntimeRecord {
@@ -103,6 +104,7 @@ export function updateEphemeralVmRuntimeStatus(
? { sshTargetId: args.sshTargetId }
: {}),
...(args.recipeResult ? { recipeResult: args.recipeResult } : {}),
...(args.provisionedProjectRoot ? { provisionedProjectRoot: args.provisionedProjectRoot } : {}),
updatedAt: args.updatedAt ?? Date.now()
})
writeEphemeralVmRuntimeStore(userDataPath, {
+1
View File
@@ -55,6 +55,7 @@ export const EphemeralVmRuntimeRecordSchema = z.object({
branch: z.string().min(1).optional(),
ref: z.string().min(1).optional(),
orcaVersion: z.string().min(1).optional(),
provisionedProjectRoot: z.string().min(1).optional(),
connectionMode: EphemeralVmRuntimeConnectionModeSchema.optional(),
runtimeEnvironmentId: z.string().min(1).optional(),
sshTargetId: z.string().min(1).optional(),
+31 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { deriveGitRemoteIdentity, normalizeGitRemoteUrl } from './git-remote-identity'
import {
deriveGitRemoteIdentity,
normalizeGitRemoteUrl,
stripGitRemoteUrlCredentials
} from './git-remote-identity'
describe('normalizeGitRemoteUrl', () => {
it('normalizes HTTPS and SSH GitHub remotes to the same canonical key', () => {
@@ -44,6 +48,32 @@ describe('normalizeGitRemoteUrl', () => {
})
})
describe('stripGitRemoteUrlCredentials', () => {
it('removes embedded HTTPS credentials and signed query parameters', () => {
expect(
stripGitRemoteUrlCredentials(
'https://user:secret@git.example.com/team/repo.git?token=secret#fragment'
)
).toBe('https://git.example.com/team/repo.git')
expect(stripGitRemoteUrlCredentials('https://token@git.example.com/team/repo.git')).toBe(
'https://git.example.com/team/repo.git'
)
})
it('preserves SSH remote usernames', () => {
expect(stripGitRemoteUrlCredentials('ssh://git@git.example.com/team/repo.git')).toBe(
'ssh://git@git.example.com/team/repo.git'
)
expect(stripGitRemoteUrlCredentials('git@git.example.com:team/repo.git')).toBe(
'git@git.example.com:team/repo.git'
)
})
it('fails closed for malformed HTTP URLs', () => {
expect(stripGitRemoteUrlCredentials('https://token@%')).toBeNull()
})
})
describe('deriveGitRemoteIdentity', () => {
it('prefers upstream, then origin, then the first named remote', () => {
expect(
+17
View File
@@ -51,6 +51,23 @@ export function normalizeGitRemoteUrl(remoteUrl: string): string | null {
}
}
export function stripGitRemoteUrlCredentials(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim()
if (!/^https?:\/\//i.test(trimmed)) {
return trimmed
}
try {
const parsed = new URL(trimmed)
parsed.username = ''
parsed.password = ''
parsed.search = ''
parsed.hash = ''
return parsed.toString()
} catch {
return null
}
}
export function parseGitRemoteVerboseOutput(stdout: string): GitRemoteEntry[] {
const entries: GitRemoteEntry[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
+3
View File
@@ -28,6 +28,8 @@ export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' a
export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const
export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY =
'worktree.linked-work-item-context.v1' as const
export const WORKTREE_EPHEMERAL_VM_CHECKOUT_MODE_RUNTIME_CAPABILITY =
'worktree.ephemeral-vm-checkout-mode.v1' as const
export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const
export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const
export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY =
@@ -102,6 +104,7 @@ export const RUNTIME_CAPABILITIES = [
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_EPHEMERAL_VM_CHECKOUT_MODE_RUNTIME_CAPABILITY,
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
AI_VAULT_RUNTIME_CAPABILITY,
+7 -1
View File
@@ -499,6 +499,8 @@ export type Worktree = {
creatorProvenance?: WorkspaceCreatorProvenance
/** Host-specific setup used to create/run this workspace. */
projectHostSetupId?: string
/** Checkout ownership for a recipe-provisioned main workspace. */
ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode
displayName: string
comment: string
linkedIssue: number | null
@@ -631,6 +633,8 @@ export type WorktreeMeta = {
hostId?: ExecutionHostId
/** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */
projectHostSetupId?: string
/** See Worktree.ephemeralVmCheckoutMode. */
ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode
/** See Worktree.creatorProvenance. */
creatorProvenance?: WorkspaceCreatorProvenance
displayName: string
@@ -2194,11 +2198,13 @@ export type OrcaDefaultTabTemplate = {
command?: string
}
export type EphemeralVmCheckoutMode = 'orca-worktree' | 'provisioned-root'
export type OrcaVmRecipe = {
id: string
name: string
create: string
checkoutMode?: 'orca-worktree' | 'provisioned-root'
checkoutMode?: EphemeralVmCheckoutMode
description?: string
suspend?: string
resume?: string