mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(vm): verify provisioned root ownership
This commit is contained in:
@@ -27,17 +27,17 @@
|
||||
"providers": ["orca-server", "direct-ssh"],
|
||||
"coveredPlatforms": ["macos", "linux", "windows"],
|
||||
"coveredProviders": ["orca-server", "direct-ssh"],
|
||||
"coverageNotes": "A pinned exact-source harness runs the pre-#14352 reader and lifecycle service against baseline, affected-main, candidate, and fix-reverted stores. Platform-neutral store tests cover v1 byte stability, mixed records, current-main migration, downgrade mutation, malformed/future metadata preservation, bounded writes, and provider userData round-tripping. The direct-SSH provisioned-root Electron journey remains the live transport and destroy proof; this desktop sidecar is not mobile-facing and changes no RPC or remote wire payload.",
|
||||
"coverageNotes": "A pinned exact-source harness runs the pre-#14352 reader and lifecycle service against baseline, affected-main, candidate, and fix-reverted stores. Platform-neutral tests cover store compatibility, bounded cleanup, branch/ref adoption, and resume ownership integrity. The direct-SSH provisioned-root Electron journey remains the live transport and destroy proof; this desktop sidecar is not mobile-facing and changes no RPC or remote wire payload.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4274",
|
||||
"https://github.com/stablyai/orca/pull/14352",
|
||||
"https://github.com/stablyai/orca/issues/13044"
|
||||
],
|
||||
"invariant": "A sidecar written by the new build cannot make the previous production build lose otherwise compatible VM runtimes. Downgrade lifecycle writes remain authoritative after re-upgrade, provisioned-root metadata remains recoverable, and corrupt or future feature metadata is preserved without poisoning the rollback-readable v1 store. Destroy attempts settle at a bounded deadline, retry starts fresh, and provider failure retains the SSH/project context needed for safe cleanup.",
|
||||
"oracle": "Write one ordinary schema-v1 runtime and one provisioned-root runtime. Require the exact pre-#14352 reader to return both, run the old destroy lifecycle against the projected provisioned record, then require the candidate to retain the cleaned state while restoring schema-v2 and checkout-mode metadata. The same old reader must reject affected-main and fix-reverted bytes. Malformed, oversized, unknown-record, and future-version feature sidecars must leave rollback-compatible records accessible and must not be overwritten during compatible lifecycle mutations. Force destroy to outlive its deadline, require cleanup_failed plus a fresh successful retry, and prove provider or project-host rollback failure retains every ownership record required to retry.",
|
||||
"invariant": "A sidecar written by the new build cannot make the previous production build lose otherwise compatible VM runtimes. Downgrade lifecycle writes remain authoritative after re-upgrade, provisioned-root metadata remains recoverable, and corrupt or future feature metadata is preserved without poisoning the rollback-readable v1 store. Destroy attempts are force-stopped at a bounded deadline, retry starts fresh, and provider failure retains the SSH/project context needed for safe cleanup. Unrecoverable recipe context releases only the hidden SSH registration while preserving lifecycle/provider metadata. Adoption must verify the requested branch/ref, and resume must preserve the connection mode plus stable SSH ownership identity while allowing endpoint rotation.",
|
||||
"oracle": "Write one ordinary schema-v1 runtime and one provisioned-root runtime. Require the exact pre-#14352 reader to return both, run the old destroy lifecycle against the projected provisioned record, then require the candidate to retain the cleaned state while restoring schema-v2 and checkout-mode metadata. The same old reader must reject affected-main and fix-reverted bytes. Malformed, oversized, unknown-record, and future-version feature sidecars must leave rollback-compatible records accessible and must not be overwritten during compatible lifecycle mutations. Force destroy to outlive its deadline, require cleanup_failed plus a fresh successful retry, prove provider or project-host rollback failure retains every ownership record required to retry, and prove missing recipe context cannot leave an unremovable hidden SSH registration. Reject a provisioned checkout on the wrong branch/ref and a resume that changes connection mode or SSH ownership.",
|
||||
"commands": [
|
||||
"node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
@@ -50,11 +50,17 @@
|
||||
"src/main/ephemeral-vm-runtime-service.test.ts",
|
||||
"src/main/ephemeral-vm-recipe-runner.test.ts",
|
||||
"src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts",
|
||||
"src/main/ephemeral-vm-resume-integrity.test.ts",
|
||||
"src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
|
||||
"src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
|
||||
"src/main/ipc/ephemeral-vm.test.ts",
|
||||
"src/main/provisioned-root-ssh-adoption.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts",
|
||||
"src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
|
||||
"src/renderer/src/lib/provisioned-root-create-options.test.ts",
|
||||
"src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"tests/e2e/ephemeral-vm-provisioned-root.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
@@ -86,7 +92,13 @@
|
||||
{
|
||||
"file": "src/shared/ephemeral-vm-recipe-process.test.ts",
|
||||
"assertions": [
|
||||
"a destroy process that never closes is killed and settled at its bounded deadline"
|
||||
"a destroy process that never closes is force-killed and settled at its bounded deadline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
|
||||
"assertions": [
|
||||
"unrecoverable recipe context releases the hidden SSH registration but preserves failed lifecycle state"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -99,6 +111,33 @@
|
||||
{
|
||||
"file": "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
|
||||
"assertions": ["failed or unconfirmed project-host rollback prevents provider destroy"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/provisioned-root-ssh-adoption.test.ts",
|
||||
"assertions": [
|
||||
"adoption rejects a checkout on an unrequested branch or ref",
|
||||
"tag and commit refs remain valid when the requested local branch is attached"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
|
||||
"assertions": [
|
||||
"the source ref commit is captured before provider create",
|
||||
"an unresolved source ref rejects before provider create"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
|
||||
"assertions": [
|
||||
"the captured source commit reaches the main-owned provisioned-root adoption request"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ephemeral-vm-resume-integrity.test.ts",
|
||||
"assertions": [
|
||||
"resume allows host and port rotation",
|
||||
"resume rejects connection-mode and stable SSH ownership drift"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -115,10 +154,10 @@
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2,
|
||||
"summary": "Seventy-nine focused store, recipe-process, lifecycle, cleanup-handler, retry-context, provisioning-recovery, and provisioned-root adoption tests passed."
|
||||
"durationSeconds": 3,
|
||||
"summary": "One hundred eight focused store, recipe-process, lifecycle, cleanup-handler, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
@@ -144,7 +183,7 @@
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Ordinary writes remain byte-identical and create no feature sidecar. Reads add one bounded feature-file existence check; sorted feature comparisons avoid unchanged rewrites, and durable companion writes occur only when compatibility metadata changes. Cleanup adds one unrefed timer per active destroy process and clears it on settlement; there is no polling, background scan, or remote fanout."
|
||||
"evidence": "Ordinary writes remain byte-identical and create no feature sidecar. Reads add one bounded feature-file existence check; sorted feature comparisons avoid unchanged rewrites, and durable companion writes occur only when compatibility metadata changes. Cleanup adds one unrefed timer per active destroy process and clears it on settlement. Provisioning adds one source-host Git ref-resolution command only when a ref was requested and runs it before provider create; adoption adds no remote subprocess beyond its existing checkout probes, and resume adds only in-memory comparisons."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Keep the pinned baseline/latest/candidate/revert oracle green in CI.",
|
||||
@@ -156,7 +195,7 @@
|
||||
"No packaged-build downgrade installer journey; the exact released source reader and service are executed in-process.",
|
||||
"Live Docker SSH evidence runs locally on macOS and in the changed-file PR E2E lane on Ubuntu; Windows desktop behavior is covered by the platform-neutral filesystem codec and package job."
|
||||
],
|
||||
"demotionRule": "Demote if a new write fails the pinned rollback reader, a downgrade lifecycle mutation is lost after re-upgrade, malformed/future metadata overwrites recoverable bytes, ordinary v1 bytes change, a destroy can remain permanently in flight, cleanup loses retry ownership, or the focused gate flakes without an identified harness defect."
|
||||
"demotionRule": "Demote if a new write fails the pinned rollback reader, a downgrade lifecycle mutation is lost after re-upgrade, malformed/future metadata overwrites recoverable bytes, ordinary v1 bytes change, a destroy can remain permanently in flight, cleanup loses retry ownership, an unrecoverable context leaves a permanent hidden SSH registration, or the focused gate flakes without an identified harness defect."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.shell-ready-exec-prompt-fallback",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
EphemeralVmRecipeConnection,
|
||||
EphemeralVmRecipeResult
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
|
||||
|
||||
type RecipeSshTarget = Extract<EphemeralVmRecipeConnection, { type: 'ssh' }>['target']
|
||||
|
||||
describe('getProvisionedRootResumeIntegrityError', () => {
|
||||
it('allows an SSH endpoint to rotate while preserving its ownership identity', () => {
|
||||
const previous = provisionedSshResult()
|
||||
const resumed = provisionedSshResult({ host: 'new-host', port: 2222, label: 'Resumed VM' })
|
||||
|
||||
expect(getProvisionedRootResumeIntegrityError(previous, resumed)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['config host', { configHost: 'other-alias' }],
|
||||
['username', { username: 'other-user' }],
|
||||
['identity file', { identityFile: '/keys/other' }],
|
||||
['identity agent', { identityAgent: '/agents/other' }],
|
||||
['identities-only setting', { identitiesOnly: true }],
|
||||
['proxy command', { proxyCommand: 'ssh proxy' }],
|
||||
['jump host', { jumpHost: 'bastion' }]
|
||||
])('rejects changed SSH %s ownership', (_label, target) => {
|
||||
expect(
|
||||
getProvisionedRootResumeIntegrityError(provisionedSshResult(), provisionedSshResult(target))
|
||||
).toContain('SSH ownership changed')
|
||||
})
|
||||
|
||||
it('rejects a resume that changes the connection type', () => {
|
||||
const resumed: EphemeralVmRecipeResult = {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'orca-server',
|
||||
pairingCode: 'pairing-code',
|
||||
projectRoot: '/workspace/orca'
|
||||
}
|
||||
}
|
||||
|
||||
expect(getProvisionedRootResumeIntegrityError(provisionedSshResult(), resumed)).toContain(
|
||||
'connection type changed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function provisionedSshResult(target: Partial<RecipeSshTarget> = {}): EphemeralVmRecipeResult {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/orca',
|
||||
target: {
|
||||
label: 'VM',
|
||||
host: 'host',
|
||||
port: 22,
|
||||
username: 'orca',
|
||||
identityFile: '/keys/orca',
|
||||
identityAgent: '/agents/orca',
|
||||
...target
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,58 @@
|
||||
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
||||
import {
|
||||
getEphemeralVmRecipeResultCheckoutMode,
|
||||
getEphemeralVmRecipeResultConnection,
|
||||
getEphemeralVmRecipeResultProjectRoot,
|
||||
type EphemeralVmRecipeResult
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
|
||||
export function provisionedRootChangedDuringResume(
|
||||
export function getProvisionedRootResumeIntegrityError(
|
||||
previous: EphemeralVmRecipeResult,
|
||||
resumed: EphemeralVmRecipeResult
|
||||
): boolean {
|
||||
): string | null {
|
||||
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
return (
|
||||
if (
|
||||
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
|
||||
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
|
||||
)
|
||||
) {
|
||||
return 'The provisioned workspace root changed while the runtime was suspended.'
|
||||
}
|
||||
const previousConnection = getEphemeralVmRecipeResultConnection(previous)
|
||||
const resumedConnection = getEphemeralVmRecipeResultConnection(resumed)
|
||||
if (previousConnection.type !== resumedConnection.type) {
|
||||
return 'The provisioned workspace connection type changed while the runtime was suspended.'
|
||||
}
|
||||
if (
|
||||
previousConnection.type === 'ssh' &&
|
||||
resumedConnection.type === 'ssh' &&
|
||||
sshOwnershipChanged(previousConnection.target, resumedConnection.target)
|
||||
) {
|
||||
return 'The provisioned workspace SSH ownership changed while the runtime was suspended.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SSH_OWNERSHIP_FIELDS = [
|
||||
'configHost',
|
||||
'username',
|
||||
'identityFile',
|
||||
'identityAgent',
|
||||
'identitiesOnly',
|
||||
'proxyCommand',
|
||||
'jumpHost'
|
||||
] as const
|
||||
|
||||
function sshOwnershipChanged(
|
||||
previous: Extract<
|
||||
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
|
||||
{ type: 'ssh' }
|
||||
>['target'],
|
||||
resumed: Extract<
|
||||
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
|
||||
{ type: 'ssh' }
|
||||
>['target']
|
||||
): boolean {
|
||||
return SSH_OWNERSHIP_FIELDS.some((field) => previous[field] !== resumed[field])
|
||||
}
|
||||
|
||||
@@ -571,4 +571,69 @@ describe('ephemeral VM runtime service', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects provisioned-root connection-mode drift during resume', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
|
||||
const resumePath = join(repoPath, 'resume.js')
|
||||
writeFileSync(
|
||||
resumePath,
|
||||
`console.log(${JSON.stringify(
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'orca-server',
|
||||
pairingCode: makePairingCode(),
|
||||
projectRoot: '/workspace/original'
|
||||
}
|
||||
})
|
||||
)})`
|
||||
)
|
||||
const recipe: OrcaVmRecipe = {
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
checkoutMode: 'provisioned-root',
|
||||
create: 'unused',
|
||||
resume: nodeCommand(resumePath),
|
||||
destroyDisabled: true
|
||||
}
|
||||
upsertEphemeralVmRuntime(userDataPath, {
|
||||
id: 'runtime-1',
|
||||
recipeId: recipe.id,
|
||||
recipe,
|
||||
status: 'suspended',
|
||||
connectionMode: 'ssh',
|
||||
cleanupStatus: 'disabled',
|
||||
cleanupDisabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
recipeResult: {
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/original',
|
||||
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const resumed = await resumeEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe,
|
||||
runtimeId: 'runtime-1'
|
||||
})
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
ok: false,
|
||||
error: 'The provisioned workspace connection type changed while the runtime was suspended.',
|
||||
runtime: {
|
||||
status: 'resume_failed',
|
||||
connectionMode: 'ssh',
|
||||
recipeResult: { connection: { type: 'ssh' } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
persistProvisionedEphemeralVmRuntime,
|
||||
prepareEphemeralVmCompatibilityPersistence
|
||||
} from './ephemeral-vm-runtime-provisioning-persistence'
|
||||
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
|
||||
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
|
||||
|
||||
export type ProvisionEphemeralVmRuntimeArgs = {
|
||||
userDataPath: string
|
||||
@@ -271,13 +271,15 @@ export async function resumeEphemeralVmRuntime(
|
||||
return { ok: false, runtime: failed, error: resume.error }
|
||||
}
|
||||
|
||||
if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) {
|
||||
const error = 'The provisioned workspace root changed while the runtime was suspended.'
|
||||
const resumeIntegrityError = resume.skipped
|
||||
? null
|
||||
: getProvisionedRootResumeIntegrityError(existing.recipeResult, resume.result)
|
||||
if (resumeIntegrityError) {
|
||||
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
status: 'resume_failed',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return { ok: false, runtime: failed, error }
|
||||
return { ok: false, runtime: failed, error: resumeIntegrityError }
|
||||
}
|
||||
|
||||
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
|
||||
const { connectRuntimeOwnedSshTargetMock, getPathMock, handleMock, removeHandlerMock } = vi.hoisted(
|
||||
() => ({
|
||||
connectRuntimeOwnedSshTargetMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock },
|
||||
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
|
||||
}))
|
||||
|
||||
vi.mock('../ephemeral-vm-runtime-ssh', () => ({
|
||||
connectRuntimeOwnedSshTarget: connectRuntimeOwnedSshTargetMock,
|
||||
disconnectRuntimeOwnedSshTarget: vi.fn(),
|
||||
removeRuntimeOwnedSshTarget: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerEphemeralVmHandlers } from './ephemeral-vm'
|
||||
|
||||
let userDataPath: string
|
||||
let repoPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-user-data-'))
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-repo-'))
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
|
||||
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
|
||||
targetId: 'runtime-ssh-ref-test',
|
||||
target: { id: 'runtime-ssh-ref-test' }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
rmSync(repoPath, { recursive: true, force: true })
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('captures the source ref commit before a provisioned-root recipe starts', async () => {
|
||||
const expectedRefHead = createGitFixtureCommit(repoPath)
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
writeRecipe(repoPath, createCountPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({ ref: 'HEAD' })
|
||||
|
||||
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
|
||||
expect(readFileSync(createCountPath, 'utf8')).toBe('x')
|
||||
})
|
||||
|
||||
it('rejects an unresolved source ref before the recipe creates resources', async () => {
|
||||
createGitFixtureCommit(repoPath)
|
||||
const createCountPath = join(repoPath, 'create-count.txt')
|
||||
writeRecipe(repoPath, createCountPath)
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
|
||||
const result = await provision({ ref: 'refs/heads/missing' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Could not resolve provisioned-root start ref: refs/heads/missing'
|
||||
})
|
||||
expect(() => readFileSync(createCountPath, 'utf8')).toThrow()
|
||||
})
|
||||
|
||||
function provision(args: { ref: string }): Promise<unknown> {
|
||||
return handlers.get('ephemeralVm:provision')?.(null, {
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
branch: 'fix-sandbox',
|
||||
ref: args.ref
|
||||
} as never) as Promise<unknown>
|
||||
}
|
||||
|
||||
function makeStore(path: string) {
|
||||
const repo = { id: 'repo-1', path, displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
|
||||
return {
|
||||
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
|
||||
getRepos: vi.fn(() => [repo]),
|
||||
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
|
||||
}
|
||||
}
|
||||
|
||||
function createGitFixtureCommit(path: string): string {
|
||||
execFileSync('git', ['init'], { cwd: path })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: path })
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: path })
|
||||
writeFileSync(join(path, 'fixture.txt'), 'fixture')
|
||||
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
|
||||
execFileSync('git', ['commit', '-m', 'fixture'], { cwd: path })
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
function writeRecipe(path: string, createCountPath: string): void {
|
||||
const startPath = join(path, 'start.js')
|
||||
writeFileSync(
|
||||
startPath,
|
||||
[
|
||||
`require('node:fs').appendFileSync(${JSON.stringify(createCountPath)}, 'x')`,
|
||||
'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",',
|
||||
'connection:{type:"ssh",projectRoot:"/workspace/repo",',
|
||||
'target:{label:"Sandbox",host:"host",port:22,username:"root"}}}))'
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
join(path, 'orca.yaml'),
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
' checkoutMode: provisioned-root',
|
||||
` create: ${JSON.stringify(`"${process.execPath}" "${startPath}"`)}`,
|
||||
' destroy: none'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getLocalProjectGitExecOptions } from '../project-runtime-git-options'
|
||||
|
||||
const activeProvisionControllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -52,6 +54,7 @@ export type EphemeralVmProvisionIpcResult =
|
||||
connectionType: 'ssh'
|
||||
runtime: EphemeralVmRuntimeRecord
|
||||
sshTargetId: string
|
||||
expectedRefHead?: string
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
@@ -132,6 +135,26 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
recipe.checkoutMode,
|
||||
repo.repo.gitRemoteIdentity?.remoteUrl
|
||||
)
|
||||
let expectedRefHead: string | undefined
|
||||
if (recipe.checkoutMode === 'provisioned-root' && args.ref) {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', '--end-of-options', `${args.ref}^{commit}`],
|
||||
getLocalProjectGitExecOptions(store, repo.repo)
|
||||
)
|
||||
expectedRefHead = stdout.trim() || undefined
|
||||
} catch {
|
||||
expectedRefHead = undefined
|
||||
}
|
||||
if (!expectedRefHead) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve provisioned-root start ref: ${args.ref}`,
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const controller = args.provisionId ? new AbortController() : null
|
||||
if (args.provisionId && controller) {
|
||||
activeProvisionControllers.set(args.provisionId, controller)
|
||||
@@ -194,6 +217,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
connectionType: 'ssh',
|
||||
runtime,
|
||||
sshTargetId: ssh.targetId,
|
||||
...(expectedRefHead ? { expectedRefHead } : {}),
|
||||
stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr),
|
||||
warnings: getEphemeralVmRecipeResultWarnings(result.start.result)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,119 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout on a branch Orca did not request', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested branch')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout that did not start from the requested ref', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]),
|
||||
exec
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'expected-head'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested ref')
|
||||
expect(exec).toHaveBeenCalledOnce()
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an attached requested branch created from a tag or commit ref', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'abc123'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } })
|
||||
})
|
||||
|
||||
it('rejects a requested ref without its source-host commit identity', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), baseBranch: 'missing-ref' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('ref identity is missing')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an explicit branch override as the requested local branch', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), branchNameOverride: 'feature/review' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } })
|
||||
})
|
||||
|
||||
it('rejects a linked worktree and sparse checkout', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const listWorktrees = vi
|
||||
|
||||
@@ -90,6 +90,16 @@ export async function adoptProvisionedRootSshCheckout(args: {
|
||||
if (gitWorktree.isSparse || sparseCheckoutEnabled) {
|
||||
throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.')
|
||||
}
|
||||
const requestedBranch = request.branchNameOverride ?? request.name
|
||||
if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) {
|
||||
throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.")
|
||||
}
|
||||
if (request.baseBranch && !request.expectedRefHead) {
|
||||
throw new Error('The requested provisioned-root ref identity is missing.')
|
||||
}
|
||||
if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) {
|
||||
throw new Error("The recipe projectRoot was not created from Orca's requested ref.")
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
attachEphemeralVmRuntimeToWorkspace({
|
||||
|
||||
@@ -45,6 +45,7 @@ export type EphemeralVmApi = {
|
||||
connectionType: 'ssh'
|
||||
runtime: EphemeralVmRuntimeRecord
|
||||
sshTargetId: string
|
||||
expectedRefHead?: string
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
|
||||
@@ -101,13 +101,14 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
|
||||
expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports an ssh recipe result through the runtime-owned ssh host', async () => {
|
||||
it('carries a provisioned-root source commit through runtime-owned SSH import', async () => {
|
||||
vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({
|
||||
ok: true,
|
||||
connectionType: 'ssh',
|
||||
stderr: 'creating sandbox',
|
||||
warnings: [],
|
||||
sshTargetId: 'runtime-ssh-runtime-1',
|
||||
expectedRefHead: 'abc123',
|
||||
runtime: {
|
||||
id: 'runtime-1',
|
||||
repoId: 'repo-1',
|
||||
@@ -119,7 +120,8 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
recipeResult: {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
checkoutMode: 'provisioned-root',
|
||||
connection: {
|
||||
type: 'ssh',
|
||||
projectRoot: '/workspace/repo',
|
||||
@@ -164,7 +166,8 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
|
||||
setup: { ...setupResult.setup, hostId: 'ssh:runtime-ssh-runtime-1' }
|
||||
},
|
||||
runtimeId: 'runtime-1',
|
||||
checkoutMode: 'orca-worktree',
|
||||
checkoutMode: 'provisioned-root',
|
||||
expectedRefHead: 'abc123',
|
||||
stderr: 'creating sandbox',
|
||||
warnings: []
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ export type PrepareEphemeralVmWorkspaceTargetResult =
|
||||
runtimeId: string
|
||||
checkoutMode: 'orca-worktree' | 'provisioned-root'
|
||||
environmentId?: string
|
||||
expectedRefHead?: string
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
@@ -134,6 +135,11 @@ export async function prepareEphemeralVmWorkspaceTarget(
|
||||
setup,
|
||||
runtimeId: provisioned.runtime.id,
|
||||
checkoutMode,
|
||||
...(checkoutMode === 'provisioned-root' &&
|
||||
provisioned.connectionType === 'ssh' &&
|
||||
provisioned.expectedRefHead
|
||||
? { expectedRefHead: provisioned.expectedRefHead }
|
||||
: {}),
|
||||
stderr: provisioned.stderr,
|
||||
warnings: provisioned.warnings
|
||||
} satisfies PrepareEphemeralVmWorkspaceTargetResult
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { PendingWorktreeCreation } from './pending-worktree-creation'
|
||||
|
||||
const { prepareTargetMock } = vi.hoisted(() => ({ prepareTargetMock: vi.fn() }))
|
||||
|
||||
const store = {
|
||||
repos: [{ id: 'repo-1' }],
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
activePendingCreationId: 'creation-1',
|
||||
updatePendingWorktreeCreation: vi.fn(),
|
||||
setupProjectExistingFolder: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: prepareTargetMock
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
import { prepareRequestForCreate } from './ephemeral-vm-worktree-creation'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.pendingWorktreeCreations = {
|
||||
'creation-1': {
|
||||
creationId: 'creation-1',
|
||||
phase: 'provisioning-vm',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: true,
|
||||
loaderVisible: true,
|
||||
request: {} as never
|
||||
}
|
||||
}
|
||||
globalThis.window = {
|
||||
api: { ephemeralVm: { onProvisionEvent: vi.fn(() => vi.fn()) } }
|
||||
} as never
|
||||
})
|
||||
|
||||
it('carries the captured provisioned-root ref identity into adoption', async () => {
|
||||
prepareTargetMock.mockResolvedValue({
|
||||
ok: true,
|
||||
runtimeId: 'runtime-1',
|
||||
checkoutMode: 'provisioned-root',
|
||||
expectedRefHead: 'abc123',
|
||||
stderr: '',
|
||||
warnings: [],
|
||||
setup: {
|
||||
project: { id: 'project-1' },
|
||||
setup: {
|
||||
id: 'setup-runtime',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:runtime-ssh-runtime-1'
|
||||
},
|
||||
repo: { id: 'repo-runtime', path: '/workspace/repo' }
|
||||
}
|
||||
})
|
||||
|
||||
const prepared = await prepareRequestForCreate('creation-1', {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
baseBranch: 'origin/main',
|
||||
branchNameOverride: 'feature/ref-check',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
ephemeralVmRecipe: {
|
||||
sourceRepoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
projectId: 'github:stablyai/orca',
|
||||
checkoutMode: 'provisioned-root'
|
||||
}
|
||||
})
|
||||
|
||||
expect(prepareTargetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: 'feature/ref-check', ref: 'origin/main' })
|
||||
)
|
||||
expect(prepared).toMatchObject({
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
ephemeralVmExpectedRefHead: 'abc123'
|
||||
})
|
||||
})
|
||||
@@ -79,6 +79,9 @@ export async function prepareRequestForCreate(
|
||||
: getEphemeralVmPortableBaseSelection(request)),
|
||||
ephemeralVmRuntimeId: preparedTarget.runtimeId,
|
||||
ephemeralVmCheckoutMode: preparedTarget.checkoutMode,
|
||||
...(preparedTarget.expectedRefHead
|
||||
? { ephemeralVmExpectedRefHead: preparedTarget.expectedRefHead }
|
||||
: {}),
|
||||
...(preparedTarget.environmentId
|
||||
? { ephemeralVmRuntimeEnvironmentId: preparedTarget.environmentId }
|
||||
: {}),
|
||||
|
||||
@@ -49,6 +49,8 @@ export type WorktreeCreationRequest = {
|
||||
ephemeralVmRuntimeEnvironmentId?: string
|
||||
/** Checkout ownership selected by the provisioned recipe. */
|
||||
ephemeralVmCheckoutMode?: 'orca-worktree' | 'provisioned-root'
|
||||
/** Source-host commit captured before a provisioned-root recipe starts. */
|
||||
ephemeralVmExpectedRefHead?: string
|
||||
/** Recipe to provision before creating the worktree. Kept serializable so
|
||||
* retry can rerun the recipe after a failed create. */
|
||||
ephemeralVmRecipe?: {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('getProvisionedRootCreateOptions', () => {
|
||||
request({
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
ephemeralVmExpectedRefHead: 'abc123',
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
@@ -41,7 +42,8 @@ describe('getProvisionedRootCreateOptions', () => {
|
||||
).toEqual({
|
||||
runtimeId: 'runtime-1',
|
||||
executionHostId: 'ssh:runtime-ssh-one',
|
||||
expectedPath: '/workspace/repo'
|
||||
expectedPath: '/workspace/repo',
|
||||
expectedRefHead: 'abc123'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,5 +53,23 @@ describe('getProvisionedRootCreateOptions', () => {
|
||||
request({ ephemeralVmCheckoutMode: 'provisioned-root', ephemeralVmRuntimeId: 'runtime-1' })
|
||||
)
|
||||
).toThrow('identity is incomplete')
|
||||
|
||||
expect(() =>
|
||||
getProvisionedRootCreateOptions(
|
||||
request({
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
ephemeralVmRuntimeId: 'runtime-1',
|
||||
baseBranch: 'origin/main',
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:runtime-ssh-one',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-runtime',
|
||||
path: '/workspace/repo'
|
||||
}
|
||||
})
|
||||
)
|
||||
).toThrow('identity is incomplete')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ProvisionedRootCreateOptions = {
|
||||
runtimeId: string
|
||||
executionHostId: NonNullable<WorktreeCreationRequest['workspaceRunContext']>['hostId']
|
||||
expectedPath: string
|
||||
expectedRefHead?: string
|
||||
}
|
||||
|
||||
export function getProvisionedRootCreateOptions(
|
||||
@@ -12,12 +13,19 @@ export function getProvisionedRootCreateOptions(
|
||||
if (request.ephemeralVmCheckoutMode !== 'provisioned-root') {
|
||||
return null
|
||||
}
|
||||
if (!request.ephemeralVmRuntimeId || !request.workspaceRunContext) {
|
||||
if (
|
||||
!request.ephemeralVmRuntimeId ||
|
||||
!request.workspaceRunContext ||
|
||||
(request.baseBranch && !request.ephemeralVmExpectedRefHead)
|
||||
) {
|
||||
throw new Error('Provisioned-root workspace identity is incomplete.')
|
||||
}
|
||||
return {
|
||||
runtimeId: request.ephemeralVmRuntimeId,
|
||||
executionHostId: request.workspaceRunContext.hostId,
|
||||
expectedPath: request.workspaceRunContext.path
|
||||
expectedPath: request.workspaceRunContext.path,
|
||||
...(request.ephemeralVmExpectedRefHead
|
||||
? { expectedRefHead: request.ephemeralVmExpectedRefHead }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export type AdoptProvisionedRootArgs = CreateWorktreeArgs & {
|
||||
runtimeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
expectedPath: string
|
||||
expectedRefHead?: string
|
||||
}
|
||||
|
||||
export type CreateWorktreeResult = {
|
||||
|
||||
Reference in New Issue
Block a user