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