diff --git a/src/main/index.ts b/src/main/index.ts index 3ea350d798d..e0930ae909c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,6 +58,10 @@ import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-rout import { resolveEnvironment } from '../shared/runtime-environment-store' import { getPreferredPairingOffer } from '../shared/runtime-environments' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' +import { + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from './runtime/runtime-rpc-startup-failure' import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' import { ServeReadinessPublisher } from './server/serve-readiness' import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' @@ -2689,12 +2693,19 @@ void app.whenReady().then(async () => { } // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. - const [win] = await Promise.all([ + const [win, runtimeRpcStartResult] = await Promise.all([ Promise.resolve(openMainWindow()), - runtimeRpc.start().catch((error) => { - console.error('[runtime] Failed to start local RPC transport:', error) - }) + runtimeRpc.start().then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) ]) + if (!runtimeRpcStartResult.ok) { + void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + } const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { diff --git a/src/main/runtime/runtime-rpc-startup-failure.test.ts b/src/main/runtime/runtime-rpc-startup-failure.test.ts new file mode 100644 index 00000000000..07053fe9fb8 --- /dev/null +++ b/src/main/runtime/runtime-rpc-startup-failure.test.ts @@ -0,0 +1,270 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { showMessageBoxMock, trackMock } = vi.hoisted(() => ({ + showMessageBoxMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('electron', () => ({ + dialog: { + showMessageBox: showMessageBoxMock + } +})) + +vi.mock('../i18n/main-i18n', () => ({ + // Why: substitute every supplied placeholder, not just {{cause}} — a mock that ignores one + // would leave a literal {{...}} in the detail and hide it from every assertion below. + translateMain: ( + _key: string, + fallback: string, + options?: Readonly> + ): string => + Object.entries(options ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{{${name}}}`, value), + fallback + ) +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +import { + classifyRuntimeRpcStartFailure, + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from './runtime-rpc-startup-failure' + +type FakeParentWindow = Electron.BrowserWindow & EventEmitter + +function createParentWindow( + visible = true, + destroyed = false, + webContentsDestroyed = destroyed +): FakeParentWindow { + const webContents = Object.assign(new EventEmitter(), { + isDestroyed: () => webContentsDestroyed + }) + const parentWindow = Object.assign(new EventEmitter(), { + isDestroyed: () => destroyed, + isVisible: () => visible + }) as unknown as FakeParentWindow + Object.defineProperty(parentWindow, 'webContents', { + get: () => { + if (destroyed) { + throw new Error('Object has been destroyed') + } + return webContents + } + }) + return parentWindow +} + +// Why: the dialog is deferred behind an await, so a synchronous "not called yet" assertion +// would pass even if the deferral were deleted; drain the microtask queue first. +function flushMicrotasks(): Promise { + return new Promise((resolve) => { + setImmediate(resolve) + }) +} + +describe('runtime RPC startup failure reporting', () => { + beforeEach(() => { + showMessageBoxMock.mockReset().mockResolvedValue({ response: 0 }) + trackMock.mockReset() + }) + + it.each([ + ['EACCES', 'permission_denied'], + ['EPERM', 'permission_denied'], + ['EADDRINUSE', 'address_in_use'], + ['ENOSPC', 'storage_unavailable'], + ['EROFS', 'storage_unavailable'], + ['EINVAL', 'invalid_path'], + ['ENOENT', 'invalid_path'], + ['ENAMETOOLONG', 'invalid_path'], + ['unexpected', 'unknown'] + ] as const)('classifies %s without exposing the raw error', (code, expected) => { + const error = Object.assign(new Error('/Users/private/orca-runtime.json'), { code }) + + expect(classifyRuntimeRpcStartFailure(error)).toBe(expected) + }) + + it('classifies a code carried on a wrapped cause', () => { + const error = new Error('failed to publish orca-runtime.json', { + cause: Object.assign(new Error('read-only volume'), { code: 'EROFS' }) + }) + + expect(classifyRuntimeRpcStartFailure(error)).toBe('storage_unavailable') + }) + + it('walks past an unmapped wrapper code to the mapped cause', () => { + const error = Object.assign(new Error('failed to publish orca-runtime.json'), { + code: 'ERR_PUBLISH_FAILED', + cause: Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + + expect(classifyRuntimeRpcStartFailure(error)).toBe('permission_denied') + }) + + it('survives a self-referential cause chain', () => { + const error: Error & { cause?: unknown } = new Error('cyclic') + error.cause = error + + expect(classifyRuntimeRpcStartFailure(error)).toBe('unknown') + }) + + it('records a privacy-safe telemetry event', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = Object.assign(new Error('/Users/private/orca-runtime.json'), { code: 'EACCES' }) + + recordRuntimeRpcStartFailure(error) + + expect(trackMock).toHaveBeenCalledWith('runtime_rpc_start_failed', { + error_class: 'permission_denied' + }) + expect(JSON.stringify(trackMock.mock.calls)).not.toContain('/Users/private') + consoleError.mockRestore() + }) + + it('does not let telemetry failure escape the startup failure handler', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const telemetryError = new Error('telemetry unavailable') + trackMock.mockImplementationOnce(() => { + throw telemetryError + }) + + expect(() => recordRuntimeRpcStartFailure(new Error('RPC failed'))).not.toThrow() + expect(consoleError).toHaveBeenCalledWith( + '[runtime] Failed to record RPC startup failure telemetry:', + telemetryError + ) + consoleError.mockRestore() + }) + + it('shows the CLI impact and local cause', async () => { + const parentWindow = createParentWindow() + const error = new Error('metadata write failed') + + await showRuntimeRpcStartupFailureDialog(parentWindow, error) + + expect(showMessageBoxMock).toHaveBeenCalledWith( + parentWindow, + expect.objectContaining({ + type: 'error', + title: 'Orca CLI unavailable', + message: "Orca couldn't start its local command transport.", + detail: expect.stringMatching( + /orca status.*orca terminal.*orchestration.*Cause: metadata write failed/s + ) + }) + ) + }) + + // Why: a bare "restart" is only true for address_in_use — the other classes need the user to + // change something, so each must reach the dialog with its own remediation. + it.each([ + ['EACCES', "Check permissions on Orca's data folder"], + ['EPERM', "Check permissions on Orca's data folder"], + ['ENOSPC', 'Your disk may be full or read-only'], + ['EROFS', 'Your disk may be full or read-only'], + ['EINVAL', 'at a path that is too long'], + ['ENAMETOOLONG', 'at a path that is too long'], + ['ENOENT', "Orca's data folder may be missing"], + ['EADDRINUSE', 'Another process may be holding the port'] + ] as const)('guides the user on how to fix %s', async (code, guidance) => { + const error = Object.assign(new Error('metadata write failed'), { code }) + + await showRuntimeRpcStartupFailureDialog(createParentWindow(), error) + + const detail = showMessageBoxMock.mock.calls[0]?.[1]?.detail as string + expect(detail).toContain(guidance) + expect(detail).not.toContain('{{') + }) + + it('falls back to a plain restart when the cause is unrecognised', async () => { + await showRuntimeRpcStartupFailureDialog(createParentWindow(), new Error('mystery')) + + const detail = showMessageBoxMock.mock.calls[0]?.[1]?.detail as string + expect(detail).toContain('Restart Orca to try again.') + expect(detail).not.toContain("Check permissions on Orca's data folder") + }) + + it('truncates a runaway cause instead of pasting it whole into the dialog', async () => { + await showRuntimeRpcStartupFailureDialog(createParentWindow(), new Error('x'.repeat(900))) + + const detail = showMessageBoxMock.mock.calls[0]?.[1]?.detail as string + const cause = detail.slice(detail.indexOf('Cause: ') + 'Cause: '.length) + expect(cause).toHaveLength(500) + expect(cause.endsWith('…')).toBe(true) + }) + + it('waits until the app window is visible', async () => { + const parentWindow = createParentWindow(false) + const reporting = showRuntimeRpcStartupFailureDialog( + parentWindow, + new Error('metadata write failed') + ) + + await flushMicrotasks() + expect(showMessageBoxMock).not.toHaveBeenCalled() + parentWindow.emit('show') + await reporting + + expect(showMessageBoxMock).toHaveBeenCalledOnce() + expect(parentWindow.listenerCount('show')).toBe(0) + expect(parentWindow.webContents.listenerCount('destroyed')).toBe(0) + }) + + it('never shows a dialog against an already destroyed window', async () => { + const parentWindow = createParentWindow(false, true) + + await showRuntimeRpcStartupFailureDialog(parentWindow, new Error('metadata write failed')) + + expect(showMessageBoxMock).not.toHaveBeenCalled() + expect(parentWindow.listenerCount('show')).toBe(0) + }) + + it('never waits on already destroyed web contents', async () => { + const parentWindow = createParentWindow(false, false, true) + + await showRuntimeRpcStartupFailureDialog(parentWindow, new Error('metadata write failed')) + + expect(showMessageBoxMock).not.toHaveBeenCalled() + expect(parentWindow.listenerCount('show')).toBe(0) + expect(parentWindow.webContents.listenerCount('destroyed')).toBe(0) + }) + + it('drops the pending dialog and its listeners when the window closes first', async () => { + const parentWindow = createParentWindow(false) + const reporting = showRuntimeRpcStartupFailureDialog( + parentWindow, + new Error('metadata write failed') + ) + + await flushMicrotasks() + expect(parentWindow.listenerCount('closed')).toBe(0) + expect(parentWindow.webContents.listenerCount('destroyed')).toBe(1) + parentWindow.webContents.emit('destroyed') + await reporting + + expect(showMessageBoxMock).not.toHaveBeenCalled() + expect(parentWindow.listenerCount('show')).toBe(0) + expect(parentWindow.webContents.listenerCount('destroyed')).toBe(0) + }) + + it('logs instead of rejecting if Electron cannot show the dialog', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + showMessageBoxMock.mockRejectedValueOnce(new Error('window closed')) + + await expect( + showRuntimeRpcStartupFailureDialog(createParentWindow(), new Error('failed')) + ).resolves.toBeUndefined() + expect(consoleError).toHaveBeenCalledWith( + '[runtime] Failed to show RPC startup failure dialog:', + expect.any(Error) + ) + consoleError.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-rpc-startup-failure.ts b/src/main/runtime/runtime-rpc-startup-failure.ts new file mode 100644 index 00000000000..fa04a1a03c3 --- /dev/null +++ b/src/main/runtime/runtime-rpc-startup-failure.ts @@ -0,0 +1,165 @@ +import { dialog, type BrowserWindow, type MessageBoxOptions } from 'electron' + +import type { RuntimeRpcStartErrorClass } from '../../shared/telemetry-events' +import { translateMain } from '../i18n/main-i18n' +import { track } from '../telemetry/client' + +const MAX_VISIBLE_CAUSE_LENGTH = 500 + +const ERROR_CLASS_BY_CODE: Readonly> = { + EACCES: 'permission_denied', + EPERM: 'permission_denied', + EADDRINUSE: 'address_in_use', + EDQUOT: 'storage_unavailable', + EIO: 'storage_unavailable', + ENOSPC: 'storage_unavailable', + EROFS: 'storage_unavailable', + EINVAL: 'invalid_path', + ENAMETOOLONG: 'invalid_path', + ENOENT: 'invalid_path', + ENOTDIR: 'invalid_path' +} + +function getErrorCode(error: unknown, seen = new Set()): string | null { + if (typeof error !== 'object' || error === null || seen.has(error)) { + return null + } + seen.add(error) + // Why: only a mapped code ends the walk — an unmapped wrapper code would otherwise mask a nested EACCES/ENOSPC. + const code = 'code' in error ? error.code : undefined + if (typeof code === 'string') { + const normalizedCode = code.toUpperCase() + if (ERROR_CLASS_BY_CODE[normalizedCode]) { + return normalizedCode + } + } + return 'cause' in error ? getErrorCode(error.cause, seen) : null +} + +export function classifyRuntimeRpcStartFailure(error: unknown): RuntimeRpcStartErrorClass { + const code = getErrorCode(error) + return (code && ERROR_CLASS_BY_CODE[code]) || 'unknown' +} + +function describeRuntimeRpcStartFailure(error: unknown): string { + const raw = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : translateMain( + 'runtimeRpc.startupFailure.unknownCause', + 'No additional error details were available.' + ) + const normalized = + raw.trim() || + translateMain( + 'runtimeRpc.startupFailure.unknownCause', + 'No additional error details were available.' + ) + return normalized.length <= MAX_VISIBLE_CAUSE_LENGTH + ? normalized + : `${normalized.slice(0, MAX_VISIBLE_CAUSE_LENGTH - 1)}…` +} + +// Why: a bare "restart" is wrong for every class but address_in_use — perms, full disks and missing +// dirs all survive a relaunch, so each class names the thing the user actually has to change. +const GUIDANCE_BY_ERROR_CLASS: Readonly< + Record +> = { + permission_denied: { + key: 'runtimeRpc.startupFailure.guidance.permissionDenied', + fallback: + "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart." + }, + storage_unavailable: { + key: 'runtimeRpc.startupFailure.guidance.storageUnavailable', + fallback: 'Your disk may be full or read-only. Free up space, then restart Orca.' + }, + invalid_path: { + key: 'runtimeRpc.startupFailure.guidance.invalidPath', + fallback: + "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca." + }, + address_in_use: { + key: 'runtimeRpc.startupFailure.guidance.addressInUse', + fallback: 'Another process may be holding the port. Restart Orca to try again.' + }, + unknown: { + key: 'runtimeRpc.startupFailure.guidance.unknown', + fallback: 'Restart Orca to try again.' + } +} + +function createRuntimeRpcStartupFailureDialogOptions(error: unknown): MessageBoxOptions { + const cause = describeRuntimeRpcStartFailure(error) + const { key, fallback } = GUIDANCE_BY_ERROR_CLASS[classifyRuntimeRpcStartFailure(error)] + return { + type: 'error', + buttons: [translateMain('runtimeRpc.startupFailure.continueButton', 'Continue without CLI')], + defaultId: 0, + cancelId: 0, + noLink: true, + title: translateMain('runtimeRpc.startupFailure.title', 'Orca CLI unavailable'), + message: translateMain( + 'runtimeRpc.startupFailure.message', + "Orca couldn't start its local command transport." + ), + detail: translateMain( + 'runtimeRpc.startupFailure.detail', + 'Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}', + { cause, guidance: translateMain(key, fallback) } + ) + } +} + +export function recordRuntimeRpcStartFailure(error: unknown): void { + console.error('[runtime] Failed to start local RPC transport:', error) + try { + track('runtime_rpc_start_failed', { + error_class: classifyRuntimeRpcStartFailure(error) + }) + } catch (telemetryError) { + console.error('[runtime] Failed to record RPC startup failure telemetry:', telemetryError) + } +} + +function waitForWindowToShow(parentWindow: BrowserWindow): Promise { + if (parentWindow.isDestroyed()) { + return Promise.resolve(false) + } + const parentWebContents = parentWindow.webContents + if (parentWebContents.isDestroyed()) { + return Promise.resolve(false) + } + if (parentWindow.isVisible()) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const settle = (visible: boolean): void => { + parentWindow.removeListener('show', onShow) + parentWebContents.removeListener('destroyed', onDestroyed) + resolve(visible) + } + const onShow = (): void => + settle(!parentWindow.isDestroyed() && !parentWebContents.isDestroyed()) + const onDestroyed = (): void => settle(false) + parentWindow.once('show', onShow) + // Why: keep this failure-only waiter off the crowded BrowserWindow `closed` event. + parentWebContents.once('destroyed', onDestroyed) + }) +} + +export async function showRuntimeRpcStartupFailureDialog( + parentWindow: BrowserWindow, + error: unknown +): Promise { + if (!(await waitForWindowToShow(parentWindow))) { + return + } + try { + await dialog.showMessageBox(parentWindow, createRuntimeRpcStartupFailureDialogOptions(error)) + } catch (dialogError) { + console.error('[runtime] Failed to show RPC startup failure dialog:', dialogError) + } +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index bbdd790efb6..6c5564e3e19 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -8,10 +8,20 @@ describe('startup ordering', () => { const attachStart = source.indexOf('attachMainWindowServices(') const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) const attachBlock = source.slice(attachStart, attachEnd) - const desktopStart = source.indexOf('const [win] = await Promise.all([') - const desktopEnd = source.indexOf('// Why: the macOS notification permission dialog') + // Why: anchor on the destructure head only — the settled-result variable's name is not the + // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. + const desktopStart = source.indexOf('const [win') + // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so + // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. + const desktopEnd = source.indexOf("win.once('show'", desktopStart) const desktopStartup = source.slice(desktopStart, desktopEnd) + // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. + expect(attachStart).toBeGreaterThanOrEqual(0) + expect(attachEnd).toBeGreaterThan(attachStart) + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') expect(attachBlock).toContain( 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' @@ -25,6 +35,13 @@ describe('startup ordering', () => { expect(windowIndex).toBeGreaterThanOrEqual(0) expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) + expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') + // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. + expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) + // Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy. + expect(desktopStartup).not.toContain( + "console.error('[runtime] Failed to start local RPC transport:'" + ) }) it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { @@ -45,7 +62,9 @@ describe('startup ordering', () => { expect(reconciliationStart).toBeGreaterThanOrEqual(0) expect(serveStart).toBeGreaterThan(reconciliationStart) expect(serveEnd).toBeGreaterThan(serveStart) - expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call + // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. + expect(desktopWindowStart).toBeGreaterThan(serveEnd) expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( @@ -64,6 +83,11 @@ describe('startup ordering', () => { const readyStart = source.indexOf('await serveReadinessPublisher.publish(') const readyEnd = source.indexOf('pairing: pairing.available', readyStart) const readyPayload = source.slice(readyStart, readyEnd) + + // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive + // somewhere later in the file — not in the serve-ready payload this test is about. + expect(readyStart).toBeGreaterThanOrEqual(0) + expect(readyEnd).toBeGreaterThan(readyStart) expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 63233daf1ab..778e78b8443 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14325,5 +14325,21 @@ "sidebar": { "label": "Agent Dashboard" } + }, + "runtimeRpc": { + "startupFailure": { + "unknownCause": "No additional error details were available.", + "continueButton": "Continue without CLI", + "title": "Orca CLI unavailable", + "message": "Orca couldn't start its local command transport.", + "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "guidance": { + "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", + "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", + "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", + "addressInUse": "Another process may be holding the port. Restart Orca to try again.", + "unknown": "Restart Orca to try again." + } + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 008fb2f67ca..3caa4065ac4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14325,5 +14325,21 @@ "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." } + }, + "runtimeRpc": { + "startupFailure": { + "unknownCause": "No additional error details were available.", + "continueButton": "Continue without CLI", + "title": "Orca CLI unavailable", + "message": "Orca couldn't start its local command transport.", + "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "guidance": { + "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", + "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", + "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", + "addressInUse": "Another process may be holding the port. Restart Orca to try again.", + "unknown": "Restart Orca to try again." + } + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cc33df6d859..6a78bc44545 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14325,5 +14325,21 @@ "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." } + }, + "runtimeRpc": { + "startupFailure": { + "unknownCause": "No additional error details were available.", + "continueButton": "Continue without CLI", + "title": "Orca CLI unavailable", + "message": "Orca couldn't start its local command transport.", + "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "guidance": { + "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", + "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", + "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", + "addressInUse": "Another process may be holding the port. Restart Orca to try again.", + "unknown": "Restart Orca to try again." + } + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 98d27a306b5..cea722e067a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14325,5 +14325,21 @@ "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." } + }, + "runtimeRpc": { + "startupFailure": { + "unknownCause": "No additional error details were available.", + "continueButton": "Continue without CLI", + "title": "Orca CLI unavailable", + "message": "Orca couldn't start its local command transport.", + "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "guidance": { + "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", + "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", + "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", + "addressInUse": "Another process may be holding the port. Restart Orca to try again.", + "unknown": "Restart Orca to try again." + } + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5f38506f064..1b39305661b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14325,5 +14325,21 @@ "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." } + }, + "runtimeRpc": { + "startupFailure": { + "unknownCause": "No additional error details were available.", + "continueButton": "Continue without CLI", + "title": "Orca CLI unavailable", + "message": "Orca couldn't start its local command transport.", + "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "guidance": { + "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", + "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", + "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", + "addressInUse": "Another process may be holding the port. Restart Orca to try again.", + "unknown": "Restart Orca to try again." + } + } } } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index b333b4a30f2..a98ece28df6 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -371,6 +371,20 @@ const agentErrorSchema = z // Why: daemon start-failure signal (fleet-wide outage like v1.4.129-rc.1); enum-only so raw stderr never reaches the wire. const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).strict() +export const runtimeRpcStartErrorClassSchema = z.enum([ + 'permission_denied', + 'address_in_use', + 'storage_unavailable', + 'invalid_path', + 'unknown' +]) +export type RuntimeRpcStartErrorClass = z.infer + +// Why: runtime discovery failures can contain user paths; keep telemetry to closed filesystem/socket categories. +const runtimeRpcStartFailedSchema = z + .object({ error_class: runtimeRpcStartErrorClassSchema }) + .strict() + // Why: daemon replace/retire lifecycle signal — issue #7936 was undiagnosable without asking a user for daemon.log. // Enum-only + bucketed session count so no paths, raw versions, or exact counts reach the wire. // The union keeps each reason pinned to its transition, so a death can't be reported as a replace. @@ -1384,6 +1398,7 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, daemon_lifecycle: daemonLifecycleSchema, + runtime_rpc_start_failed: runtimeRpcStartFailedSchema, codex_trust_grant: codexTrustGrantSchema,