From 1bfba51e36f1df2592eb565379e1377f69c6d742 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 00:06:15 -0700 Subject: [PATCH] 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> --- src/main/ephemeral-vm/failed-start-cleanup.ts | 22 ++- src/main/ephemeral-vm/runtime-service.ts | 11 +- src/main/ephemeral-vm/runtime-ssh-cleanup.ts | 3 +- .../ipc/ephemeral-vm-provision-cancel.test.ts | 8 +- src/main/ipc/ephemeral-vm-recipe-context.ts | 63 ++++++--- src/main/ipc/ephemeral-vm-runtime-handlers.ts | 8 +- src/main/ipc/ephemeral-vm.ts | 34 +++-- src/preload/api/ephemeral-vm-api.ts | 5 +- .../settings/EphemeralVmRuntimeRow.tsx | 6 +- .../settings/EphemeralVmRuntimesSection.tsx | 3 +- .../src/hooks/useEphemeralVmRecipeOptions.ts | 31 +++-- .../src/i18n/en-runtime-required.json | 27 ++++ src/renderer/src/i18n/locales/de.json | 27 ++++ src/renderer/src/i18n/locales/en.json | 27 ++++ src/renderer/src/i18n/locales/es.json | 27 ++++ src/renderer/src/i18n/locales/fr.json | 27 ++++ src/renderer/src/i18n/locales/ja.json | 27 ++++ src/renderer/src/i18n/locales/ko.json | 27 ++++ src/renderer/src/i18n/locales/pt.json | 27 ++++ src/renderer/src/i18n/locales/ru.json | 27 ++++ src/renderer/src/i18n/locales/tr.json | 27 ++++ src/renderer/src/i18n/locales/zh-Hant.json | 27 ++++ src/renderer/src/i18n/locales/zh.json | 27 ++++ .../src/lib/ephemeral-vm-error-copy.test.ts | 53 +++++++ .../src/lib/ephemeral-vm-error-copy.ts | 129 ++++++++++++++++++ .../src/lib/ephemeral-vm-workspace-target.ts | 3 +- src/shared/ephemeral-vm-failure-copy.ts | 121 ++++++++++++++++ .../ephemeral-vm-recipe-checkout-mode.test.ts | 14 +- .../ephemeral-vm-recipe-checkout-mode.ts | 13 +- .../ephemeral-vm-recipe-destroy-result.ts | 16 ++- src/shared/ephemeral-vm-recipe-runner.ts | 29 ++-- src/shared/ephemeral-vm-recipes.test.ts | 15 +- src/shared/ephemeral-vm-recipes.ts | 11 +- src/shared/ephemeral-vm-runtime-store.test.ts | 22 +++ src/shared/ephemeral-vm-runtime-store.ts | 16 ++- src/shared/ephemeral-vm-runtimes.ts | 3 + 36 files changed, 857 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/lib/ephemeral-vm-error-copy.test.ts create mode 100644 src/renderer/src/lib/ephemeral-vm-error-copy.ts create mode 100644 src/shared/ephemeral-vm-failure-copy.ts diff --git a/src/main/ephemeral-vm/failed-start-cleanup.ts b/src/main/ephemeral-vm/failed-start-cleanup.ts index 5c454e6e3da..878f59cebf9 100644 --- a/src/main/ephemeral-vm/failed-start-cleanup.ts +++ b/src/main/ephemeral-vm/failed-start-cleanup.ts @@ -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 { +): Promise { 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) } } diff --git a/src/main/ephemeral-vm/runtime-service.ts b/src/main/ephemeral-vm/runtime-service.ts index c9cc5017b0c..5a94f2ec621 100644 --- a/src/main/ephemeral-vm/runtime-service.ts +++ b/src/main/ephemeral-vm/runtime-service.ts @@ -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, { diff --git a/src/main/ephemeral-vm/runtime-ssh-cleanup.ts b/src/main/ephemeral-vm/runtime-ssh-cleanup.ts index 606052517e2..e88a84a7160 100644 --- a/src/main/ephemeral-vm/runtime-ssh-cleanup.ts +++ b/src/main/ephemeral-vm/runtime-ssh-cleanup.ts @@ -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) diff --git a/src/main/ipc/ephemeral-vm-provision-cancel.test.ts b/src/main/ipc/ephemeral-vm-provision-cancel.test.ts index ede14cf8fe9..0243488d077 100644 --- a/src/main/ipc/ephemeral-vm-provision-cancel.test.ts +++ b/src/main/ipc/ephemeral-vm-provision-cancel.test.ts @@ -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) }) diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index 034d34663bc..e797a57e2b7 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -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>['environmentRecipeDiagnostics'] message?: string + messageCode?: string + messageParams?: Record } export type EphemeralVmRecipeCatalogEntry = { @@ -24,7 +31,12 @@ export type EphemeralVmRecipeCatalogEntry = { export type RecipeRepoResult = | { ok: true; repo: Exclude, 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 } : {}) + } +} diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index fada16a9e16..d43c5adedb0 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -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') }) } ) diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index ecff0758281..73900e99108 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -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() @@ -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 => { 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: '' } +} diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index e44139a2d5c..5234044a3e5 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -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 message?: string + messageCode?: string + messageParams?: Record }> 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: ( diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimeRow.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimeRow.tsx index ad6b737e84e..b650734e4a2 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimeRow.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimeRow.tsx @@ -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 (
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}

- {runtime.cleanupLastError ? ( -

{runtime.cleanupLastError}

+ {cleanupError ? ( +

{cleanupError}

) : null}
diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx index da37b6bcad0..4513e9cfc67 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx @@ -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.' diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts index 3cf47e7a707..97750798d9e 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts @@ -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([]) const [selectedRecipeId, setSelectedRecipeId] = useState(null) - const [error, setError] = useState(null) + // Failures stay coded until render so a language switch relocalizes them. + const [errors, setErrors] = useState([]) 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 } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1acf72a7658..40cb481c663 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 0ad8f3b0b99..ba9df812ca6 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -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." + } } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4e6f1441db8..d4ab14c5bd9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4ea96819e6d..9bd007b66dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 19ba9d65bf4..8cc8f2d9832 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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." + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5cc78b18321..af26699d994 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "破棄処理は不明なコードで終了しました。" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7bf422ac70f..3191bfbf2a4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "삭제 작업이 알 수 없는 코드로 종료되었습니다." + } } } diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 44f5c540c2e..0fdafeeb99c 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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." + } } } diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 8d346c6b833..db2b9192d42 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -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": "Уничтожение завершилось с неизвестным кодом." + } } } diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 4437528d0a8..5042fa5f71a 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -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ı." + } } } diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index ba4add1973e..1d9bed3bb44 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -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": "銷燬作業已結束,結束代碼未知。" + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index af4eb35bda3..fab3aa96e5f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "销毁操作已退出,退出码未知。" + } } } diff --git a/src/renderer/src/lib/ephemeral-vm-error-copy.test.ts b/src/renderer/src/lib/ephemeral-vm-error-copy.test.ts new file mode 100644 index 00000000000..e4e255c211e --- /dev/null +++ b/src/renderer/src/lib/ephemeral-vm-error-copy.test.ts @@ -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' + ) + }) +}) diff --git a/src/renderer/src/lib/ephemeral-vm-error-copy.ts b/src/renderer/src/lib/ephemeral-vm-error-copy.ts new file mode 100644 index 00000000000..3d15f7e5cfb --- /dev/null +++ b/src/renderer/src/lib/ephemeral-vm-error-copy.ts @@ -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 + +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 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 + }) +} diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index 878bda208fd..4df897e950b 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -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) diff --git a/src/shared/ephemeral-vm-failure-copy.ts b/src/shared/ephemeral-vm-failure-copy.ts new file mode 100644 index 00000000000..e6183341801 --- /dev/null +++ b/src/shared/ephemeral-vm-failure-copy.ts @@ -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 +} + +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 | 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 +): Pick { + return { + ...(failure.errorCode ? { errorCode: failure.errorCode } : {}), + ...(failure.errorParams ? { errorParams: failure.errorParams } : {}) + } +} diff --git a/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts b/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts index d49d395afde..37d5bf910b9 100644 --- a/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts +++ b/src/shared/ephemeral-vm-recipe-checkout-mode.test.ts @@ -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 ) diff --git a/src/shared/ephemeral-vm-recipe-checkout-mode.ts b/src/shared/ephemeral-vm-recipe-checkout-mode.ts index 67e27f3e89e..50181645481 100644 --- a/src/shared/ephemeral-vm-recipe-checkout-mode.ts +++ b/src/shared/ephemeral-vm-recipe-checkout-mode.ts @@ -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' + ) } diff --git a/src/shared/ephemeral-vm-recipe-destroy-result.ts b/src/shared/ephemeral-vm-recipe-destroy-result.ts index 5a569a240cc..e6fa7124994 100644 --- a/src/shared/ephemeral-vm-recipe-destroy-result.ts +++ b/src/shared/ephemeral-vm-recipe-destroy-result.ts @@ -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 } } diff --git a/src/shared/ephemeral-vm-recipe-runner.ts b/src/shared/ephemeral-vm-recipe-runner.ts index 9968d106018..ba3c9f82638 100644 --- a/src/shared/ephemeral-vm-recipe-runner.ts +++ b/src/shared/ephemeral-vm-recipe-runner.ts @@ -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 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 } diff --git a/src/shared/ephemeral-vm-recipes.test.ts b/src/shared/ephemeral-vm-recipes.test.ts index c57dfaf8241..fbd27f5a652 100644 --- a/src/shared/ephemeral-vm-recipes.test.ts +++ b/src/shared/ephemeral-vm-recipes.test.ts @@ -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' }) }) diff --git a/src/shared/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 108bd6ca4ff..51ba53fbf2b 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -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 = z.lazy(() => z.union([ @@ -133,7 +134,7 @@ export type EphemeralVmRecipeResult = z.infer { 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( diff --git a/src/shared/ephemeral-vm-runtime-store.ts b/src/shared/ephemeral-vm-runtime-store.ts index ebf06963a74..d8757c26c74 100644 --- a/src/shared/ephemeral-vm-runtime-store.ts +++ b/src/shared/ephemeral-vm-runtime-store.ts @@ -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. diff --git a/src/shared/ephemeral-vm-runtimes.ts b/src/shared/ephemeral-vm-runtimes.ts index 6ae0742e37a..f8646c0fe0e 100644 --- a/src/shared/ephemeral-vm-runtimes.ts +++ b/src/shared/ephemeral-vm-runtimes.ts @@ -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