mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(vm): add provisioned root recipe contract (#14352)
This commit is contained in:
@@ -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