diff --git a/src/main/index.ts b/src/main/index.ts index b2bf24c8a..dc298ddbd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -178,6 +178,7 @@ import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' +import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -1565,6 +1566,7 @@ app.whenReady().then(async () => { store = new Store() logStartupMilestone('store-loaded') + selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') }) applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index d16497be5..f46dbb22e 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -14,6 +14,7 @@ import { removeEnvironment, updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal' import { cleanupEphemeralVmRuntime, resumeEphemeralVmRuntime, @@ -102,6 +103,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (result.ok && runtime.runtimeEnvironmentId) { try { removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) + clearActiveRuntimeEnvironmentFocusIfMatches(store, runtime.runtimeEnvironmentId) } catch { // Cleanup of provider resources matters more than hiding a stale local // environment row; users can still remove that manually. diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 0b3f5d85b..071155271 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -71,9 +71,14 @@ function makeStore(repoPath: string) { badgeColor: '#000', addedAt: 0 } + let activeRuntimeEnvironmentId: string | null = null return { getRepo: vi.fn((repoId: string) => (repoId === 'repo-1' ? repo : null)), - getRepos: vi.fn(() => [repo]) + getRepos: vi.fn(() => [repo]), + getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId })), + updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { + activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId + }) } } @@ -129,7 +134,8 @@ describe('registerEphemeralVmHandlers', () => { ].join('\n') ) - registerEphemeralVmHandlers(makeStore(repoPath) as never) + const store = makeStore(repoPath) + registerEphemeralVmHandlers(store as never) const result = await handlers.get('ephemeralVm:listRecipes')?.(null, { repoId: 'repo-1' } as never) @@ -163,7 +169,8 @@ describe('registerEphemeralVmHandlers', () => { ].join('\n') ) - registerEphemeralVmHandlers(makeStore(repoPath) as never) + const store = makeStore(repoPath) + registerEphemeralVmHandlers(store as never) const result = await handlers.get('ephemeralVm:listRecipeCatalog')?.(null, undefined as never) expect(result).toEqual([ @@ -212,7 +219,8 @@ describe('registerEphemeralVmHandlers', () => { ].join('\n') ) - registerEphemeralVmHandlers(makeStore(repoPath) as never) + const store = makeStore(repoPath) + registerEphemeralVmHandlers(store as never) const result = (await handlers.get('ephemeralVm:provision')?.(null, { repoId: 'repo-1', recipeId: 'cloud-sandbox', @@ -253,6 +261,17 @@ describe('registerEphemeralVmHandlers', () => { workspaceId: 'repo-1::/workspace/repo/worktree' }) ) + + store.updateSettings({ activeRuntimeEnvironmentId: result.environment!.id }) + const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, { + runtimeId: result.runtime?.id + } as never) + expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' })) + expect(listEnvironments(userDataPath)).toEqual([]) + expect(store.updateSettings).toHaveBeenLastCalledWith( + { activeRuntimeEnvironmentId: null }, + { notifyListeners: true } + ) }) it('provisions an ssh recipe without creating a runtime environment', async () => { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f1fea8f77..76e8b1693 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -443,7 +443,7 @@ describe('registerCoreHandlers', () => { expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled() expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) - expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled() + expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store) expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b54a54493..4baae415b 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -166,7 +166,7 @@ export function registerCoreHandlers( } registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index c8cd0ffc7..3dbcac7a7 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -80,9 +80,21 @@ function handler( describe('registerRuntimeEnvironmentHandlers', () => { let userDataPath: string + let activeRuntimeEnvironmentId: string | null + let store: { + getSettings: () => { activeRuntimeEnvironmentId: string | null } + updateSettings: ReturnType + } beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-')) + activeRuntimeEnvironmentId = null + store = { + getSettings: () => ({ activeRuntimeEnvironmentId }), + updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { + activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId + }) + } getPathMock.mockReset() getPathMock.mockReturnValue(userDataPath) handleMock.mockReset() @@ -104,7 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('registers desktop runtime environment management handlers', () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', @@ -123,7 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('clears stale IPC registrations before registering runtime environment handlers', () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', @@ -140,7 +152,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('stores, resolves, lists, and removes environments under Electron userData', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const add = handler< { name: string; pairingCode: string }, @@ -149,6 +161,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) expect(JSON.stringify(added)).not.toContain('device-token') expect(JSON.stringify(added)).not.toContain('publicKeyB64') + activeRuntimeEnvironmentId = added.environment.id const list = handler('runtimeEnvironments:list') expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) @@ -170,13 +183,18 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(removed).toMatchObject({ removed: { id: added.environment.id, name: 'desk' } }) + expect(store.updateSettings).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: null }, + { notifyListeners: true } + ) + expect(activeRuntimeEnvironmentId).toBeNull() expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) it('disconnects a saved runtime without removing it', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const add = handler< { name: string; pairingCode: string }, @@ -200,7 +218,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('marks environments owned by ephemeral VM runtimes in the public list', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) // The ephemeral-VM provision flow persists `source: 'ephemeral-vm'` directly // on the environment record (ephemeral-vm.ts), so the public list reads it @@ -221,7 +239,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('checks a saved remote runtime and records the runtime id on success', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'rpc-1', ok: true, @@ -260,7 +278,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('attaches shared-control diagnostics to saved remote runtime status', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ state: 'reconnecting', pendingRequestCount: 1, @@ -296,7 +314,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('attaches shared-control diagnostics to failed saved remote runtime status', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ state: 'reconnecting', pendingRequestCount: 0, @@ -334,7 +352,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('returns shared-control diagnostics when saved remote runtime status throws', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ state: 'reconnecting', pendingRequestCount: 0, @@ -367,7 +385,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('proxies generic one-shot RPC calls to the saved remote runtime', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'rpc-2', ok: true, @@ -401,7 +419,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => { if (method === 'status.get') { return { @@ -442,7 +460,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('uses the cached request connection for terminal hot path RPCs', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({ id: 'rpc-terminal', ok: true, @@ -482,7 +500,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('keeps terminal hot path RPCs on the cached request connection when shared control is supported', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -545,7 +563,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('routes one-shot RPC calls through shared control when the runtime advertises support', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -599,7 +617,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('rechecks shared-control support when the saved runtime identity changes', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) let statusCalls = 0 sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => { if (method === 'status.get') { @@ -654,7 +672,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('does not fall back after a shared-control request fails on a supported runtime', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -691,7 +709,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', @@ -761,7 +779,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('routes passive subscriptions through shared control when supported', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -814,7 +832,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('keeps shared-control subscriptions retained across transient errors until final close', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() const senderSend = vi.fn() const destroyedListenerRemoved = vi.fn() @@ -893,7 +911,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('falls back to legacy passive subscriptions when shared control is unsupported', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -942,7 +960,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('dedupes concurrent shared-control capability probes per environment', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) let resolveStatus: (value: unknown) => void = () => {} sendRemoteRuntimeRequestMock.mockImplementation((_pairing, method) => { if (method === 'status.get') { @@ -989,7 +1007,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('clears rejected shared-control capability probes so a later call can retry', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock .mockRejectedValueOnce(new Error('probe failed')) .mockResolvedValueOnce({ @@ -1031,7 +1049,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('clears shared-control capability cache when a runtime is disconnected', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -1075,7 +1093,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('clears shared-control capability cache when a runtime is removed and re-added', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ id: 'status', ok: true, @@ -1119,7 +1137,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('limits background one-shot RPCs without blocking foreground runtime calls', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const pendingBackground: ((value: unknown) => void)[] = [] sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => { if (method === 'status.get') { @@ -1210,7 +1228,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('starts and stops streaming subscriptions for a saved remote runtime', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() const sendBinary = vi.fn() const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed') @@ -1307,7 +1325,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('closes streaming subscriptions when their saved runtime is removed', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() const sendBinary = vi.fn() subscribeRemoteRuntimeRequestMock.mockResolvedValue({ @@ -1371,7 +1389,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('rejects cross-window streaming subscription control', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() const sendBinary = vi.fn() subscribeRemoteRuntimeRequestMock.mockResolvedValue({ @@ -1441,7 +1459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('closes a streaming subscription that resolves after the sender is destroyed', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() let resolveSubscribe: (value: { requestId: string @@ -1519,7 +1537,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) it('removes the destroyed listener when streaming subscription setup rejects', async () => { - registerRuntimeEnvironmentHandlers() + registerRuntimeEnvironmentHandlers(store as never) subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed')) const add = handler< diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index ec9eac844..0c1aca30c 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -13,6 +13,8 @@ import { import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import type { Store } from '../persistence' +import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' import { callRuntimeEnvironment, @@ -63,7 +65,7 @@ function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] { return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) } -export function registerRuntimeEnvironmentHandlers(): void { +export function registerRuntimeEnvironmentHandlers(store: Store): void { // Why: keep direct re-registration safe even though register-core-handlers // normally guards this path; otherwise the binary send listener can stack. resetSharedControlSupport() @@ -99,6 +101,7 @@ export function registerRuntimeEnvironmentHandlers(): void { closeRemoteRuntimeRequestConnection(args.selector) clearSharedControlSupport(args.selector) } + clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id) closeSubscriptionsForEnvironment(removed.id) return { removed: redactRuntimeEnvironment(removed) } } diff --git a/src/main/runtime-environment-focus-self-heal.test.ts b/src/main/runtime-environment-focus-self-heal.test.ts new file mode 100644 index 000000000..edce1d690 --- /dev/null +++ b/src/main/runtime-environment-focus-self-heal.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../shared/types' +import type { KnownRuntimeEnvironment } from '../shared/runtime-environments' +import { + clearActiveRuntimeEnvironmentFocusIfMatches, + selfHealRuntimeEnvironmentFocus +} from './runtime-environment-focus-self-heal' + +function environment( + id: string, + source?: KnownRuntimeEnvironment['source'] +): KnownRuntimeEnvironment { + return { + id, + name: id, + createdAt: 0, + updatedAt: 0, + lastUsedAt: null, + runtimeId: null, + ...(source ? { source } : {}), + endpoints: [ + { + id: `ws-${id}`, + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'key' + } + ], + preferredEndpointId: `ws-${id}` + } +} + +function makeStore(activeRuntimeEnvironmentId: string | null | undefined) { + const settings: Pick = {} + if (activeRuntimeEnvironmentId !== undefined) { + settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId + } + const updateSettings = vi.fn((updates: Pick) => { + settings.activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId + return settings + }) + return { + store: { + getSettings: () => settings, + updateSettings + }, + updateSettings + } +} + +describe('runtime environment focus self-heal', () => { + it('keeps a focus id that resolves to a user-managed environment', () => { + const { store, updateSettings } = makeStore('env-1') + + selfHealRuntimeEnvironmentFocus({ + store, + userDataPath: '/user-data', + listKnownEnvironments: () => [environment('env-1')] + }) + + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('clears a dangling focus id and logs one diagnostic line', () => { + const { store, updateSettings } = makeStore('missing-env') + const log = vi.fn() + + selfHealRuntimeEnvironmentFocus({ + store, + userDataPath: '/user-data', + listKnownEnvironments: () => [environment('env-1')], + log + }) + + expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) + expect(log).toHaveBeenCalledTimes(1) + expect(log.mock.calls[0][0]).toContain('missing-env') + }) + + it('clears an ephemeral-VM focus id after restart', () => { + const { store, updateSettings } = makeStore('vm-env') + const log = vi.fn() + + selfHealRuntimeEnvironmentFocus({ + store, + userDataPath: '/user-data', + listKnownEnvironments: () => [environment('vm-env', 'ephemeral-vm')], + log + }) + + expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) + expect(log).toHaveBeenCalledTimes(1) + }) + + it('leaves null and absent focus settings untouched', () => { + const nullCase = makeStore(null) + const absentCase = makeStore(undefined) + const listKnownEnvironments = vi.fn(() => [environment('env-1')]) + + selfHealRuntimeEnvironmentFocus({ + store: nullCase.store, + userDataPath: '/user-data', + listKnownEnvironments + }) + selfHealRuntimeEnvironmentFocus({ + store: absentCase.store, + userDataPath: '/user-data', + listKnownEnvironments + }) + + expect(nullCase.updateSettings).not.toHaveBeenCalled() + expect(absentCase.updateSettings).not.toHaveBeenCalled() + expect(listKnownEnvironments).not.toHaveBeenCalled() + }) + + it('normalizes an empty persisted id to null without reading the registry', () => { + const { store, updateSettings } = makeStore('') + const listKnownEnvironments = vi.fn(() => [environment('env-1')]) + + selfHealRuntimeEnvironmentFocus({ + store, + userDataPath: '/user-data', + listKnownEnvironments + }) + + expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null }) + expect(listKnownEnvironments).not.toHaveBeenCalled() + }) + + it('fails soft when the registry cannot be read', () => { + const { store, updateSettings } = makeStore('env-1') + + selfHealRuntimeEnvironmentFocus({ + store, + userDataPath: '/user-data', + listKnownEnvironments: () => { + throw new Error('invalid registry') + } + }) + + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('clears the active focus on matching in-process removal with listener notification', () => { + const { store, updateSettings } = makeStore('env-1') + + clearActiveRuntimeEnvironmentFocusIfMatches(store, 'env-1') + + expect(updateSettings).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: null }, + { notifyListeners: true } + ) + }) +}) diff --git a/src/main/runtime-environment-focus-self-heal.ts b/src/main/runtime-environment-focus-self-heal.ts new file mode 100644 index 000000000..a748f4794 --- /dev/null +++ b/src/main/runtime-environment-focus-self-heal.ts @@ -0,0 +1,71 @@ +import type { GlobalSettings } from '../shared/types' +import { listEnvironments } from '../shared/runtime-environment-store' +import { + isUserManagedRuntimeEnvironment, + type KnownRuntimeEnvironment +} from '../shared/runtime-environments' + +type RuntimeEnvironmentFocusStore = { + getSettings: () => Pick + updateSettings: ( + updates: Pick, + options?: { notifyListeners?: boolean } + ) => unknown +} + +type SelfHealRuntimeEnvironmentFocusArgs = { + store: RuntimeEnvironmentFocusStore + userDataPath: string + listKnownEnvironments?: (userDataPath: string) => KnownRuntimeEnvironment[] + log?: (message: string) => void +} + +function logClearedFocus(log: ((message: string) => void) | undefined, reason: string): void { + const writeLog = log ?? console.info + writeLog(`[runtime-environment-focus] cleared active runtime environment: ${reason}`) +} + +export function clearActiveRuntimeEnvironmentFocusIfMatches( + store: RuntimeEnvironmentFocusStore, + environmentId: string +): void { + if (store.getSettings().activeRuntimeEnvironmentId !== environmentId) { + return + } + store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true }) +} + +export function selfHealRuntimeEnvironmentFocus({ + store, + userDataPath, + listKnownEnvironments = listEnvironments, + log +}: SelfHealRuntimeEnvironmentFocusArgs): void { + const activeRuntimeEnvironmentId = store.getSettings().activeRuntimeEnvironmentId + if (activeRuntimeEnvironmentId === undefined || activeRuntimeEnvironmentId === null) { + return + } + + if (activeRuntimeEnvironmentId.trim() === '') { + store.updateSettings({ activeRuntimeEnvironmentId: null }) + logClearedFocus(log, 'empty persisted id') + return + } + + let environments: KnownRuntimeEnvironment[] + try { + environments = listKnownEnvironments(userDataPath) + } catch { + // Why: an unreadable registry must not clear a possibly-valid focus; keep + // it and let a later launch heal once the registry reads again. + return + } + + const focusedEnvironment = environments.find((entry) => entry.id === activeRuntimeEnvironmentId) + if (focusedEnvironment && isUserManagedRuntimeEnvironment(focusedEnvironment)) { + return + } + + store.updateSettings({ activeRuntimeEnvironmentId: null }) + logClearedFocus(log, `dangling id ${activeRuntimeEnvironmentId}`) +} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx new file mode 100644 index 000000000..5701928ad --- /dev/null +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' + +vi.mock('@/store', () => { + const storeState = {} + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState } + ) + return { useAppStore } +}) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [token, value]) => text.replace(`{{${token}}}`, value), + fallback + ) + : fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() } +})) + +import { WorktreeRow } from './ResourceUsageStatusSegment' + +function makeSession(overrides: Partial): UnifiedSessionRow { + return { + sessionId: 'sess-1', + paneKey: null, + pid: 100, + label: 'zsh', + bound: true, + tabId: 'tab-1', + cpu: 1, + memory: 100, + hasLocalSamples: true, + ...overrides + } +} + +function makeWorktree(overrides: Partial): UnifiedWorktreeRow { + return { + worktreeId: 'wt-1', + worktreeName: 'feature-branch', + repoId: 'repo-1', + repoName: 'repo', + cpu: 1, + memory: 100, + history: [], + hasLocalSamples: true, + isRemote: false, + sessions: [], + ...overrides + } +} + +describe('resource manager row presentation', () => { + let container: HTMLDivElement + let root: Root + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + function renderWorktreeRow(worktree: UnifiedWorktreeRow): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render( + {}} + onNavigate={() => {}} + onDelete={() => {}} + onKillSession={() => {}} + navigateToTab={() => {}} + /> + ) + }) + } + + it('keeps the remote chip and kill affordances on SSH-backed rows', () => { + renderWorktreeRow( + makeWorktree({ + isRemote: true, + cpu: null, + memory: null, + sessions: [ + makeSession({ sessionId: 'ssh-a', bound: true }), + makeSession({ sessionId: 'ssh-b', bound: false, tabId: null, cpu: null, memory: null }) + ] + }) + ) + + expect(container.textContent).toContain('· remote') + const killButtons = container.querySelectorAll('button[aria-label^="Kill session"]') + expect(killButtons).toHaveLength(2) + expect( + container.querySelector('button[aria-label="Kill session ssh-a"]') + ).not.toBeNull() + expect( + container.querySelector('button[aria-label="Kill session ssh-b"]') + ).not.toBeNull() + }) + + it('keeps kill affordances on the orphan bucket rows', () => { + renderWorktreeRow( + makeWorktree({ + worktreeId: ORPHAN_WORKTREE_ID, + worktreeName: 'Orphaned terminals', + sessions: [makeSession({ sessionId: 'orphan-a', bound: false, tabId: null })] + }) + ) + + expect( + container.querySelector('button[aria-label="Kill session orphan-a"]') + ).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts index c30366e8d..e29871f0a 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts @@ -12,7 +12,7 @@ describe('ResourceUsageStatusSegment session polling', () => { expect(source).not.toContain('SESSIONS_POLL_MS') expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1) - const openEffectIndex = source.indexOf('if (!open || runtimeEnvironmentActive)') + const openEffectIndex = source.indexOf('if (!open)') const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex) // Why: pty.listSessions() is a global daemon inventory and can pause input diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 86aa18ef2..fffd83215 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -37,6 +37,7 @@ import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { isFolderRepo } from '../../../../shared/repo-kind' import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' @@ -352,7 +353,9 @@ function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): Uni // ─── Session row ──────────────────────────────────────────────────── -function SessionRow({ +// Exported (with WorktreeRow) for row-level regression tests pinning the kill +// affordance and remote-chip presentation for SSH/orphan rows. +export function SessionRow({ session, worktreeId, onNavigate, @@ -433,7 +436,7 @@ function SessionRow({ // ─── Worktree row ─────────────────────────────────────────────────── -function WorktreeRow({ +export function WorktreeRow({ worktree, storeRecord, activeWorktreeId, @@ -745,10 +748,6 @@ export function ResourceUsageStatusSegment({ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null) const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) - const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) const [open, setOpen] = useState(false) const [sortOption, setSortOption] = useState('memory') @@ -770,19 +769,12 @@ export function ResourceUsageStatusSegment({ // merged tree needs them only while open, so closed status-bar badges should // not subscribe to those high-churn maps. const runtimePaneTitlesByTabId = useAppStore((s) => - getResourceUsageRuntimePaneTitlesByTabId(s, open, runtimeEnvironmentActive) + getResourceUsageRuntimePaneTitlesByTabId(s, open) ) - const repos = useAppStore((s) => getResourceUsageRepos(s, open, runtimeEnvironmentActive)) - const allWorktrees = useAppStore((s) => - getResourceUsageAllWorktrees(s, open, runtimeEnvironmentActive) - ) - const tabsByWorktree = useAppStore((s) => - getResourceUsageTabsByWorktree(s, open, runtimeEnvironmentActive) - ) - // Why: this segment only understands the local Electron PTY/resource daemon. - // While a runtime server is active, hiding local samples avoids showing or - // killing sessions from the wrong machine. - const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + const repos = useAppStore((s) => getResourceUsageRepos(s, open)) + const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + const resourceSnapshot = snapshot // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource // Manager also reads restored wake hints, but only for classification. const resourceSessionBindings = useMemo( @@ -822,13 +814,6 @@ export function ResourceUsageStatusSegment({ ) const refreshSessions = useCallback(async () => { - if (runtimeEnvironmentActive) { - if (mountedRef.current) { - setSessions([]) - setSessionsError(false) - } - return - } try { const result = await window.api.pty.listSessions() if (!mountedRef.current) { @@ -841,7 +826,7 @@ export function ResourceUsageStatusSegment({ setSessionsError(true) } } - }, [mountedRef, runtimeEnvironmentActive]) + }, [mountedRef]) const daemonActions = useDaemonActions({ onRestartSettled: () => { @@ -858,7 +843,6 @@ export function ResourceUsageStatusSegment({ // closes this popover; the status-bar trigger becomes the handoff point. const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ snapshot: spaceScanSnapshot, - runtimeEnvironmentActive, open, activeView, scannedAt: workspaceSpaceScannedAt, @@ -880,7 +864,7 @@ export function ResourceUsageStatusSegment({ // daemon PTYs because large preserved-session sets make that visible while // typing. useEffect(() => { - if (!open || runtimeEnvironmentActive) { + if (!open) { return } void fetchSnapshot() @@ -894,14 +878,7 @@ export function ResourceUsageStatusSegment({ return () => { window.clearInterval(memTimer) } - }, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (runtimeEnvironmentActive) { - setSessions([]) - setSessionsError(false) - } - }, [runtimeEnvironmentActive]) + }, [open, fetchSnapshot, refreshSessions]) const repoDisplayNameById = useMemo(() => { const map = new Map() @@ -927,6 +904,17 @@ export function ResourceUsageStatusSegment({ return map }, [repos]) + // Why: runtime-hosted repos never have local daemon samples or killable + // local sessions; this map drives their per-row exclusion in the merge. + const repoRuntimeScopedById = useMemo(() => { + const map = new Map() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + map.set(repo.id, parsed?.kind === 'runtime') + } + return map + }, [repos]) + const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const oldWorkspaceCount = useMemo(() => { @@ -951,7 +939,7 @@ export function ResourceUsageStatusSegment({ // feel laggy because the segment is always mounted in the status bar. const unifiedRepos = useMemo( () => - open && !runtimeEnvironmentActive + open ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, @@ -959,12 +947,12 @@ export function ResourceUsageStatusSegment({ runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, - repoConnectionIdById + repoConnectionIdById, + repoRuntimeScopedById }) : [], [ open, - runtimeEnvironmentActive, resourceSnapshot, sessions, tabsByWorktree, @@ -973,30 +961,27 @@ export function ResourceUsageStatusSegment({ runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, - repoConnectionIdById + repoConnectionIdById, + repoRuntimeScopedById ] ) // Why: orphan detection needs daemon inventory. Keep it open-only so the // closed badge never reintroduces a background global session scan. const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady || runtimeEnvironmentActive) { + if (!open || !workspaceSessionReady) { return 0 } return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) + }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) const closedSessionCount = useMemo(() => { - if (!workspaceSessionReady || runtimeEnvironmentActive) { + if (!workspaceSessionReady) { return 0 } return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size - }, [resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) - const triggerSessionCount = runtimeEnvironmentActive - ? 0 - : open - ? sessions.length - : closedSessionCount + }, [resourceSessionBindings, workspaceSessionReady]) + const triggerSessionCount = open ? sessions.length : closedSessionCount const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1013,26 +998,20 @@ export function ResourceUsageStatusSegment({ // Why: memorySnapshotError is null both for "last fetch succeeded" and // "never fetched". If session refresh fails before a memory snapshot exists, // treat that as daemon-unreachable too. - const daemonUnreachable = - !runtimeEnvironmentActive && - sessionsError && - (memorySnapshotError !== null || snapshot === null) + const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) // Why: a partial failure where the sessions IPC fails but the snapshot // IPC still works was silently invisible after the merge — the old // SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show // a slim inline notice so the user understands why the session list is // empty/stale even though the resource numbers look fine. - const sessionsOnlyError = - !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null + const sessionsOnlyError = sessionsError && memorySnapshotError === null const resourceManagerTooltipLines = getResourceManagerTooltipLines({ memoryLabel: memBadgeLabel, sessionCount: triggerSessionCount, - runtimeEnvironmentActive, spaceScanReady }) const resourceManagerAriaLabel = getResourceManagerAriaLabel({ sessionCount: triggerSessionCount, - runtimeEnvironmentActive, spaceScanReady }) @@ -1089,12 +1068,9 @@ export function ResourceUsageStatusSegment({ }, []) const handleOpenWorkspaceCleanup = useCallback((): void => { - if (runtimeEnvironmentActive) { - return - } setOpen(false) queueMicrotask(() => openModal('workspace-cleanup')) - }, [openModal, runtimeEnvironmentActive]) + }, [openModal]) const handleKillSession = useCallback( (session: UnifiedSessionRow): void => { @@ -1205,7 +1181,7 @@ export function ResourceUsageStatusSegment({ : resourceManagerAriaLabel } > - {spaceScanReady && !runtimeEnvironmentActive ? ( + {spaceScanReady ? (