feat(vm): add provisioned root recipe contract (#14352)

This commit is contained in:
Jinwoo Hong
2026-08-13 16:23:21 -07:00
committed by GitHub
parent a5a998ea77
commit 99d19d4635
18 changed files with 586 additions and 6 deletions
@@ -0,0 +1,71 @@
import {
getEphemeralVmRecipeResultConnection,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service'
import {
runEphemeralVmRecipeCleanup,
type EphemeralVmRecipeContext
} from './ephemeral-vm-recipe-runner'
type FailedStart = {
context: EphemeralVmRecipeContext
recipeResult: EphemeralVmRecipeResult
}
export async function cleanupFailedEphemeralVmStart(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<void> {
const cleanupError = await getCleanupError(args, start)
if (cleanupError === null) {
return
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.recipeResult)
upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'cleanup_failed',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'failed',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
cleanupLastAttemptAt: now,
cleanupLastError: cleanupError,
createdAt: now,
updatedAt: now,
recipeResult: start.recipeResult
})
}
async function getCleanupError(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<string | null> {
try {
const cleanup = await runEphemeralVmRecipeCleanup({
repoPath: args.repoPath,
recipe: args.recipe,
context: start.context,
recipeResult: start.recipeResult,
signal: args.signal,
onStdout: args.onStdout,
onStderr: args.onStderr
})
if (cleanup.ok && !cleanup.skipped) {
return null
}
return cleanup.ok
? 'Destroy is disabled for this recipe.'
: (cleanup.error ?? 'Destroy failed.')
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
@@ -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: expected } })
}
)
it('runs a recipe from the repo root and parses its JSON result', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
@@ -107,6 +143,43 @@ describe('runEphemeralVmRecipeStart', () => {
})
})
it('requires the versioned result handshake for provisioned-root recipes', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
writeFileSync(
scriptPath,
[
'console.log(JSON.stringify({',
' schemaVersion: Number(process.env.RESULT_VERSION),',
' ...(process.env.RESULT_VERSION === "2" ? { checkoutMode: "provisioned-root" } : {}),',
` pairingCode: ${JSON.stringify(makePairingCode())},`,
" projectRoot: '/workspace/repo'",
'}))'
].join('\n')
)
const recipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root' as const,
create: nodeCommand(scriptPath)
}
await expect(
runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '1' } })
).resolves.toMatchObject({
ok: false,
error:
'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".',
recipeResult: { schemaVersion: 1 }
})
await expect(
runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '2' } })
).resolves.toMatchObject({
ok: true,
result: { schemaVersion: 2, checkoutMode: 'provisioned-root' }
})
})
it('returns a process failure when the recipe exits nonzero', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
+19
View File
@@ -0,0 +1,19 @@
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultProjectRoot,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
export function provisionedRootChangedDuringResume(
previous: EphemeralVmRecipeResult,
resumed: EphemeralVmRecipeResult
): boolean {
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
return false
}
return (
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
)
}
+194 -2
View File
@@ -3,10 +3,14 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
import {
listEphemeralVmRuntimes,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import {
cleanupEphemeralVmRuntime,
provisionEphemeralVmRuntime
provisionEphemeralVmRuntime,
resumeEphemeralVmRuntime
} from './ephemeral-vm-runtime-service'
import type { OrcaVmRecipe } from '../shared/types'
@@ -174,4 +178,192 @@ describe('ephemeral VM runtime service', () => {
})
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('destroys a provisioned resource when its checkout handshake is incompatible', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
writeFileSync(cleanupPath, "require('fs').writeFileSync('cleanup-ran.txt', 'yes')")
const provisioned = await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
})
expect(provisioned).toMatchObject({
ok: false,
start: {
error:
'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".'
}
})
expect(readFileSync(join(repoPath, 'cleanup-ran.txt'), 'utf8')).toBe('yes')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('persists failed cleanup after an incompatible checkout handshake', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
writeFileSync(cleanupPath, 'process.exit(1)')
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
const provisioned = await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe,
repoId: 'repo-1',
workspaceName: 'Fix Login Race',
now: 1_000
})
expect(provisioned.ok).toBe(false)
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
recipe,
repoId: 'repo-1',
workspaceName: 'Fix Login Race',
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastAttemptAt: 1_000,
cleanupLastError: expect.any(String)
})
])
})
it('persists incompatible resources when destroy is disabled', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
status: 'cleanup_failed',
cleanupStatus: 'disabled',
cleanupDisabled: true,
cleanupLastError: 'Destroy is disabled for this recipe.'
})
])
})
it('rejects a provisioned root that moves 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({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/moved",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' }',
'}))'
].join('\n')
)
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 root changed while the runtime was suspended.',
runtime: {
status: 'resume_failed',
recipeResult: { connection: { projectRoot: '/workspace/original' } }
}
})
})
})
+17
View File
@@ -15,6 +15,8 @@ import {
type EphemeralVmRecipeStartFailure,
type EphemeralVmRecipeStartSuccess
} from './ephemeral-vm-recipe-runner'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
@@ -114,6 +116,12 @@ export async function provisionEphemeralVmRuntime(
onStderr: args.onStderr
})
if (!start.ok) {
if (start.recipeResult) {
await cleanupFailedEphemeralVmStart(args, {
context: start.context,
recipeResult: start.recipeResult
})
}
return { ok: false, start }
}
@@ -274,6 +282,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 failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'resume_failed',
updatedAt: Date.now()
})
return { ok: false, runtime: failed, error }
}
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'running',
...(!resume.skipped ? { recipeResult: resume.result } : {}),
+24
View File
@@ -201,6 +201,7 @@ describe('parseOrcaYaml', () => {
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: provisioned-root',
' description: Starts a per-workspace VM.',
' create: ./scripts/orca-vm/start-cloud-sandbox.sh',
' suspend: ./scripts/orca-vm/suspend-cloud-sandbox.sh',
@@ -214,6 +215,7 @@ describe('parseOrcaYaml', () => {
{
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
description: 'Starts a per-workspace VM.',
create: './scripts/orca-vm/start-cloud-sandbox.sh',
suspend: './scripts/orca-vm/suspend-cloud-sandbox.sh',
@@ -224,6 +226,28 @@ describe('parseOrcaYaml', () => {
})
})
it('rejects environment recipes with an unknown checkout mode', () => {
const yaml = [
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: magic',
' create: ./scripts/create.sh'
].join('\n')
expect(parseOrcaYaml(yaml)).toEqual({
scripts: {},
environmentRecipeDiagnostics: [
{
index: 0,
field: 'checkoutMode',
message:
'Recipe "cloud-sandbox" checkoutMode must be "orca-worktree" or "provisioned-root".'
}
]
})
})
it('parses legacy environmentRecipes command and cleanup aliases', () => {
const yaml = [
'environmentRecipes:',
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
getEphemeralVmRecipeCheckoutModeError,
getEphemeralVmRecipeResultSchemaVersion
} from './ephemeral-vm-recipe-checkout-mode'
import type { OrcaVmRecipe } from './types'
const defaultRecipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: './create.sh'
}
describe('ephemeral VM recipe checkout mode', () => {
it('keeps existing recipes on schema version 1', () => {
expect(getEphemeralVmRecipeResultSchemaVersion(defaultRecipe)).toBe(1)
expect(
getEphemeralVmRecipeCheckoutModeError(defaultRecipe, {
schemaVersion: 1,
pairingCode: 'orca://pair?code=test',
projectRoot: '/workspace/repo'
})
).toBeNull()
})
it('requires both sides to opt in to provisioned-root', () => {
const provisionedRootResult = {
schemaVersion: 2 as const,
checkoutMode: 'provisioned-root' as const,
pairingCode: 'orca://pair?code=test',
projectRoot: '/workspace/repo'
}
expect(getEphemeralVmRecipeCheckoutModeError(defaultRecipe, provisionedRootResult)).toBe(
'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.'
)
expect(
getEphemeralVmRecipeCheckoutModeError(
{ ...defaultRecipe, checkoutMode: 'provisioned-root' },
provisionedRootResult
)
).toBeNull()
})
})
@@ -0,0 +1,21 @@
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
): string | null {
const configuredMode = recipe.checkoutMode ?? 'orca-worktree'
const resultMode = getEphemeralVmRecipeResultCheckoutMode(result)
if (configuredMode === resultMode) {
return null
}
return configuredMode === 'provisioned-root'
? 'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".'
: 'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.'
}
@@ -38,6 +38,7 @@ describe('runRecipeCommand', () => {
command: nodeCommand(scriptPath),
repoPath,
mode: 'create',
resultSchemaVersion: 1,
context: {
recipeId: 'cloud-sandbox',
repoPath
@@ -71,6 +72,7 @@ describe('runRecipeCommand', () => {
command: nodeCommand(scriptPath),
repoPath,
mode: 'create',
resultSchemaVersion: 1,
context: {
recipeId: 'cloud-sandbox',
repoPath
+5 -2
View File
@@ -25,6 +25,7 @@ export async function runRecipeCommand(args: {
repoPath: string
context: EphemeralVmRecipeContext
mode: 'create' | 'suspend' | 'resume' | 'destroy'
resultSchemaVersion: 1 | 2
stdin?: string
env?: NodeJS.ProcessEnv
maxCaptureBytes?: number
@@ -42,7 +43,7 @@ export async function runRecipeCommand(args: {
child = spawnCommand(args.command, {
cwd: args.repoPath,
detached: process.platform !== 'win32',
env: buildRecipeEnv(args.env, args.mode, args.context),
env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion),
shell: true,
windowsHide: true
}) as ChildProcessWithoutNullStreams
@@ -123,7 +124,8 @@ function killRecipeProcess(child: ChildProcessWithoutNullStreams): void {
function buildRecipeEnv(
env: NodeJS.ProcessEnv | undefined,
mode: 'create' | 'suspend' | 'resume' | 'destroy',
context: EphemeralVmRecipeContext
context: EphemeralVmRecipeContext,
resultSchemaVersion: 1 | 2
): NodeJS.ProcessEnv {
return {
...process.env,
@@ -138,6 +140,7 @@ function buildRecipeEnv(
ORCA_REPO_URL: context.repoUrl ?? '',
ORCA_REPO_BRANCH: context.branch ?? '',
ORCA_REPO_REF: context.ref ?? '',
ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion),
ORCA_VERSION: context.orcaVersion ?? ''
}
}
+30
View File
@@ -3,6 +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,
getEphemeralVmRecipeResultSchemaVersion
} from './ephemeral-vm-recipe-checkout-mode'
import { runRecipeCommand } from './ephemeral-vm-recipe-process'
import {
buildEphemeralVmRecipeCleanupPayload,
@@ -56,6 +60,7 @@ export type EphemeralVmRecipeStartFailure = {
stderr: string
exitCode: number | null
signal: NodeJS.Signals | null
recipeResult?: EphemeralVmRecipeResult
}
export type EphemeralVmRecipeStartResult =
@@ -108,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,
@@ -134,6 +140,16 @@ export async function runEphemeralVmRecipeStart(
...processResult
}
}
const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result)
if (checkoutModeError) {
return {
ok: false,
context,
error: checkoutModeError,
recipeResult: parsed.result,
...processResult
}
}
return {
ok: true,
@@ -158,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,
@@ -193,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,
@@ -234,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,
@@ -263,6 +282,17 @@ export async function runEphemeralVmRecipeResume(
...processResult
}
}
const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result)
if (checkoutModeError) {
return {
ok: false,
skipped: false,
context: args.context,
error: checkoutModeError,
recipeResult: parsed.result,
...processResult
}
}
return {
ok: true,
+37
View File
@@ -127,6 +127,43 @@ describe('parseEphemeralVmRecipeResult', () => {
})
})
it('parses the provisioned-root version handshake', () => {
const result = parseEphemeralVmRecipeResult(
JSON.stringify({
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: 'C:\\workspace\\repo',
target: {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
}
}
})
)
expect(result).toEqual({
ok: true,
result: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: 'C:\\workspace\\repo',
target: {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
}
}
}
})
})
it('rejects ssh results with relative project roots', () => {
expect(
parseEphemeralVmRecipeResult(
+28 -1
View File
@@ -103,9 +103,30 @@ export const EphemeralVmRecipeConnectionResultSchema = z
})
.strict()
export const EphemeralVmRecipeProvisionedRootLegacyResultSchema = z
.object({
schemaVersion: z.literal(2),
checkoutMode: z.literal('provisioned-root'),
pairingCode: z.string().min(1),
projectRoot: z.string().min(1),
userData: z.record(z.string(), JsonValueSchema).optional()
})
.strict()
export const EphemeralVmRecipeProvisionedRootConnectionResultSchema = z
.object({
schemaVersion: z.literal(2),
checkoutMode: z.literal('provisioned-root'),
connection: EphemeralVmRecipeConnectionSchema,
userData: z.record(z.string(), JsonValueSchema).optional()
})
.strict()
export const EphemeralVmRecipeResultSchema = z.union([
EphemeralVmRecipeLegacyResultSchema,
EphemeralVmRecipeConnectionResultSchema
EphemeralVmRecipeConnectionResultSchema,
EphemeralVmRecipeProvisionedRootLegacyResultSchema,
EphemeralVmRecipeProvisionedRootConnectionResultSchema
])
export type EphemeralVmRecipeResult = z.infer<typeof EphemeralVmRecipeResultSchema>
@@ -173,6 +194,12 @@ export function getEphemeralVmRecipeResultProjectRoot(result: EphemeralVmRecipeR
return getEphemeralVmRecipeResultConnection(result).projectRoot
}
export function getEphemeralVmRecipeResultCheckoutMode(
result: EphemeralVmRecipeResult
): 'orca-worktree' | 'provisioned-root' {
return result.schemaVersion === 2 ? 'provisioned-root' : 'orca-worktree'
}
export function getEphemeralVmRecipeResultPairingCode(
result: EphemeralVmRecipeResult
): string | null {
+1
View File
@@ -32,6 +32,7 @@ const EphemeralVmRuntimeRecipeSchema = z
id: z.string().min(1),
name: z.string().min(1),
create: z.string().min(1),
checkoutMode: z.enum(['orca-worktree', 'provisioned-root']).optional(),
description: z.string().min(1).optional(),
suspend: z.string().min(1).optional(),
resume: z.string().min(1).optional(),
+10
View File
@@ -164,6 +164,15 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult {
}
seenIds.add(id)
const description = asTrimmedString(record.description)
const checkoutMode = asTrimmedString(record.checkoutMode)
if (checkoutMode && checkoutMode !== 'orca-worktree' && checkoutMode !== 'provisioned-root') {
diagnostics.push({
index,
field: 'checkoutMode',
message: `Recipe "${id}" checkoutMode must be "orca-worktree" or "provisioned-root".`
})
return null
}
const suspend = asTrimmedString(record.suspend)
const resume = asTrimmedString(record.resume)
const destroyValue = asTrimmedString(record.destroy) ?? asTrimmedString(record.cleanup)
@@ -172,6 +181,7 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult {
id,
name,
create,
...(checkoutMode ? { checkoutMode } : {}),
...(description ? { description } : {}),
...(suspend ? { suspend } : {}),
...(resume ? { resume } : {}),
@@ -11,6 +11,7 @@ describe('plugin VM recipe artifacts', () => {
schemaVersion: 1,
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: './scripts/create.sh',
suspend: './scripts/suspend.sh',
resume: './scripts/resume.sh',
@@ -18,7 +19,11 @@ describe('plugin VM recipe artifacts', () => {
})
)
expect(recipe).toMatchObject({ id: 'cloud-sandbox', name: 'Cloud Sandbox' })
expect(recipe).toMatchObject({
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root'
})
expect(listPluginVmRecipeCommands(recipe)).toEqual([
{ phase: 'create', command: './scripts/create.sh' },
{ phase: 'suspend', command: './scripts/suspend.sh' },
@@ -15,6 +15,7 @@ const pluginVmRecipeArtifactSchema = z
id: z.string().regex(ORCA_VM_RECIPE_ID_PATTERN, ORCA_VM_RECIPE_ID_RULE),
name: z.string().trim().min(1).max(128),
description: z.string().trim().min(1).max(1024).optional(),
checkoutMode: z.enum(['orca-worktree', 'provisioned-root']).optional(),
create: recipeCommandSchema,
suspend: recipeCommandSchema.optional(),
resume: recipeCommandSchema.optional(),
@@ -43,6 +44,7 @@ export function parsePluginVmRecipeArtifact(raw: string): OrcaVmRecipe {
id: parsed.id,
name: parsed.name,
create: parsed.create,
...(parsed.checkoutMode ? { checkoutMode: parsed.checkoutMode } : {}),
...(parsed.description ? { description: parsed.description } : {}),
...(parsed.suspend ? { suspend: parsed.suspend } : {}),
...(parsed.resume ? { resume: parsed.resume } : {}),
+3
View File
@@ -2218,10 +2218,13 @@ export type OrcaDefaultTabTemplate = {
command?: string
}
export type EphemeralVmCheckoutMode = 'orca-worktree' | 'provisioned-root'
export type OrcaVmRecipe = {
id: string
name: string
create: string
checkoutMode?: EphemeralVmCheckoutMode
description?: string
suspend?: string
resume?: string