mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(i18n): code ephemeral VM provisioning and cleanup failures (#35747)
* fix(i18n): code ephemeral VM provisioning and cleanup failures * fix(i18n): relocalize recipe-list errors on language change --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,13 @@ import {
|
||||
upsertEphemeralVmRuntime,
|
||||
upsertEphemeralVmRuntimeRollbackRecovery
|
||||
} from '../../shared/ephemeral-vm-runtime-store'
|
||||
import {
|
||||
toCleanupLastErrorFields,
|
||||
ephemeralVmFailure,
|
||||
ephemeralVmFailureFromError,
|
||||
pickEphemeralVmFailureCode,
|
||||
type EphemeralVmFailure
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
import type { ProvisionEphemeralVmRuntimeArgs } from './runtime-service'
|
||||
import {
|
||||
runEphemeralVmRecipeCleanup,
|
||||
@@ -42,7 +49,7 @@ export async function cleanupFailedEphemeralVmStart(
|
||||
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'failed',
|
||||
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
|
||||
cleanupLastAttemptAt: now,
|
||||
cleanupLastError: cleanupError,
|
||||
...toCleanupLastErrorFields(cleanupError),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
recipeResult: start.recipeResult
|
||||
@@ -62,7 +69,7 @@ export async function cleanupFailedEphemeralVmStart(
|
||||
async function getCleanupError(
|
||||
args: ProvisionEphemeralVmRuntimeArgs,
|
||||
start: FailedStart
|
||||
): Promise<string | null> {
|
||||
): Promise<EphemeralVmFailure | null> {
|
||||
try {
|
||||
const cleanup = await runEphemeralVmRecipeCleanup({
|
||||
repoPath: args.repoPath,
|
||||
@@ -76,10 +83,13 @@ async function getCleanupError(
|
||||
if (cleanup.ok && !cleanup.skipped) {
|
||||
return null
|
||||
}
|
||||
return cleanup.ok
|
||||
? 'Destroy is disabled for this recipe.'
|
||||
: (cleanup.error ?? 'Destroy failed.')
|
||||
if (cleanup.ok) {
|
||||
return ephemeralVmFailure('destroyDisabled')
|
||||
}
|
||||
return cleanup.error
|
||||
? { error: cleanup.error, ...pickEphemeralVmFailureCode(cleanup) }
|
||||
: ephemeralVmFailure('destroyFailed')
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
return ephemeralVmFailureFromError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
} from './runtime-provisioning-persistence'
|
||||
import { getProvisionedRootResumeIntegrityError } from './resume-integrity'
|
||||
import { runControlledEphemeralVmRuntimeCleanup } from './runtime-cleanup-control'
|
||||
import {
|
||||
ephemeralVmFailure,
|
||||
pickEphemeralVmFailureCode
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
|
||||
export type ProvisionEphemeralVmRuntimeArgs = {
|
||||
userDataPath: string
|
||||
@@ -182,13 +186,16 @@ async function cleanupEphemeralVmRuntimeOnce(
|
||||
})
|
||||
|
||||
if (!cleanup.ok) {
|
||||
const failure = cleanup.error
|
||||
? { error: cleanup.error, ...pickEphemeralVmFailureCode(cleanup) }
|
||||
: ephemeralVmFailure('destroyFailed')
|
||||
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
cleanupLastError: cleanup.error ?? 'Destroy failed.',
|
||||
cleanupLastError: failure,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return { ok: false, runtime: failed, error: cleanup.error ?? 'Destroy failed.' }
|
||||
return { ok: false, runtime: failed, error: failure.error }
|
||||
}
|
||||
|
||||
const cleaned = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
updateEphemeralVmRuntimeStatus
|
||||
} from '../../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
|
||||
import { ephemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
|
||||
|
||||
export async function removeEphemeralVmRuntimeSshTarget(args: {
|
||||
userDataPath: string
|
||||
@@ -22,7 +23,7 @@ export async function removeEphemeralVmRuntimeSshTarget(args: {
|
||||
}
|
||||
return updateEphemeralVmRuntimeStatus(args.userDataPath, latest.id, {
|
||||
status: 'cleanup_failed',
|
||||
cleanupLastError: 'Failed to remove the hidden SSH target.'
|
||||
cleanupLastError: ephemeralVmFailure('sshTargetRemoveFailed')
|
||||
})
|
||||
}
|
||||
const latest = getCurrentRuntime(args.userDataPath, current)
|
||||
|
||||
@@ -57,7 +57,13 @@ it('cancels source resolution before a provisioned-root recipe creates resources
|
||||
const result = await provision
|
||||
|
||||
expect(cancelled).toEqual({ cancelled: true })
|
||||
expect(result).toEqual({ ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' })
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Provisioning cancelled.',
|
||||
errorCode: 'provisionCancelled',
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
})
|
||||
expect(existsSync(createMarker)).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store
|
||||
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
|
||||
import { isFolderRepo, isGitRepoKind } from '../../shared/repo-kind'
|
||||
import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types'
|
||||
import {
|
||||
EphemeralVmFailureError,
|
||||
ephemeralVmFailure,
|
||||
type EphemeralVmFailure
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
|
||||
export type EphemeralVmRecipeListResult = {
|
||||
status: 'ok' | 'error'
|
||||
@@ -12,6 +17,8 @@ export type EphemeralVmRecipeListResult = {
|
||||
recipes: OrcaVmRecipe[]
|
||||
diagnostics: NonNullable<ReturnType<typeof loadHooks>>['environmentRecipeDiagnostics']
|
||||
message?: string
|
||||
messageCode?: string
|
||||
messageParams?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export type EphemeralVmRecipeCatalogEntry = {
|
||||
@@ -24,7 +31,12 @@ export type EphemeralVmRecipeCatalogEntry = {
|
||||
|
||||
export type RecipeRepoResult =
|
||||
| { ok: true; repo: Exclude<ReturnType<Store['getRepo']>, null | undefined> }
|
||||
| { ok: false; message: string; doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult }
|
||||
| {
|
||||
ok: false
|
||||
message: string
|
||||
failure: EphemeralVmFailure
|
||||
doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult
|
||||
}
|
||||
|
||||
export function listRecipes(
|
||||
store: Store,
|
||||
@@ -33,22 +45,10 @@ export function listRecipes(
|
||||
): EphemeralVmRecipeListResult {
|
||||
const repo = store.getRepo(repoId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return {
|
||||
status: 'error',
|
||||
repoPath: null,
|
||||
recipes: [],
|
||||
diagnostics: [],
|
||||
message: `Repo not found: ${repoId}`
|
||||
}
|
||||
return failedRecipeList(null, ephemeralVmFailure('repoNotFound', { repoId }))
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
return {
|
||||
status: 'error',
|
||||
repoPath: repo.path,
|
||||
recipes: [],
|
||||
diagnostics: [],
|
||||
message: 'Ephemeral VM recipes run on the local desktop host in v1.'
|
||||
}
|
||||
return failedRecipeList(repo.path, ephemeralVmFailure('remoteRepoUnsupported'))
|
||||
}
|
||||
const hooks = loadHooks(repo.path)
|
||||
return {
|
||||
@@ -82,10 +82,10 @@ export function listRecipeCatalog(
|
||||
export function getRecipeRepo(store: Store, repoId: string): RecipeRepoResult {
|
||||
const repo = store.getRepo(repoId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return failedRecipeRepo(null, `Repo not found: ${repoId}`)
|
||||
return failedRecipeRepo(null, ephemeralVmFailure('repoNotFound', { repoId }))
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
return failedRecipeRepo(repo.path, 'Ephemeral VM recipes run on the local desktop host in v1.')
|
||||
return failedRecipeRepo(repo.path, ephemeralVmFailure('remoteRepoUnsupported'))
|
||||
}
|
||||
return { ok: true, repo }
|
||||
}
|
||||
@@ -101,14 +101,14 @@ export function getRuntimeRecipeContext(
|
||||
} {
|
||||
const runtime = listEphemeralVmRuntimes(userDataPath).find((entry) => entry.id === runtimeId)
|
||||
if (!runtime) {
|
||||
throw new Error(`Unknown ephemeral VM runtime: ${runtimeId}`)
|
||||
throw new EphemeralVmFailureError(ephemeralVmFailure('runtimeNotFound', { runtimeId }))
|
||||
}
|
||||
if (!runtime.repoId) {
|
||||
throw new Error(`Ephemeral VM runtime has no repo id: ${runtimeId}`)
|
||||
throw new EphemeralVmFailureError(ephemeralVmFailure('runtimeRepoMissing', { runtimeId }))
|
||||
}
|
||||
const repo = getRecipeRepo(store, runtime.repoId)
|
||||
if (!repo.ok) {
|
||||
throw new Error(repo.message)
|
||||
throw new EphemeralVmFailureError(repo.failure)
|
||||
}
|
||||
// Legacy runtimes lack snapshots; never substitute a newer same-ID plugin recipe for their repo-owned recipe.
|
||||
const recipe =
|
||||
@@ -117,7 +117,9 @@ export function getRuntimeRecipeContext(
|
||||
(entry) => entry.id === runtime.recipeId
|
||||
)
|
||||
if (!recipe) {
|
||||
throw new Error(`Recipe not found: ${runtime.recipeId}`)
|
||||
throw new EphemeralVmFailureError(
|
||||
ephemeralVmFailure('recipeNotFound', { recipeId: runtime.recipeId })
|
||||
)
|
||||
}
|
||||
return { runtime, repo, recipe }
|
||||
}
|
||||
@@ -144,10 +146,12 @@ export function combineEphemeralVmRecipes(
|
||||
return [...repoRecipes, ...pluginRecipes.filter((recipe) => !repoIds.has(recipe.id))]
|
||||
}
|
||||
|
||||
function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoResult {
|
||||
function failedRecipeRepo(repoPath: string | null, failure: EphemeralVmFailure): RecipeRepoResult {
|
||||
const message = failure.error
|
||||
return {
|
||||
ok: false,
|
||||
message,
|
||||
failure,
|
||||
doctor: (recipeId) => ({
|
||||
recipeId,
|
||||
repoPath: repoPath ?? '',
|
||||
@@ -163,3 +167,18 @@ function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoR
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function failedRecipeList(
|
||||
repoPath: string | null,
|
||||
failure: EphemeralVmFailure
|
||||
): EphemeralVmRecipeListResult {
|
||||
return {
|
||||
status: 'error',
|
||||
repoPath,
|
||||
recipes: [],
|
||||
diagnostics: [],
|
||||
message: failure.error,
|
||||
...(failure.errorCode ? { messageCode: failure.errorCode } : {}),
|
||||
...(failure.errorParams ? { messageParams: failure.errorParams } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
removeRuntimeOwnedSshTarget
|
||||
} from '../ephemeral-vm/runtime-ssh'
|
||||
import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
|
||||
import {
|
||||
ephemeralVmFailure,
|
||||
ephemeralVmFailureFromError
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
import { invalidateRuntimeEnvironmentTransport } from './runtime-environments/runtime-environments'
|
||||
import { retireRemovedRuntimeEnvironment } from './runtime-environments/runtime-environment-removal-cleanup'
|
||||
import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm/runtime-attachment'
|
||||
@@ -86,7 +90,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
cleanupLastAttemptAt: Date.now(),
|
||||
cleanupLastError: error instanceof Error ? error.message : String(error)
|
||||
cleanupLastError: ephemeralVmFailureFromError(error)
|
||||
})
|
||||
return removeEphemeralVmRuntimeSshTarget({
|
||||
userDataPath,
|
||||
@@ -151,7 +155,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
|
||||
status: 'cleanup_failed',
|
||||
cleanupStatus: 'failed',
|
||||
cleanupLastError: 'Cleanup was interrupted. Retry cleanup to continue.'
|
||||
cleanupLastError: ephemeralVmFailure('cleanupInterrupted')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -36,6 +36,11 @@ import type { PluginService } from '../plugins/plugin-service'
|
||||
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
|
||||
import { resolveProvisionedRootSource } from '../ephemeral-vm/provisioned-root-source'
|
||||
import { getRuntimeEnvironmentRegistryRoot } from './runtime-environments/runtime-environment-registry-root'
|
||||
import {
|
||||
ephemeralVmFailure,
|
||||
pickEphemeralVmFailureCode,
|
||||
type EphemeralVmFailure
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
|
||||
const activeProvisionControllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -59,12 +64,11 @@ export type EphemeralVmProvisionIpcResult =
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
| {
|
||||
| ({
|
||||
ok: false
|
||||
error: string
|
||||
stderr: string
|
||||
stdout: string
|
||||
}
|
||||
} & EphemeralVmFailure)
|
||||
|
||||
export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void {
|
||||
ipcMain.removeHandler('ephemeralVm:listRecipes')
|
||||
@@ -122,7 +126,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
): Promise<EphemeralVmProvisionIpcResult> => {
|
||||
const repo = getRecipeRepo(store, args.repoId)
|
||||
if (!repo.ok) {
|
||||
return { ok: false, error: repo.message, stdout: '', stderr: '' }
|
||||
return provisionFailure(repo.failure)
|
||||
}
|
||||
const recipe = resolveRecipeForRepo(
|
||||
repo.repo.path,
|
||||
@@ -130,7 +134,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
await getApprovedPluginVmRecipes(pluginService)
|
||||
)
|
||||
if (!recipe) {
|
||||
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
|
||||
return provisionFailure(ephemeralVmFailure('recipeNotFound', { recipeId: args.recipeId }))
|
||||
}
|
||||
const controller = args.provisionId ? new AbortController() : null
|
||||
if (args.provisionId && controller) {
|
||||
@@ -159,17 +163,14 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
controller?.signal
|
||||
)
|
||||
if (controller?.signal.aborted) {
|
||||
return { ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' }
|
||||
return provisionFailure(ephemeralVmFailure('provisionCancelled'))
|
||||
}
|
||||
if (!source) {
|
||||
return {
|
||||
ok: false,
|
||||
error: args.ref
|
||||
? `Could not resolve provisioned-root start ref: ${args.ref}`
|
||||
: 'Could not resolve a default provisioned-root start ref.',
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
}
|
||||
return provisionFailure(
|
||||
args.ref
|
||||
? ephemeralVmFailure('startRefUnresolved', { ref: args.ref })
|
||||
: ephemeralVmFailure('defaultStartRefUnresolved')
|
||||
)
|
||||
}
|
||||
sourceRef = source.ref
|
||||
expectedRefHead = source.head
|
||||
@@ -196,6 +197,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
return {
|
||||
ok: false,
|
||||
error: result.start.error,
|
||||
...pickEphemeralVmFailureCode(result.start),
|
||||
stdout: redactEphemeralVmRecipeDiagnosticText(result.start.stdout),
|
||||
stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr)
|
||||
}
|
||||
@@ -298,3 +300,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
function buildEphemeralEnvironmentName(repoName: string, runtimeId: string): string {
|
||||
return `${repoName} VM ${runtimeId.slice(-8)}`
|
||||
}
|
||||
|
||||
function provisionFailure(failure: EphemeralVmFailure): EphemeralVmProvisionIpcResult {
|
||||
return { ok: false, ...failure, stdout: '', stderr: '' }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environ
|
||||
import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes'
|
||||
import type { EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
|
||||
import type { EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
|
||||
|
||||
export type EphemeralVmApi = {
|
||||
listRecipes: (args: { repoId: string }) => Promise<{
|
||||
@@ -11,6 +12,8 @@ export type EphemeralVmApi = {
|
||||
recipes: OrcaHooks['environmentRecipes']
|
||||
diagnostics: NonNullable<OrcaHooks['environmentRecipeDiagnostics']>
|
||||
message?: string
|
||||
messageCode?: string
|
||||
messageParams?: Record<string, string | number>
|
||||
}>
|
||||
listRecipeCatalog: () => Promise<
|
||||
{
|
||||
@@ -51,7 +54,7 @@ export type EphemeralVmApi = {
|
||||
stderr: string
|
||||
warnings: EphemeralVmRecipeResultWarning[]
|
||||
}
|
||||
| { ok: false; error: string; stderr: string; stdout: string }
|
||||
| ({ ok: false; stderr: string; stdout: string } & EphemeralVmFailure)
|
||||
>
|
||||
cancelProvision: (args: { provisionId: string }) => Promise<{ cancelled: boolean }>
|
||||
onProvisionEvent: (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlertTriangle, Copy, Loader2, Square, Trash2 } from 'lucide-react'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
|
||||
import { getEphemeralVmRecipeResultProjectRoot } from '../../../../shared/ephemeral-vm-recipes'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ephemeralVmCleanupErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
@@ -29,6 +30,7 @@ export function EphemeralVmRuntimeRow({
|
||||
onCopyCleanupCommand
|
||||
}: EphemeralVmRuntimeRowProps): React.JSX.Element {
|
||||
const hasError = cleanupFailed || runtime.status === 'failed'
|
||||
const cleanupError = ephemeralVmCleanupErrorText(runtime)
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
@@ -48,8 +50,8 @@ export function EphemeralVmRuntimeRow({
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}
|
||||
</p>
|
||||
{runtime.cleanupLastError ? (
|
||||
<p className="mt-0.5 truncate text-xs text-destructive">{runtime.cleanupLastError}</p>
|
||||
{cleanupError ? (
|
||||
<p className="mt-0.5 truncate text-xs text-destructive">{cleanupError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-r
|
||||
import { EPHEMERAL_VM_CLEANUP_STOPPED_ERROR } from '../../../../shared/ephemeral-vm-recipe-destroy-result'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ephemeralVmCleanupErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { EphemeralVmCleanupStopDialog } from './EphemeralVmCleanupStopDialog'
|
||||
@@ -147,7 +148,7 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element | null {
|
||||
}
|
||||
if (hasCleanupFailed(cleaned)) {
|
||||
throw new Error(
|
||||
cleaned.cleanupLastError ??
|
||||
ephemeralVmCleanupErrorText(cleaned) ??
|
||||
translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
|
||||
'Couldn’t clean up Cloud VM runtime.'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { OrcaVmRecipe } from '../../../shared/orca-yaml-hook-types'
|
||||
import type { EphemeralVmFailure } from '../../../shared/ephemeral-vm-failure-copy'
|
||||
import { ephemeralVmErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
|
||||
|
||||
type EphemeralVmRecipeOptionsArgs = {
|
||||
enabled: boolean
|
||||
@@ -20,7 +23,8 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
} {
|
||||
const [recipes, setRecipes] = useState<OrcaVmRecipe[]>([])
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Failures stay coded until render so a language switch relocalizes them.
|
||||
const [errors, setErrors] = useState<EphemeralVmFailure[]>([])
|
||||
const requestGeneration = useRef(0)
|
||||
const canLoad =
|
||||
args.enabled &&
|
||||
@@ -36,7 +40,7 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
if (resetSelection) {
|
||||
setRecipes([])
|
||||
setSelectedRecipeId(null)
|
||||
setError(null)
|
||||
setErrors([])
|
||||
}
|
||||
if (!canLoad || !args.repoId) {
|
||||
return
|
||||
@@ -61,13 +65,20 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
const diagnosticMessages = (result.diagnostics ?? []).map((diagnostic) => {
|
||||
const recipeLabel = `environmentRecipes[${diagnostic.index}]`
|
||||
const fieldLabel = diagnostic.field ? `.${diagnostic.field}` : ''
|
||||
return `${recipeLabel}${fieldLabel}: ${diagnostic.message}`
|
||||
return { error: `${recipeLabel}${fieldLabel}: ${diagnostic.message}` }
|
||||
})
|
||||
setError(
|
||||
[result.status === 'error' ? result.message : null, ...diagnosticMessages]
|
||||
.filter((message): message is string => Boolean(message))
|
||||
.join('\n') || null
|
||||
)
|
||||
setErrors([
|
||||
...(result.status === 'error' && result.message
|
||||
? [
|
||||
{
|
||||
error: result.message,
|
||||
errorCode: result.messageCode,
|
||||
errorParams: result.messageParams
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...diagnosticMessages
|
||||
])
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (generation !== requestGeneration.current) {
|
||||
@@ -75,7 +86,7 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
}
|
||||
setRecipes([])
|
||||
setSelectedRecipeId(null)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setErrors([{ error: cause instanceof Error ? cause.message : String(cause) }])
|
||||
})
|
||||
},
|
||||
[args.initialRecipeId, args.repoId, canLoad]
|
||||
@@ -99,5 +110,7 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
})
|
||||
}, [load])
|
||||
|
||||
useUiLanguageRevision()
|
||||
const error = errors.map(ephemeralVmErrorText).filter(Boolean).join('\n') || null
|
||||
return { recipes, selectedRecipeId, setSelectedRecipeId, error }
|
||||
}
|
||||
|
||||
+27
@@ -1906,6 +1906,33 @@
|
||||
"workspaceChanged": "The workspace changed while the emulator was starting. Reattach the emulator."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"cleanupInterrupted": "Cleanup was interrupted. Retry cleanup to continue.",
|
||||
"cleanupStopped": "Cleanup stopped by user.",
|
||||
"defaultStartRefUnresolved": "Could not resolve a default provisioned-root start ref.",
|
||||
"destroyDisabled": "Destroy is disabled for this recipe.",
|
||||
"destroyExited": "Destroy exited with code {{exitCode}}.",
|
||||
"destroyExitedWithoutCode": "Destroy exited with code unknown.",
|
||||
"destroyFailed": "Destroy failed.",
|
||||
"provisionCancelled": "Provisioning cancelled.",
|
||||
"provisionedRootNotConfigured": "Recipe result requests provisioned-root checkout, but the recipe is not configured for it.",
|
||||
"provisionedRootResultRequired": "Provisioned-root recipes must return schemaVersion 2 with checkoutMode \"provisioned-root\".",
|
||||
"recipeExited": "Recipe exited with code {{exitCode}}.",
|
||||
"recipeExitedWithoutCode": "Recipe exited with code unknown.",
|
||||
"recipeNotFound": "Recipe not found: {{recipeId}}",
|
||||
"recipeResultMissing": "Recipe produced no JSON result.",
|
||||
"recipeResultNotJsonObject": "Recipe stdout must be one JSON object.",
|
||||
"recipeResultPairingCodeInvalid": "Recipe result pairingCode is not a valid Orca pairing code.",
|
||||
"recipeResultProjectRootNotAbsolute": "Recipe result projectRoot must be an absolute runtime path.",
|
||||
"remoteRepoUnsupported": "Ephemeral VM recipes run on the local desktop host in v1.",
|
||||
"repoNotFound": "Repo not found: {{repoId}}",
|
||||
"runtimeNotFound": "Unknown ephemeral VM runtime: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "Ephemeral VM runtime has no repo id: {{runtimeId}}",
|
||||
"sshTargetRemoveFailed": "Failed to remove the hidden SSH target.",
|
||||
"startRefUnresolved": "Could not resolve provisioned-root start ref: {{ref}}"
|
||||
}
|
||||
},
|
||||
"jira": {
|
||||
"workspace": {
|
||||
"linkedCount_one": "{{count}} workspace linked to {{key}}",
|
||||
|
||||
@@ -22162,5 +22162,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Emulator-Verfügbarkeit konnte nicht geprüft werden."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Repo nicht gefunden: {{repoId}}",
|
||||
"remoteRepoUnsupported": "Rezepte für temporäre VMs werden in v1 auf dem lokalen Desktop-Host ausgeführt.",
|
||||
"recipeNotFound": "Rezept nicht gefunden: {{recipeId}}",
|
||||
"runtimeNotFound": "Unbekannte Laufzeitumgebung für temporäre VMs: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "Laufzeitumgebung für temporäre VMs hat keine Repo-ID: {{runtimeId}}",
|
||||
"provisionCancelled": "Bereitstellung abgebrochen.",
|
||||
"startRefUnresolved": "Startreferenz für das bereitgestellte Stammverzeichnis konnte nicht aufgelöst werden: {{ref}}",
|
||||
"defaultStartRefUnresolved": "Es konnte keine Standard-Startreferenz für das bereitgestellte Stammverzeichnis aufgelöst werden.",
|
||||
"recipeExited": "Rezept wurde mit Code {{exitCode}} beendet.",
|
||||
"recipeResultMissing": "Rezept hat kein JSON-Ergebnis erzeugt.",
|
||||
"recipeResultNotJsonObject": "stdout des Rezepts muss genau ein JSON-Objekt sein.",
|
||||
"recipeResultPairingCodeInvalid": "pairingCode im Rezeptergebnis ist kein gültiger Orca-Kopplungscode.",
|
||||
"recipeResultProjectRootNotAbsolute": "projectRoot im Rezeptergebnis muss ein absoluter Pfad in der Laufzeitumgebung sein.",
|
||||
"provisionedRootResultRequired": "Rezepte mit bereitgestelltem Stammverzeichnis müssen schemaVersion 2 mit checkoutMode \"provisioned-root\" zurückgeben.",
|
||||
"provisionedRootNotConfigured": "Das Rezeptergebnis fordert einen Checkout mit bereitgestelltem Stammverzeichnis an, aber das Rezept ist dafür nicht konfiguriert.",
|
||||
"destroyExited": "Löschen wurde mit Code {{exitCode}} beendet.",
|
||||
"destroyFailed": "Löschen fehlgeschlagen.",
|
||||
"destroyDisabled": "Löschen ist für dieses Rezept deaktiviert.",
|
||||
"cleanupStopped": "Bereinigung durch Benutzer gestoppt.",
|
||||
"cleanupInterrupted": "Bereinigung wurde unterbrochen. Zum Fortsetzen die Bereinigung erneut versuchen.",
|
||||
"sshTargetRemoveFailed": "Das verborgene SSH-Ziel konnte nicht entfernt werden.",
|
||||
"recipeExitedWithoutCode": "Rezept wurde mit unbekanntem Code beendet.",
|
||||
"destroyExitedWithoutCode": "Löschen wurde mit unbekanntem Code beendet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22619,5 +22619,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Could not check emulator availability."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Repo not found: {{repoId}}",
|
||||
"remoteRepoUnsupported": "Ephemeral VM recipes run on the local desktop host in v1.",
|
||||
"recipeNotFound": "Recipe not found: {{recipeId}}",
|
||||
"runtimeNotFound": "Unknown ephemeral VM runtime: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "Ephemeral VM runtime has no repo id: {{runtimeId}}",
|
||||
"provisionCancelled": "Provisioning cancelled.",
|
||||
"startRefUnresolved": "Could not resolve provisioned-root start ref: {{ref}}",
|
||||
"defaultStartRefUnresolved": "Could not resolve a default provisioned-root start ref.",
|
||||
"recipeExited": "Recipe exited with code {{exitCode}}.",
|
||||
"recipeResultMissing": "Recipe produced no JSON result.",
|
||||
"recipeResultNotJsonObject": "Recipe stdout must be one JSON object.",
|
||||
"recipeResultPairingCodeInvalid": "Recipe result pairingCode is not a valid Orca pairing code.",
|
||||
"recipeResultProjectRootNotAbsolute": "Recipe result projectRoot must be an absolute runtime path.",
|
||||
"provisionedRootResultRequired": "Provisioned-root recipes must return schemaVersion 2 with checkoutMode \"provisioned-root\".",
|
||||
"provisionedRootNotConfigured": "Recipe result requests provisioned-root checkout, but the recipe is not configured for it.",
|
||||
"destroyExited": "Destroy exited with code {{exitCode}}.",
|
||||
"destroyFailed": "Destroy failed.",
|
||||
"destroyDisabled": "Destroy is disabled for this recipe.",
|
||||
"cleanupStopped": "Cleanup stopped by user.",
|
||||
"cleanupInterrupted": "Cleanup was interrupted. Retry cleanup to continue.",
|
||||
"sshTargetRemoveFailed": "Failed to remove the hidden SSH target.",
|
||||
"recipeExitedWithoutCode": "Recipe exited with code unknown.",
|
||||
"destroyExitedWithoutCode": "Destroy exited with code unknown."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22780,5 +22780,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "No se pudo comprobar la disponibilidad del emulador."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Repositorio no encontrado: {{repoId}}",
|
||||
"remoteRepoUnsupported": "En v1, las recetas de VM efímeras se ejecutan en el host de escritorio local.",
|
||||
"recipeNotFound": "Receta no encontrada: {{recipeId}}",
|
||||
"runtimeNotFound": "Entorno de ejecución de VM efímera desconocido: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "El entorno de ejecución de VM efímera no tiene ID de repositorio: {{runtimeId}}",
|
||||
"provisionCancelled": "Aprovisionamiento cancelado.",
|
||||
"startRefUnresolved": "No se pudo resolver la referencia inicial de la raíz aprovisionada: {{ref}}",
|
||||
"defaultStartRefUnresolved": "No se pudo resolver una referencia inicial predeterminada de la raíz aprovisionada.",
|
||||
"recipeExited": "La receta finalizó con el código {{exitCode}}.",
|
||||
"recipeResultMissing": "La receta no produjo ningún resultado JSON.",
|
||||
"recipeResultNotJsonObject": "La salida stdout de la receta debe ser un único objeto JSON.",
|
||||
"recipeResultPairingCodeInvalid": "El campo pairingCode del resultado de la receta no es un código de emparejamiento de Orca válido.",
|
||||
"recipeResultProjectRootNotAbsolute": "El campo projectRoot del resultado de la receta debe ser una ruta absoluta en el entorno de ejecución.",
|
||||
"provisionedRootResultRequired": "Las recetas de raíz aprovisionada deben devolver schemaVersion 2 con checkoutMode \"provisioned-root\".",
|
||||
"provisionedRootNotConfigured": "El resultado de la receta solicita un checkout con raíz aprovisionada, pero la receta no está configurada para ello.",
|
||||
"destroyExited": "La destrucción finalizó con el código {{exitCode}}.",
|
||||
"destroyFailed": "No se pudo destruir.",
|
||||
"destroyDisabled": "La destrucción está deshabilitada para esta receta.",
|
||||
"cleanupStopped": "Limpieza detenida por el usuario.",
|
||||
"cleanupInterrupted": "La limpieza se interrumpió. Vuelve a intentar la limpieza para continuar.",
|
||||
"sshTargetRemoveFailed": "No se pudo eliminar el destino SSH oculto.",
|
||||
"recipeExitedWithoutCode": "La receta finalizó con un código desconocido.",
|
||||
"destroyExitedWithoutCode": "La destrucción finalizó con un código desconocido."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22780,5 +22780,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Impossible de vérifier la disponibilité de l’émulateur."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Dépôt introuvable : {{repoId}}",
|
||||
"remoteRepoUnsupported": "En v1, les recettes de VM éphémères s’exécutent sur l’hôte local de l’application de bureau.",
|
||||
"recipeNotFound": "Recette introuvable : {{recipeId}}",
|
||||
"runtimeNotFound": "Runtime de VM éphémère inconnu : {{runtimeId}}",
|
||||
"runtimeRepoMissing": "Le runtime de VM éphémère n’a pas d’identifiant de dépôt : {{runtimeId}}",
|
||||
"provisionCancelled": "Provisionnement annulé.",
|
||||
"startRefUnresolved": "Impossible de résoudre la référence de départ de la racine provisionnée : {{ref}}",
|
||||
"defaultStartRefUnresolved": "Impossible de résoudre une référence de départ par défaut pour la racine provisionnée.",
|
||||
"recipeExited": "La recette s’est terminée avec le code {{exitCode}}.",
|
||||
"recipeResultMissing": "La recette n’a produit aucun résultat JSON.",
|
||||
"recipeResultNotJsonObject": "La sortie stdout de la recette doit être un seul objet JSON.",
|
||||
"recipeResultPairingCodeInvalid": "Le champ pairingCode du résultat de la recette n’est pas un code d’appairage Orca valide.",
|
||||
"recipeResultProjectRootNotAbsolute": "Le champ projectRoot du résultat de la recette doit être un chemin absolu sur le runtime.",
|
||||
"provisionedRootResultRequired": "Les recettes à racine provisionnée doivent renvoyer schemaVersion 2 avec checkoutMode \"provisioned-root\".",
|
||||
"provisionedRootNotConfigured": "Le résultat de la recette demande un checkout à racine provisionnée, mais la recette n’est pas configurée pour cela.",
|
||||
"destroyExited": "La destruction s’est terminée avec le code {{exitCode}}.",
|
||||
"destroyFailed": "Échec de la destruction.",
|
||||
"destroyDisabled": "La destruction est désactivée pour cette recette.",
|
||||
"cleanupStopped": "Nettoyage arrêté par l’utilisateur.",
|
||||
"cleanupInterrupted": "Le nettoyage a été interrompu. Réessayez le nettoyage pour continuer.",
|
||||
"sshTargetRemoveFailed": "Impossible de supprimer la cible SSH masquée.",
|
||||
"recipeExitedWithoutCode": "La recette s’est terminée avec un code inconnu.",
|
||||
"destroyExitedWithoutCode": "La destruction s’est terminée avec un code inconnu."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22164,5 +22164,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "エミュレータが利用可能か確認できませんでした。"
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "リポジトリが見つかりません: {{repoId}}",
|
||||
"remoteRepoUnsupported": "v1 では、一時的な VM のレシピはローカルのデスクトップホストで実行されます。",
|
||||
"recipeNotFound": "レシピが見つかりません: {{recipeId}}",
|
||||
"runtimeNotFound": "不明な一時 VM ランタイム: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "一時 VM ランタイムにリポジトリ ID がありません: {{runtimeId}}",
|
||||
"provisionCancelled": "プロビジョニングがキャンセルされました。",
|
||||
"startRefUnresolved": "プロビジョニングされたルートの開始参照を解決できませんでした: {{ref}}",
|
||||
"defaultStartRefUnresolved": "プロビジョニングされたルートのデフォルトの開始参照を解決できませんでした。",
|
||||
"recipeExited": "レシピはコード {{exitCode}} で終了しました。",
|
||||
"recipeResultMissing": "レシピが JSON の結果を出力しませんでした。",
|
||||
"recipeResultNotJsonObject": "レシピの stdout は単一の JSON オブジェクトである必要があります。",
|
||||
"recipeResultPairingCodeInvalid": "レシピの結果の pairingCode は有効な Orca ペアリングコードではありません。",
|
||||
"recipeResultProjectRootNotAbsolute": "レシピの結果の projectRoot はランタイム内の絶対パスである必要があります。",
|
||||
"provisionedRootResultRequired": "プロビジョニングされたルートレシピは、schemaVersion 2 と checkoutMode \"provisioned-root\" を返す必要があります。",
|
||||
"provisionedRootNotConfigured": "レシピの結果はプロビジョニングされたルートでのチェックアウトを要求していますが、レシピはそのように設定されていません。",
|
||||
"destroyExited": "破棄処理はコード {{exitCode}} で終了しました。",
|
||||
"destroyFailed": "破棄に失敗しました。",
|
||||
"destroyDisabled": "このレシピでは破棄が無効になっています。",
|
||||
"cleanupStopped": "ユーザーがクリーンアップを停止しました。",
|
||||
"cleanupInterrupted": "クリーンアップが中断されました。続行するにはクリーンアップを再試行してください。",
|
||||
"sshTargetRemoveFailed": "非表示の SSH ターゲットを削除できませんでした。",
|
||||
"recipeExitedWithoutCode": "レシピは不明なコードで終了しました。",
|
||||
"destroyExitedWithoutCode": "破棄処理は不明なコードで終了しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22164,5 +22164,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "에뮬레이터 사용 가능 여부를 확인하지 못했습니다."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "리포지토리를 찾을 수 없습니다: {{repoId}}",
|
||||
"remoteRepoUnsupported": "v1에서는 임시 VM 레시피가 로컬 데스크톱 호스트에서 실행됩니다.",
|
||||
"recipeNotFound": "레시피를 찾을 수 없습니다: {{recipeId}}",
|
||||
"runtimeNotFound": "알 수 없는 임시 VM 런타임: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "임시 VM 런타임에 리포지토리 ID가 없습니다: {{runtimeId}}",
|
||||
"provisionCancelled": "프로비저닝이 취소되었습니다.",
|
||||
"startRefUnresolved": "프로비저닝된 루트의 시작 참조를 확인할 수 없습니다: {{ref}}",
|
||||
"defaultStartRefUnresolved": "프로비저닝된 루트의 기본 시작 참조를 확인할 수 없습니다.",
|
||||
"recipeExited": "레시피가 코드 {{exitCode}}로 종료되었습니다.",
|
||||
"recipeResultMissing": "레시피가 JSON 결과를 생성하지 않았습니다.",
|
||||
"recipeResultNotJsonObject": "레시피의 stdout은 하나의 JSON 객체여야 합니다.",
|
||||
"recipeResultPairingCodeInvalid": "레시피 결과의 pairingCode가 유효한 Orca 페어링 코드가 아닙니다.",
|
||||
"recipeResultProjectRootNotAbsolute": "레시피 결과의 projectRoot는 런타임 내의 절대 경로여야 합니다.",
|
||||
"provisionedRootResultRequired": "프로비저닝된 루트 레시피는 schemaVersion 2와 checkoutMode \"provisioned-root\"를 반환해야 합니다.",
|
||||
"provisionedRootNotConfigured": "레시피 결과가 프로비저닝된 루트 체크아웃을 요청하지만, 레시피는 이에 맞게 구성되지 않았습니다.",
|
||||
"destroyExited": "삭제 작업이 코드 {{exitCode}}로 종료되었습니다.",
|
||||
"destroyFailed": "삭제에 실패했습니다.",
|
||||
"destroyDisabled": "이 레시피에서는 삭제가 비활성화되어 있습니다.",
|
||||
"cleanupStopped": "사용자가 정리를 중지했습니다.",
|
||||
"cleanupInterrupted": "정리가 중단되었습니다. 계속하려면 정리를 다시 시도하세요.",
|
||||
"sshTargetRemoveFailed": "숨겨진 SSH 대상을 제거할 수 없습니다.",
|
||||
"recipeExitedWithoutCode": "레시피가 알 수 없는 코드로 종료되었습니다.",
|
||||
"destroyExitedWithoutCode": "삭제 작업이 알 수 없는 코드로 종료되었습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22595,5 +22595,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Não foi possível verificar a disponibilidade do emulador."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Repositório não encontrado: {{repoId}}",
|
||||
"remoteRepoUnsupported": "Na v1, as receitas de VM efêmeras são executadas no host local do aplicativo desktop.",
|
||||
"recipeNotFound": "Receita não encontrada: {{recipeId}}",
|
||||
"runtimeNotFound": "Runtime de VM efêmera desconhecido: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "O runtime de VM efêmera não tem ID de repositório: {{runtimeId}}",
|
||||
"provisionCancelled": "Provisionamento cancelado.",
|
||||
"startRefUnresolved": "Não foi possível resolver a referência inicial da raiz provisionada: {{ref}}",
|
||||
"defaultStartRefUnresolved": "Não foi possível resolver uma referência inicial padrão para a raiz provisionada.",
|
||||
"recipeExited": "A receita terminou com o código {{exitCode}}.",
|
||||
"recipeResultMissing": "A receita não produziu nenhum resultado JSON.",
|
||||
"recipeResultNotJsonObject": "A saída stdout da receita deve ser um único objeto JSON.",
|
||||
"recipeResultPairingCodeInvalid": "O campo pairingCode do resultado da receita não é um código de pareamento válido do Orca.",
|
||||
"recipeResultProjectRootNotAbsolute": "O campo projectRoot do resultado da receita deve ser um caminho absoluto no runtime.",
|
||||
"provisionedRootResultRequired": "Receitas de raiz provisionada devem retornar schemaVersion 2 com checkoutMode \"provisioned-root\".",
|
||||
"provisionedRootNotConfigured": "O resultado da receita solicita um checkout com raiz provisionada, mas a receita não está configurada para isso.",
|
||||
"destroyExited": "A destruição terminou com o código {{exitCode}}.",
|
||||
"destroyFailed": "Falha na destruição.",
|
||||
"destroyDisabled": "A destruição está desativada para esta receita.",
|
||||
"cleanupStopped": "Limpeza interrompida pelo usuário.",
|
||||
"cleanupInterrupted": "A limpeza foi interrompida. Tente a limpeza novamente para continuar.",
|
||||
"sshTargetRemoveFailed": "Não foi possível remover o destino SSH oculto.",
|
||||
"recipeExitedWithoutCode": "A receita terminou com um código desconhecido.",
|
||||
"destroyExitedWithoutCode": "A destruição terminou com um código desconhecido."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22837,5 +22837,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Не удалось проверить доступность эмулятора."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Репозиторий не найден: {{repoId}}",
|
||||
"remoteRepoUnsupported": "В v1 рецепты временных ВМ запускаются на локальном компьютере с настольным приложением.",
|
||||
"recipeNotFound": "Рецепт не найден: {{recipeId}}",
|
||||
"runtimeNotFound": "Неизвестная среда выполнения временной ВМ: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "У среды выполнения временной ВМ нет идентификатора репозитория: {{runtimeId}}",
|
||||
"provisionCancelled": "Подготовка отменена.",
|
||||
"startRefUnresolved": "Не удалось определить начальную ссылку для подготовленного корня проекта: {{ref}}",
|
||||
"defaultStartRefUnresolved": "Не удалось определить начальную ссылку по умолчанию для подготовленного корня проекта.",
|
||||
"recipeExited": "Рецепт завершился с кодом {{exitCode}}.",
|
||||
"recipeResultMissing": "Рецепт не вернул результат JSON.",
|
||||
"recipeResultNotJsonObject": "stdout рецепта должен содержать ровно один объект JSON.",
|
||||
"recipeResultPairingCodeInvalid": "Поле pairingCode в результате рецепта не является допустимым кодом сопряжения Orca.",
|
||||
"recipeResultProjectRootNotAbsolute": "Поле projectRoot в результате рецепта должно содержать абсолютный путь в среде выполнения.",
|
||||
"provisionedRootResultRequired": "Рецепты с подготовкой корня проекта должны возвращать schemaVersion 2 с checkoutMode \"provisioned-root\".",
|
||||
"provisionedRootNotConfigured": "Результат рецепта запрашивает режим работы с подготовленным корнем проекта, но рецепт для этого не настроен.",
|
||||
"destroyExited": "Уничтожение завершилось с кодом {{exitCode}}.",
|
||||
"destroyFailed": "Уничтожение завершилось ошибкой.",
|
||||
"destroyDisabled": "Уничтожение отключено для этого рецепта.",
|
||||
"cleanupStopped": "Очистка остановлена пользователем.",
|
||||
"cleanupInterrupted": "Очистка прервана. Повторите очистку, чтобы продолжить.",
|
||||
"sshTargetRemoveFailed": "Не удалось удалить скрытую цель SSH-подключения.",
|
||||
"recipeExitedWithoutCode": "Рецепт завершился с неизвестным кодом.",
|
||||
"destroyExitedWithoutCode": "Уничтожение завершилось с неизвестным кодом."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22143,5 +22143,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "Emülatörün kullanılabilirliği kontrol edilemedi."
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "Repo bulunamadı: {{repoId}}",
|
||||
"remoteRepoUnsupported": "v1'de geçici VM tarifleri yerel masaüstü ana makinesinde çalışır.",
|
||||
"recipeNotFound": "Tarif bulunamadı: {{recipeId}}",
|
||||
"runtimeNotFound": "Bilinmeyen geçici VM çalışma zamanı: {{runtimeId}}",
|
||||
"runtimeRepoMissing": "Geçici VM çalışma zamanında repo kimliği yok: {{runtimeId}}",
|
||||
"provisionCancelled": "Hazırlama iptal edildi.",
|
||||
"startRefUnresolved": "Sağlanmış kökün başlangıç referansı çözümlenemedi: {{ref}}",
|
||||
"defaultStartRefUnresolved": "Sağlanmış kök için varsayılan bir başlangıç referansı çözümlenemedi.",
|
||||
"recipeExited": "Tarif {{exitCode}} koduyla sonlandı.",
|
||||
"recipeResultMissing": "Tarif hiçbir JSON sonucu üretmedi.",
|
||||
"recipeResultNotJsonObject": "Tarifin stdout çıktısı tek bir JSON nesnesi olmalıdır.",
|
||||
"recipeResultPairingCodeInvalid": "Tarif sonucundaki pairingCode geçerli bir Orca eşleştirme kodu değil.",
|
||||
"recipeResultProjectRootNotAbsolute": "Tarif sonucundaki projectRoot, çalışma zamanı ortamındaki mutlak bir yol olmalıdır.",
|
||||
"provisionedRootResultRequired": "Sağlanmış kök tarifleri, checkoutMode \"provisioned-root\" ile schemaVersion 2 döndürmelidir.",
|
||||
"provisionedRootNotConfigured": "Tarif sonucu sağlanmış kök checkout'u istiyor, ancak tarif bunun için yapılandırılmamış.",
|
||||
"destroyExited": "Yok etme işlemi {{exitCode}} koduyla sonlandı.",
|
||||
"destroyFailed": "Yok etme işlemi gerçekleştirilemedi.",
|
||||
"destroyDisabled": "Bu tarifte yok etme devre dışı.",
|
||||
"cleanupStopped": "Temizleme kullanıcı tarafından durduruldu.",
|
||||
"cleanupInterrupted": "Temizleme kesintiye uğradı. Devam etmek için temizlemeyi yeniden deneyin.",
|
||||
"sshTargetRemoveFailed": "Gizli SSH hedefi kaldırılamadı.",
|
||||
"recipeExitedWithoutCode": "Tarif bilinmeyen bir kodla sonlandı.",
|
||||
"destroyExitedWithoutCode": "Yok etme işlemi bilinmeyen bir kodla sonlandı."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21864,5 +21864,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "無法檢查模擬器是否可用。"
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "找不到存放庫:{{repoId}}",
|
||||
"remoteRepoUnsupported": "在 v1 中,臨時虛擬機器環境範本會在本機桌面主機上執行。",
|
||||
"recipeNotFound": "找不到環境範本:{{recipeId}}",
|
||||
"runtimeNotFound": "未知的臨時虛擬機器執行環境:{{runtimeId}}",
|
||||
"runtimeRepoMissing": "臨時虛擬機器執行環境沒有存放庫 ID:{{runtimeId}}",
|
||||
"provisionCancelled": "已取消佈建。",
|
||||
"startRefUnresolved": "無法解析已佈建根目錄的起始參照:{{ref}}",
|
||||
"defaultStartRefUnresolved": "無法解析已佈建根目錄的預設起始參照。",
|
||||
"recipeExited": "環境範本已結束,結束代碼為 {{exitCode}}。",
|
||||
"recipeResultMissing": "環境範本未產生 JSON 結果。",
|
||||
"recipeResultNotJsonObject": "環境範本的 stdout 必須是單一 JSON 物件。",
|
||||
"recipeResultPairingCodeInvalid": "環境範本結果中的 pairingCode 不是有效的 Orca 配對碼。",
|
||||
"recipeResultProjectRootNotAbsolute": "環境範本結果中的 projectRoot 必須是執行環境中的絕對路徑。",
|
||||
"provisionedRootResultRequired": "使用已佈建根目錄的環境範本必須回傳 schemaVersion 2,且 checkoutMode 為 \"provisioned-root\"。",
|
||||
"provisionedRootNotConfigured": "環境範本結果要求使用已佈建根目錄檢出模式,但環境範本尚未設定為使用此模式。",
|
||||
"destroyExited": "銷燬作業已結束,結束代碼為 {{exitCode}}。",
|
||||
"destroyFailed": "銷燬失敗。",
|
||||
"destroyDisabled": "此環境範本已停用銷燬。",
|
||||
"cleanupStopped": "使用者已停止清理。",
|
||||
"cleanupInterrupted": "清理已中斷。請重試清理以繼續。",
|
||||
"sshTargetRemoveFailed": "無法移除隱藏的 SSH 目標。",
|
||||
"recipeExitedWithoutCode": "環境範本已結束,結束代碼未知。",
|
||||
"destroyExitedWithoutCode": "銷燬作業已結束,結束代碼未知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22164,5 +22164,32 @@
|
||||
"settings": {
|
||||
"availabilityCheckFailed": "无法检查模拟器可用性。"
|
||||
}
|
||||
},
|
||||
"ephemeralVm": {
|
||||
"errors": {
|
||||
"repoNotFound": "未找到仓库:{{repoId}}",
|
||||
"remoteRepoUnsupported": "在 v1 中,临时虚拟机环境模板在本地桌面主机上运行。",
|
||||
"recipeNotFound": "未找到环境模板:{{recipeId}}",
|
||||
"runtimeNotFound": "未知的临时虚拟机运行时:{{runtimeId}}",
|
||||
"runtimeRepoMissing": "临时虚拟机运行时没有仓库 ID:{{runtimeId}}",
|
||||
"provisionCancelled": "预配已取消。",
|
||||
"startRefUnresolved": "无法解析已预配根目录的起始引用:{{ref}}",
|
||||
"defaultStartRefUnresolved": "无法解析已预配根目录的默认起始引用。",
|
||||
"recipeExited": "环境模板已退出,退出码为 {{exitCode}}。",
|
||||
"recipeResultMissing": "环境模板未生成 JSON 结果。",
|
||||
"recipeResultNotJsonObject": "环境模板的 stdout 必须是单个 JSON 对象。",
|
||||
"recipeResultPairingCodeInvalid": "环境模板结果中的 pairingCode 不是有效的 Orca 配对码。",
|
||||
"recipeResultProjectRootNotAbsolute": "环境模板结果中的 projectRoot 必须是运行时中的绝对路径。",
|
||||
"provisionedRootResultRequired": "使用已预配根目录的环境模板必须返回 schemaVersion 2,且 checkoutMode 为 \"provisioned-root\"。",
|
||||
"provisionedRootNotConfigured": "环境模板结果请求使用已预配根目录检出模式,但环境模板未配置为使用此模式。",
|
||||
"destroyExited": "销毁操作已退出,退出码为 {{exitCode}}。",
|
||||
"destroyFailed": "销毁失败。",
|
||||
"destroyDisabled": "此环境模板已禁用销毁。",
|
||||
"cleanupStopped": "用户已停止清理。",
|
||||
"cleanupInterrupted": "清理已中断。请重试清理以继续。",
|
||||
"sshTargetRemoveFailed": "无法移除隐藏的 SSH 目标。",
|
||||
"recipeExitedWithoutCode": "环境模板已退出,退出码未知。",
|
||||
"destroyExitedWithoutCode": "销毁操作已退出,退出码未知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import es from '@/i18n/locales/es.json'
|
||||
import { ephemeralVmFailure } from '../../../shared/ephemeral-vm-failure-copy'
|
||||
import { ephemeralVmCleanupErrorText, ephemeralVmErrorText } from './ephemeral-vm-error-copy'
|
||||
|
||||
const originalLanguage = i18n.language
|
||||
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage(originalLanguage)
|
||||
})
|
||||
|
||||
describe('ephemeralVmErrorText', () => {
|
||||
it('localizes a coded failure and keeps the interpolated id verbatim', async () => {
|
||||
i18n.addResourceBundle('es', 'translation', es)
|
||||
await i18n.changeLanguage('es')
|
||||
const missing = ephemeralVmFailure('recipeNotFound', { recipeId: 'cloud-{{ref}}' })
|
||||
expect(missing.error).toBe('Recipe not found: cloud-{{ref}}')
|
||||
expect(ephemeralVmErrorText(missing)).toBe(
|
||||
es.ephemeralVm.errors.recipeNotFound.replace('{{recipeId}}', 'cloud-{{ref}}')
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the host English for uncoded, unknown, or incomplete failures', async () => {
|
||||
i18n.addResourceBundle('es', 'translation', es)
|
||||
await i18n.changeLanguage('es')
|
||||
expect(ephemeralVmErrorText({ error: 'Old host text' })).toBe('Old host text')
|
||||
expect(ephemeralVmErrorText({ error: 'Newer', errorCode: 'futureCode' })).toBe('Newer')
|
||||
expect(ephemeralVmErrorText({ error: 'English', errorCode: 'toString' })).toBe('English')
|
||||
expect(
|
||||
ephemeralVmErrorText({
|
||||
error: 'English',
|
||||
errorCode: 'destroyExited',
|
||||
errorParams: { exitCode: '2' }
|
||||
})
|
||||
).toBe('English')
|
||||
})
|
||||
|
||||
it('localizes a persisted cleanup error from its stored code', async () => {
|
||||
i18n.addResourceBundle('es', 'translation', es)
|
||||
await i18n.changeLanguage('es')
|
||||
expect(
|
||||
ephemeralVmCleanupErrorText({
|
||||
cleanupLastError: 'Destroy exited with code 3.',
|
||||
cleanupLastErrorCode: 'destroyExited',
|
||||
cleanupLastErrorParams: { exitCode: 3 }
|
||||
})
|
||||
).toBe(es.ephemeralVm.errors.destroyExited.replace('{{exitCode}}', '3'))
|
||||
expect(ephemeralVmCleanupErrorText({ cleanupLastError: 'provider said no' })).toBe(
|
||||
'provider said no'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
EPHEMERAL_VM_FAILURE_COPY as EN,
|
||||
type EphemeralVmFailure,
|
||||
type EphemeralVmFailureCode
|
||||
} from '../../../shared/ephemeral-vm-failure-copy'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../../shared/ephemeral-vm-runtimes'
|
||||
|
||||
type Params = Record<string, string | number>
|
||||
|
||||
function text(params: Params | undefined, name: string): string | null {
|
||||
const value = params?.[name]
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function num(params: Params | undefined, name: string): number | null {
|
||||
const value = params?.[name]
|
||||
return typeof value === 'number' ? value : null
|
||||
}
|
||||
|
||||
// null = required param missing; caller shows the host's English `error`.
|
||||
const COPY: Record<EphemeralVmFailureCode, (params: Params | undefined) => string | null> = {
|
||||
repoNotFound: (p) => {
|
||||
const repoId = text(p, 'repoId')
|
||||
return repoId === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.repoNotFound', EN.repoNotFound, { repoId })
|
||||
},
|
||||
remoteRepoUnsupported: () =>
|
||||
translate('ephemeralVm.errors.remoteRepoUnsupported', EN.remoteRepoUnsupported),
|
||||
recipeNotFound: (p) => {
|
||||
const recipeId = text(p, 'recipeId')
|
||||
return recipeId === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.recipeNotFound', EN.recipeNotFound, { recipeId })
|
||||
},
|
||||
runtimeNotFound: (p) => {
|
||||
const runtimeId = text(p, 'runtimeId')
|
||||
return runtimeId === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.runtimeNotFound', EN.runtimeNotFound, { runtimeId })
|
||||
},
|
||||
runtimeRepoMissing: (p) => {
|
||||
const runtimeId = text(p, 'runtimeId')
|
||||
return runtimeId === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.runtimeRepoMissing', EN.runtimeRepoMissing, { runtimeId })
|
||||
},
|
||||
provisionCancelled: () =>
|
||||
translate('ephemeralVm.errors.provisionCancelled', EN.provisionCancelled),
|
||||
startRefUnresolved: (p) => {
|
||||
const ref = text(p, 'ref')
|
||||
return ref === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.startRefUnresolved', EN.startRefUnresolved, { ref })
|
||||
},
|
||||
defaultStartRefUnresolved: () =>
|
||||
translate('ephemeralVm.errors.defaultStartRefUnresolved', EN.defaultStartRefUnresolved),
|
||||
recipeExited: (p) => {
|
||||
const exitCode = num(p, 'exitCode')
|
||||
return exitCode === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.recipeExited', EN.recipeExited, { exitCode })
|
||||
},
|
||||
recipeExitedWithoutCode: () =>
|
||||
translate('ephemeralVm.errors.recipeExitedWithoutCode', EN.recipeExitedWithoutCode),
|
||||
recipeResultMissing: () =>
|
||||
translate('ephemeralVm.errors.recipeResultMissing', EN.recipeResultMissing),
|
||||
recipeResultNotJsonObject: () =>
|
||||
translate('ephemeralVm.errors.recipeResultNotJsonObject', EN.recipeResultNotJsonObject),
|
||||
recipeResultPairingCodeInvalid: () =>
|
||||
translate(
|
||||
'ephemeralVm.errors.recipeResultPairingCodeInvalid',
|
||||
EN.recipeResultPairingCodeInvalid
|
||||
),
|
||||
recipeResultProjectRootNotAbsolute: () =>
|
||||
translate(
|
||||
'ephemeralVm.errors.recipeResultProjectRootNotAbsolute',
|
||||
EN.recipeResultProjectRootNotAbsolute
|
||||
),
|
||||
provisionedRootResultRequired: () =>
|
||||
translate('ephemeralVm.errors.provisionedRootResultRequired', EN.provisionedRootResultRequired),
|
||||
provisionedRootNotConfigured: () =>
|
||||
translate('ephemeralVm.errors.provisionedRootNotConfigured', EN.provisionedRootNotConfigured),
|
||||
destroyExited: (p) => {
|
||||
const exitCode = num(p, 'exitCode')
|
||||
return exitCode === null
|
||||
? null
|
||||
: translate('ephemeralVm.errors.destroyExited', EN.destroyExited, { exitCode })
|
||||
},
|
||||
destroyExitedWithoutCode: () =>
|
||||
translate('ephemeralVm.errors.destroyExitedWithoutCode', EN.destroyExitedWithoutCode),
|
||||
destroyFailed: () => translate('ephemeralVm.errors.destroyFailed', EN.destroyFailed),
|
||||
destroyDisabled: () => translate('ephemeralVm.errors.destroyDisabled', EN.destroyDisabled),
|
||||
cleanupStopped: () => translate('ephemeralVm.errors.cleanupStopped', EN.cleanupStopped),
|
||||
cleanupInterrupted: () =>
|
||||
translate('ephemeralVm.errors.cleanupInterrupted', EN.cleanupInterrupted),
|
||||
sshTargetRemoveFailed: () =>
|
||||
translate('ephemeralVm.errors.sshTargetRemoveFailed', EN.sshTargetRemoveFailed)
|
||||
}
|
||||
|
||||
function isEphemeralVmFailureCode(code: string): code is EphemeralVmFailureCode {
|
||||
return Object.hasOwn(COPY, code)
|
||||
}
|
||||
|
||||
/** Localized text for a coded failure; uncoded, unknown, or incomplete codes show the host's English. */
|
||||
export function ephemeralVmErrorText(failure: EphemeralVmFailure): string {
|
||||
const code = failure.errorCode
|
||||
if (!code || !isEphemeralVmFailureCode(code)) {
|
||||
return failure.error
|
||||
}
|
||||
return COPY[code](failure.errorParams) ?? failure.error
|
||||
}
|
||||
|
||||
export function ephemeralVmCleanupErrorText(
|
||||
runtime: Pick<
|
||||
EphemeralVmRuntimeRecord,
|
||||
'cleanupLastError' | 'cleanupLastErrorCode' | 'cleanupLastErrorParams'
|
||||
>
|
||||
): string | null {
|
||||
if (!runtime.cleanupLastError) {
|
||||
return null
|
||||
}
|
||||
return ephemeralVmErrorText({
|
||||
error: runtime.cleanupLastError,
|
||||
errorCode: runtime.cleanupLastErrorCode,
|
||||
errorParams: runtime.cleanupLastErrorParams
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/protocol-version'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ephemeralVmErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
export type PrepareEphemeralVmWorkspaceTargetArgs = {
|
||||
@@ -62,7 +63,7 @@ export async function prepareEphemeralVmWorkspaceTarget(
|
||||
...(args.provisionId ? { provisionId: args.provisionId } : {})
|
||||
})
|
||||
if (!provisioned.ok) {
|
||||
return { ok: false, error: provisioned.error, stderr: provisioned.stderr }
|
||||
return { ok: false, error: ephemeralVmErrorText(provisioned), stderr: provisioned.stderr }
|
||||
}
|
||||
|
||||
const checkoutMode = getEphemeralVmRecipeResultCheckoutMode(provisioned.runtime.recipeResult)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// One English table for ephemeral VM failures: main builds the wire/persisted English, the renderer translates by code.
|
||||
export const EPHEMERAL_VM_FAILURE_COPY = {
|
||||
repoNotFound: 'Repo not found: {{repoId}}',
|
||||
remoteRepoUnsupported: 'Ephemeral VM recipes run on the local desktop host in v1.',
|
||||
recipeNotFound: 'Recipe not found: {{recipeId}}',
|
||||
runtimeNotFound: 'Unknown ephemeral VM runtime: {{runtimeId}}',
|
||||
runtimeRepoMissing: 'Ephemeral VM runtime has no repo id: {{runtimeId}}',
|
||||
provisionCancelled: 'Provisioning cancelled.',
|
||||
startRefUnresolved: 'Could not resolve provisioned-root start ref: {{ref}}',
|
||||
defaultStartRefUnresolved: 'Could not resolve a default provisioned-root start ref.',
|
||||
recipeExited: 'Recipe exited with code {{exitCode}}.',
|
||||
recipeExitedWithoutCode: 'Recipe exited with code unknown.',
|
||||
recipeResultMissing: 'Recipe produced no JSON result.',
|
||||
recipeResultNotJsonObject: 'Recipe stdout must be one JSON object.',
|
||||
recipeResultPairingCodeInvalid: 'Recipe result pairingCode is not a valid Orca pairing code.',
|
||||
recipeResultProjectRootNotAbsolute: 'Recipe result projectRoot must be an absolute runtime path.',
|
||||
provisionedRootResultRequired:
|
||||
'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".',
|
||||
provisionedRootNotConfigured:
|
||||
'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.',
|
||||
destroyExited: 'Destroy exited with code {{exitCode}}.',
|
||||
destroyExitedWithoutCode: 'Destroy exited with code unknown.',
|
||||
destroyFailed: 'Destroy failed.',
|
||||
destroyDisabled: 'Destroy is disabled for this recipe.',
|
||||
cleanupStopped: 'Cleanup stopped by user.',
|
||||
cleanupInterrupted: 'Cleanup was interrupted. Retry cleanup to continue.',
|
||||
sshTargetRemoveFailed: 'Failed to remove the hidden SSH target.'
|
||||
} as const
|
||||
|
||||
export type EphemeralVmFailureCode = keyof typeof EPHEMERAL_VM_FAILURE_COPY
|
||||
|
||||
type EphemeralVmFailureParams = {
|
||||
repoId?: string
|
||||
recipeId?: string
|
||||
runtimeId?: string
|
||||
ref?: string
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
/** Wire/persisted shape: `error` stays English for older clients; the code is optional and open. */
|
||||
export type EphemeralVmFailure = {
|
||||
error: string
|
||||
errorCode?: string
|
||||
errorParams?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export function ephemeralVmFailure(
|
||||
errorCode: EphemeralVmFailureCode,
|
||||
params: EphemeralVmFailureParams = {}
|
||||
): EphemeralVmFailure {
|
||||
// One pass so an id containing "{{ref}}" stays verbatim.
|
||||
const error = EPHEMERAL_VM_FAILURE_COPY[errorCode].replace(
|
||||
/\{\{(repoId|recipeId|runtimeId|ref|exitCode)\}\}/g,
|
||||
(_slot, slot: keyof EphemeralVmFailureParams) => String(params[slot] ?? '')
|
||||
)
|
||||
const errorParams = Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
(entry): entry is [string, string | number] => entry[1] !== undefined
|
||||
)
|
||||
)
|
||||
return {
|
||||
error,
|
||||
errorCode,
|
||||
...(Object.keys(errorParams).length > 0 ? { errorParams } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function ephemeralVmExitFailure(
|
||||
step: 'recipe' | 'destroy',
|
||||
exitCode: number | null
|
||||
): EphemeralVmFailure {
|
||||
if (step === 'recipe') {
|
||||
return exitCode === null
|
||||
? ephemeralVmFailure('recipeExitedWithoutCode')
|
||||
: ephemeralVmFailure('recipeExited', { exitCode })
|
||||
}
|
||||
return exitCode === null
|
||||
? ephemeralVmFailure('destroyExitedWithoutCode')
|
||||
: ephemeralVmFailure('destroyExited', { exitCode })
|
||||
}
|
||||
|
||||
/** In-process carrier so a catch site can persist the code next to the English message. */
|
||||
export class EphemeralVmFailureError extends Error {
|
||||
readonly failure: EphemeralVmFailure
|
||||
|
||||
constructor(failure: EphemeralVmFailure) {
|
||||
super(failure.error)
|
||||
this.name = 'EphemeralVmFailureError'
|
||||
this.failure = failure
|
||||
}
|
||||
}
|
||||
|
||||
export function ephemeralVmFailureFromError(error: unknown): EphemeralVmFailure {
|
||||
if (error instanceof EphemeralVmFailureError) {
|
||||
return error.failure
|
||||
}
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
/** Runtime-record fields; null (or a plain string) writes undefined so stale codes clear. */
|
||||
export function toCleanupLastErrorFields(failure: EphemeralVmFailure | null): {
|
||||
cleanupLastError: string | undefined
|
||||
cleanupLastErrorCode: string | undefined
|
||||
cleanupLastErrorParams: Record<string, string | number> | undefined
|
||||
} {
|
||||
return {
|
||||
cleanupLastError: failure?.error,
|
||||
cleanupLastErrorCode: failure?.errorCode,
|
||||
cleanupLastErrorParams: failure?.errorParams
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the coded fields, for spreading next to an existing English `error`. */
|
||||
export function pickEphemeralVmFailureCode(
|
||||
failure: Partial<EphemeralVmFailure>
|
||||
): Pick<EphemeralVmFailure, 'errorCode' | 'errorParams'> {
|
||||
return {
|
||||
...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
|
||||
...(failure.errorParams ? { errorParams: failure.errorParams } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getEphemeralVmRecipeCheckoutModeError,
|
||||
getEphemeralVmRecipeCheckoutModeFailure,
|
||||
getEphemeralVmRecipeResultSchemaVersion
|
||||
} from './ephemeral-vm-recipe-checkout-mode'
|
||||
import type { OrcaVmRecipe } from './orca-yaml-hook-types'
|
||||
@@ -15,7 +15,7 @@ describe('ephemeral VM recipe checkout mode', () => {
|
||||
it('keeps existing recipes on schema version 1', () => {
|
||||
expect(getEphemeralVmRecipeResultSchemaVersion(defaultRecipe)).toBe(1)
|
||||
expect(
|
||||
getEphemeralVmRecipeCheckoutModeError(defaultRecipe, {
|
||||
getEphemeralVmRecipeCheckoutModeFailure(defaultRecipe, {
|
||||
schemaVersion: 1,
|
||||
pairingCode: 'orca://pair?code=test',
|
||||
projectRoot: '/workspace/repo'
|
||||
@@ -30,11 +30,13 @@ describe('ephemeral VM recipe checkout mode', () => {
|
||||
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(getEphemeralVmRecipeCheckoutModeFailure(defaultRecipe, provisionedRootResult)).toEqual({
|
||||
error:
|
||||
'Recipe result requests provisioned-root checkout, but the recipe is not configured for it.',
|
||||
errorCode: 'provisionedRootNotConfigured'
|
||||
})
|
||||
expect(
|
||||
getEphemeralVmRecipeCheckoutModeError(
|
||||
getEphemeralVmRecipeCheckoutModeFailure(
|
||||
{ ...defaultRecipe, checkoutMode: 'provisioned-root' },
|
||||
provisionedRootResult
|
||||
)
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { getEphemeralVmRecipeResultCheckoutMode } from './ephemeral-vm-recipes'
|
||||
import type { EphemeralVmRecipeResult } from './ephemeral-vm-recipes'
|
||||
import type { OrcaVmRecipe } from './orca-yaml-hook-types'
|
||||
import { ephemeralVmFailure, type EphemeralVmFailure } from './ephemeral-vm-failure-copy'
|
||||
|
||||
export function getEphemeralVmRecipeResultSchemaVersion(recipe: OrcaVmRecipe): 1 | 2 {
|
||||
return recipe.checkoutMode === 'provisioned-root' ? 2 : 1
|
||||
}
|
||||
|
||||
export function getEphemeralVmRecipeCheckoutModeError(
|
||||
export function getEphemeralVmRecipeCheckoutModeFailure(
|
||||
recipe: OrcaVmRecipe,
|
||||
result: EphemeralVmRecipeResult
|
||||
): string | null {
|
||||
): EphemeralVmFailure | 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.'
|
||||
return ephemeralVmFailure(
|
||||
configuredMode === 'provisioned-root'
|
||||
? 'provisionedRootResultRequired'
|
||||
: 'provisionedRootNotConfigured'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { ProcessRunResult } from './ephemeral-vm-recipe-process'
|
||||
import {
|
||||
EPHEMERAL_VM_FAILURE_COPY,
|
||||
ephemeralVmExitFailure,
|
||||
ephemeralVmFailure,
|
||||
type EphemeralVmFailure
|
||||
} from './ephemeral-vm-failure-copy'
|
||||
|
||||
export const EPHEMERAL_VM_CLEANUP_STOPPED_ERROR = 'Cleanup stopped by user.'
|
||||
export const EPHEMERAL_VM_CLEANUP_STOPPED_ERROR = EPHEMERAL_VM_FAILURE_COPY.cleanupStopped
|
||||
|
||||
type FailedEphemeralVmRecipeDestroy = {
|
||||
ok: false
|
||||
skipped: false
|
||||
error: string
|
||||
} & ProcessRunResult
|
||||
} & EphemeralVmFailure &
|
||||
ProcessRunResult
|
||||
|
||||
export function getEphemeralVmRecipeDestroyFailure(
|
||||
result: ProcessRunResult
|
||||
@@ -15,7 +21,7 @@ export function getEphemeralVmRecipeDestroyFailure(
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
error: EPHEMERAL_VM_CLEANUP_STOPPED_ERROR,
|
||||
...ephemeralVmFailure('cleanupStopped'),
|
||||
...result
|
||||
}
|
||||
}
|
||||
@@ -23,7 +29,7 @@ export function getEphemeralVmRecipeDestroyFailure(
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
error: `Destroy exited with code ${result.exitCode ?? 'unknown'}.`,
|
||||
...ephemeralVmExitFailure('destroy', result.exitCode),
|
||||
...result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { statSync } from 'node:fs'
|
||||
import type { OrcaVmRecipe } from './orca-yaml-hook-types'
|
||||
import { parseEphemeralVmRecipeResult, type EphemeralVmRecipeResult } from './ephemeral-vm-recipes'
|
||||
import {
|
||||
getEphemeralVmRecipeCheckoutModeError,
|
||||
getEphemeralVmRecipeCheckoutModeFailure,
|
||||
getEphemeralVmRecipeResultSchemaVersion
|
||||
} from './ephemeral-vm-recipe-checkout-mode'
|
||||
import { runRecipeCommand } from './ephemeral-vm-recipe-process'
|
||||
import {
|
||||
ephemeralVmExitFailure,
|
||||
pickEphemeralVmFailureCode,
|
||||
type EphemeralVmFailure
|
||||
} from './ephemeral-vm-failure-copy'
|
||||
import { getEphemeralVmRecipeDestroyFailure } from './ephemeral-vm-recipe-destroy-result'
|
||||
import {
|
||||
buildEphemeralVmRecipeCleanupPayload,
|
||||
@@ -57,13 +62,12 @@ export type EphemeralVmRecipeStartSuccess = {
|
||||
export type EphemeralVmRecipeStartFailure = {
|
||||
ok: false
|
||||
context: EphemeralVmRecipeContext
|
||||
error: string
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
recipeResult?: EphemeralVmRecipeResult
|
||||
}
|
||||
} & EphemeralVmFailure
|
||||
|
||||
export type EphemeralVmRecipeStartResult =
|
||||
| EphemeralVmRecipeStartSuccess
|
||||
@@ -87,12 +91,11 @@ export type EphemeralVmRecipeLifecycleArgs = EphemeralVmRecipeCleanupArgs
|
||||
export type EphemeralVmRecipeCleanupResult = {
|
||||
ok: boolean
|
||||
skipped: boolean
|
||||
error?: string
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
}
|
||||
} & Partial<EphemeralVmFailure>
|
||||
|
||||
export type EphemeralVmRecipeResumeResult =
|
||||
| (EphemeralVmRecipeStartSuccess & { skipped: false })
|
||||
@@ -128,7 +131,7 @@ export async function runEphemeralVmRecipeStart(
|
||||
return {
|
||||
ok: false,
|
||||
context,
|
||||
error: `Recipe exited with code ${processResult.exitCode ?? 'unknown'}.`,
|
||||
...ephemeralVmExitFailure('recipe', processResult.exitCode),
|
||||
...processResult
|
||||
}
|
||||
}
|
||||
@@ -139,15 +142,16 @@ export async function runEphemeralVmRecipeStart(
|
||||
ok: false,
|
||||
context,
|
||||
error: parsed.error,
|
||||
...pickEphemeralVmFailureCode(parsed),
|
||||
...processResult
|
||||
}
|
||||
}
|
||||
const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result)
|
||||
if (checkoutModeError) {
|
||||
const checkoutModeFailure = getEphemeralVmRecipeCheckoutModeFailure(args.recipe, parsed.result)
|
||||
if (checkoutModeFailure) {
|
||||
return {
|
||||
ok: false,
|
||||
context,
|
||||
error: checkoutModeError,
|
||||
...checkoutModeFailure,
|
||||
recipeResult: parsed.result,
|
||||
...processResult
|
||||
}
|
||||
@@ -277,16 +281,17 @@ export async function runEphemeralVmRecipeResume(
|
||||
skipped: false,
|
||||
context: args.context,
|
||||
error: parsed.error,
|
||||
...pickEphemeralVmFailureCode(parsed),
|
||||
...processResult
|
||||
}
|
||||
}
|
||||
const checkoutModeError = getEphemeralVmRecipeCheckoutModeError(args.recipe, parsed.result)
|
||||
if (checkoutModeError) {
|
||||
const checkoutModeFailure = getEphemeralVmRecipeCheckoutModeFailure(args.recipe, parsed.result)
|
||||
if (checkoutModeFailure) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
context: args.context,
|
||||
error: checkoutModeError,
|
||||
...checkoutModeFailure,
|
||||
recipeResult: parsed.result,
|
||||
...processResult
|
||||
}
|
||||
|
||||
@@ -183,14 +183,16 @@ describe('parseEphemeralVmRecipeResult', () => {
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'Recipe result projectRoot must be an absolute runtime path.'
|
||||
error: 'Recipe result projectRoot must be an absolute runtime path.',
|
||||
errorCode: 'recipeResultProjectRootNotAbsolute'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-json stdout', () => {
|
||||
expect(parseEphemeralVmRecipeResult('Pairing URL: nope')).toEqual({
|
||||
ok: false,
|
||||
error: 'Recipe stdout must be one JSON object.'
|
||||
error: 'Recipe stdout must be one JSON object.',
|
||||
errorCode: 'recipeResultNotJsonObject'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +204,8 @@ describe('parseEphemeralVmRecipeResult', () => {
|
||||
|
||||
expect(parseEphemeralVmRecipeResult(amplified)).toEqual({
|
||||
ok: false,
|
||||
error: 'Recipe stdout must be one JSON object.'
|
||||
error: 'Recipe stdout must be one JSON object.',
|
||||
errorCode: 'recipeResultNotJsonObject'
|
||||
})
|
||||
expect(parseSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
@@ -221,7 +224,8 @@ describe('parseEphemeralVmRecipeResult', () => {
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'Recipe result pairingCode is not a valid Orca pairing code.'
|
||||
error: 'Recipe result pairingCode is not a valid Orca pairing code.',
|
||||
errorCode: 'recipeResultPairingCodeInvalid'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,7 +240,8 @@ describe('parseEphemeralVmRecipeResult', () => {
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'Recipe result projectRoot must be an absolute runtime path.'
|
||||
error: 'Recipe result projectRoot must be an absolute runtime path.',
|
||||
errorCode: 'recipeResultProjectRootNotAbsolute'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { parsePairingCode } from './pairing'
|
||||
import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from './ssh-types'
|
||||
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
||||
import { ephemeralVmFailure, type EphemeralVmFailure } from './ephemeral-vm-failure-copy'
|
||||
|
||||
const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([
|
||||
@@ -133,7 +134,7 @@ export type EphemeralVmRecipeResult = z.infer<typeof EphemeralVmRecipeResultSche
|
||||
|
||||
export type EphemeralVmRecipeResultParseResult =
|
||||
| { ok: true; result: EphemeralVmRecipeResult }
|
||||
| { ok: false; error: string }
|
||||
| ({ ok: false } & EphemeralVmFailure)
|
||||
|
||||
export type EphemeralVmRecipeDoctorCheckStatus = 'pass' | 'warn' | 'fail'
|
||||
|
||||
@@ -154,14 +155,14 @@ export type EphemeralVmRecipeDoctorResult = {
|
||||
export function parseEphemeralVmRecipeResult(stdout: string): EphemeralVmRecipeResultParseResult {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: 'Recipe produced no JSON result.' }
|
||||
return { ok: false, ...ephemeralVmFailure('recipeResultMissing') }
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
assertJsonTextStructureWithinLimits(trimmed, EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS)
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
return { ok: false, error: 'Recipe stdout must be one JSON object.' }
|
||||
return { ok: false, ...ephemeralVmFailure('recipeResultNotJsonObject') }
|
||||
}
|
||||
const result = EphemeralVmRecipeResultSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
@@ -169,10 +170,10 @@ export function parseEphemeralVmRecipeResult(stdout: string): EphemeralVmRecipeR
|
||||
}
|
||||
const connection = getEphemeralVmRecipeResultConnection(result.data)
|
||||
if (connection.type === 'orca-server' && !parsePairingCode(connection.pairingCode)) {
|
||||
return { ok: false, error: 'Recipe result pairingCode is not a valid Orca pairing code.' }
|
||||
return { ok: false, ...ephemeralVmFailure('recipeResultPairingCodeInvalid') }
|
||||
}
|
||||
if (!isAbsoluteRuntimePath(connection.projectRoot)) {
|
||||
return { ok: false, error: 'Recipe result projectRoot must be an absolute runtime path.' }
|
||||
return { ok: false, ...ephemeralVmFailure('recipeResultProjectRootNotAbsolute') }
|
||||
}
|
||||
return { ok: true, result: result.data }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
upsertEphemeralVmRuntime
|
||||
} from './ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from './ephemeral-vm-runtimes'
|
||||
import { ephemeralVmFailure } from './ephemeral-vm-failure-copy'
|
||||
|
||||
function pairingCode(endpoint = 'wss://sandbox.example.com'): string {
|
||||
return encodePairingOffer({
|
||||
@@ -125,6 +126,27 @@ describe('ephemeral VM runtime store', () => {
|
||||
expect(recovered.cleanupLastError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists a cleanup failure code next to its English and clears it with plain text', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
upsertEphemeralVmRuntime(userDataPath, runtimeRecord())
|
||||
|
||||
updateEphemeralVmRuntimeStatus(userDataPath, 'orca-instance-1', {
|
||||
cleanupLastError: ephemeralVmFailure('destroyExited', { exitCode: 2 })
|
||||
})
|
||||
expect(listEphemeralVmRuntimes(userDataPath)[0]).toMatchObject({
|
||||
cleanupLastError: 'Destroy exited with code 2.',
|
||||
cleanupLastErrorCode: 'destroyExited',
|
||||
cleanupLastErrorParams: { exitCode: 2 }
|
||||
})
|
||||
|
||||
const uncoded = updateEphemeralVmRuntimeStatus(userDataPath, 'orca-instance-1', {
|
||||
cleanupLastError: 'provider delete failed'
|
||||
})
|
||||
expect(uncoded.cleanupLastError).toBe('provider delete failed')
|
||||
expect(uncoded.cleanupLastErrorCode).toBeUndefined()
|
||||
expect(uncoded.cleanupLastErrorParams).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists runtime connection metadata', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
upsertEphemeralVmRuntime(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JsonStringifyByteLimitError } from './node-bounded-json-stringify'
|
||||
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
|
||||
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
|
||||
import { hardenExistingSecureFile } from './secure-file'
|
||||
import { toCleanupLastErrorFields, type EphemeralVmFailure } from './ephemeral-vm-failure-copy'
|
||||
import {
|
||||
featureEntryFromRuntime,
|
||||
featureIdentity,
|
||||
@@ -118,7 +119,8 @@ export function updateEphemeralVmRuntimeStatus(
|
||||
status?: EphemeralVmRuntimeStatus
|
||||
cleanupStatus?: EphemeralVmCleanupStatus
|
||||
cleanupLastAttemptAt?: number
|
||||
cleanupLastError?: string | null
|
||||
/** A string clears any stored code; null clears both. */
|
||||
cleanupLastError?: string | EphemeralVmFailure | null
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
connectionMode?: EphemeralVmRuntimeRecord['connectionMode'] | null
|
||||
@@ -143,11 +145,13 @@ export function updateEphemeralVmRuntimeStatus(
|
||||
...(args.cleanupLastAttemptAt !== undefined
|
||||
? { cleanupLastAttemptAt: args.cleanupLastAttemptAt }
|
||||
: {}),
|
||||
...(args.cleanupLastError === null
|
||||
? { cleanupLastError: undefined }
|
||||
: args.cleanupLastError
|
||||
? { cleanupLastError: args.cleanupLastError }
|
||||
: {}),
|
||||
...(args.cleanupLastError === null || args.cleanupLastError
|
||||
? toCleanupLastErrorFields(
|
||||
typeof args.cleanupLastError === 'string'
|
||||
? { error: args.cleanupLastError }
|
||||
: args.cleanupLastError
|
||||
)
|
||||
: {}),
|
||||
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
|
||||
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
|
||||
// null explicitly clears the field (e.g. terminal cleanup); undefined leaves it unchanged.
|
||||
|
||||
@@ -62,6 +62,9 @@ export const EphemeralVmRuntimeRecordSchema = z.object({
|
||||
cleanupDisabled: z.boolean().optional(),
|
||||
cleanupLastAttemptAt: z.number().finite().optional(),
|
||||
cleanupLastError: z.string().min(1).optional(),
|
||||
/** Optional open code + params so the renderer can localize `cleanupLastError`; English stays authoritative. */
|
||||
cleanupLastErrorCode: z.string().min(1).optional(),
|
||||
cleanupLastErrorParams: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||
createdAt: z.number().finite(),
|
||||
updatedAt: z.number().finite(),
|
||||
recipeResult: EphemeralVmRecipeResultSchema
|
||||
|
||||
Reference in New Issue
Block a user