mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(vm): add provisioned root recipe contract (#14352)
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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' } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user