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:
OrcaWin
2026-10-29 07:06:15 +00:00
committed by GitHub
co-authored by OrcaWin
parent 478a008299
commit 1bfba51e36
36 changed files with 857 additions and 106 deletions
+16 -6
View File
@@ -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)
}
}
+9 -2
View File
@@ -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, {
+2 -1
View File
@@ -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)
})
+41 -22
View File
@@ -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')
})
}
)
+20 -14
View File
@@ -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: '' }
}
+4 -1
View File
@@ -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
View File
@@ -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}}",
+27
View File
@@ -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."
}
}
}
+27
View File
@@ -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."
}
}
}
+27
View File
@@ -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."
}
}
}
+27
View File
@@ -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."
}
}
}
+27
View File
@@ -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": "破棄処理は不明なコードで終了しました。"
}
}
}
+27
View File
@@ -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": "삭제 작업이 알 수 없는 코드로 종료되었습니다."
}
}
}
+27
View File
@@ -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."
}
}
}
+27
View File
@@ -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": "Уничтожение завершилось с неизвестным кодом."
}
}
}
+27
View File
@@ -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": "銷燬作業已結束,結束代碼未知。"
}
}
}
+27
View File
@@ -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)
+121
View File
@@ -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
}
}
+17 -12
View File
@@ -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
}
+10 -5
View File
@@ -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'
})
})
+6 -5
View File
@@ -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(
+10 -6
View File
@@ -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.
+3
View File
@@ -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