From 53513dd7f84f8c32fdfa31d48c1c14dda1c9720d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 15:35:47 -0700 Subject: [PATCH 1/9] fix: archive and fence retired runtime host sessions Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: KimYoungHwan8750 <142006989+KimYoungHwan8750@users.noreply.github.com> --- .../runtime-host-session-retirement.md | 28 + src/main/ipc/ephemeral-vm-runtime-handlers.ts | 8 +- src/main/ipc/ephemeral-vm.test.ts | 8 + .../ipc/renderer-shutdown-checkpoint.test.ts | 4 + src/main/ipc/renderer-shutdown-checkpoint.ts | 17 +- .../renderer-workspace-session-admission.ts | 22 + ...ntime-environment-connectivity-handlers.ts | 19 +- ...untime-environment-removal-storage.test.ts | 22 +- ...ime-environment-session-retirement.test.ts | 509 ++++++++++++++++++ ...time-environments-capability-cache.test.ts | 6 + .../ipc/runtime-environments-pairing.test.ts | 8 +- ...environments-subscription-teardown.test.ts | 8 +- src/main/ipc/runtime-environments.ts | 6 +- src/main/ipc/session.ts | 18 +- .../loading-store/pty-binding-persistence.ts | 12 + .../removed-host-scrollback-snapshots.test.ts | 154 ++++++ .../runtime-session-recovery-archive.ts | 109 ++++ .../runtime-session-retirement.test.ts | 284 ++++++++++ .../runtime-session-retirement.ts | 154 ++++++ .../loading-store/session-host-partitions.ts | 37 +- .../session-snapshot-operations.ts | 36 ++ .../state-serialization-secret-handling.ts | 5 + ...environment-host-session-self-heal.test.ts | 108 ++++ ...time-environment-host-session-self-heal.ts | 44 ++ ...rca-runtime-terminal-surface-close.test.ts | 17 + .../runtime/retire-unowned-runtime-session.ts | 21 + ...cy-worker-terminal-recovery-persistence.ts | 3 + ...orkspace-session-namespace-custody.test.ts | 270 ++++++++++ ...ime-workspace-session-namespace-custody.ts | 58 ++ .../startup/main-process-ready-foundation.ts | 2 + src/shared/persisted-state-types.ts | 12 + .../removed-runtime-session-archive.spec.ts | 163 ++++++ 32 files changed, 2156 insertions(+), 16 deletions(-) create mode 100644 docs/reference/runtime-host-session-retirement.md create mode 100644 src/main/ipc/renderer-workspace-session-admission.ts create mode 100644 src/main/ipc/runtime-environment-session-retirement.test.ts create mode 100644 src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts create mode 100644 src/main/persistence/loading-store/runtime-session-recovery-archive.ts create mode 100644 src/main/persistence/loading-store/runtime-session-retirement.test.ts create mode 100644 src/main/persistence/loading-store/runtime-session-retirement.ts create mode 100644 src/main/runtime-environment-host-session-self-heal.test.ts create mode 100644 src/main/runtime-environment-host-session-self-heal.ts create mode 100644 src/main/runtime/retire-unowned-runtime-session.ts create mode 100644 src/main/runtime/runtime-workspace-session-namespace-custody.test.ts create mode 100644 src/main/runtime/runtime-workspace-session-namespace-custody.ts create mode 100644 tests/e2e/removed-runtime-session-archive.spec.ts diff --git a/docs/reference/runtime-host-session-retirement.md b/docs/reference/runtime-host-session-retirement.md new file mode 100644 index 000000000000..20e7f6f56cac --- /dev/null +++ b/docs/reference/runtime-host-session-retirement.md @@ -0,0 +1,28 @@ +# Removed paired-host sessions + +Removing a pairing retires its client-side session partition only when the current main catalog +does not own that namespace. It does not stop processes on the remote host or declare them exited. +Local, direct SSH, and historical runtime namespace aliases remain separate authorities. + +Before removing active state, Orca durably writes a private recovery file under the active profile's +`retired-runtime-sessions/` directory, beside `profile-state.db`. Each version-1 JSON file records +the execution host, the exact session (including unsaved editor text and disk signatures), and +available referenced terminal scrollback. Missing pre-existing snapshots are listed explicitly. +Archives use the profile's secure file writer and are limited to 64 MiB. An archive failure keeps +the active session and permits ordinary saves; a failed database commit restores the partition. + +The database retains the archive filename and a fence against late session or PTY writes. This +small record survives restart; the old buffers no longer participate in routine session writes. +A delayed save with newer dirty editor text produces a content-addressed `late-editor-draft` +archive. Identical buffers share one file; they never recreate an active session partition. +Keep the archive directory when copying a profile. Database-only backups and exports do not +include these separate files. Archives are retained until explicitly recovered or removed. + +To recover text from a removed host, preserve the archive, identify the recorded workspace and +path in `session.openFilesByWorktree`, and recover its `dirtyDraftContent` on the intended owner. +Do not restore PTY bindings or silently save the text through the currently focused host. Re-pairing +creates a new host ID; the archive preserves the previous identity rather than guessing a new one. + +Startup cleanup handles absent paired-host UUIDs only with a readable, nonempty registry and no +current namespace custody. Missing, corrupt, unsupported, or empty registries preserve historical +state. An explicitly observed removal can retire the last host through the GUI or transport watch. diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 261c8050df87..011cccd3cecd 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -1,3 +1,4 @@ +import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session' import { app, ipcMain } from 'electron' import type { Store } from '../persistence' import { @@ -107,8 +108,11 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { } if (result.ok && runtime.runtimeEnvironmentId) { try { - removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) - } catch { + const removed = removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) + await invalidateRuntimeEnvironmentTransport(removed.id) + await retireUnownedRuntimeSession(store, removed.id) + } catch (error) { + console.warn('[ephemeral-vm] Preserving session after local retirement failure:', error) // 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 4f71dde0f9f2..d30c6f46ff8d 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -82,6 +83,9 @@ function makeStore(repoPath: string) { return { getRepo: vi.fn((repoId: string) => (repoId === 'repo-1' ? repo : null)), getRepos: vi.fn(() => [repo]), + getFolderWorkspaces: vi.fn(() => []), + getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), + removeRuntimeWorkspaceSessionPartition: vi.fn(async () => true), getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId })), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId @@ -411,6 +415,10 @@ describe('registerEphemeralVmHandlers', () => { } as never) expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' })) expect(listEnvironments(userDataPath)).toEqual([]) + expect(store.removeRuntimeWorkspaceSessionPartition).toHaveBeenCalledExactlyOnceWith( + `runtime:${result.environment!.id}`, + expect.any(Function) + ) expect(store.getSettings().activeRuntimeEnvironmentId).toBe(result.environment!.id) expect(store.updateSettings).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 43491ba31af4..4e95d8c2190b 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -1,3 +1,7 @@ +vi.mock('./renderer-workspace-session-admission', () => ({ + canCreateRendererSessionPartition: () => true +})) + import { beforeEach, describe, expect, it, vi } from 'vitest' const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 82fcbee0d4e2..6549b9552c9c 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -1,3 +1,4 @@ +import { canAdmitRendererSessionWrite } from './renderer-workspace-session-admission' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../shared/execution-host' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' @@ -50,7 +51,10 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { let ok = true try { for (const { state, hostId } of args.sessions) { - if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { + if ( + !isFrozenOrcadSourceSessionPartition(store, hostId) && + isShutdownSessionAdmitted(store, hostId) + ) { store.stageWorkspaceSessionBeforeUnload(state, hostId) } } @@ -68,3 +72,14 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { (): Promise => pendingCheckpoint ) } + +// Why fail open: this is the last save before the window closes, so an unrelated host's ambiguous +// custody verdict must not be allowed to discard the staged state. +function isShutdownSessionAdmitted(store: Store, hostId?: string | null): boolean { + try { + return canAdmitRendererSessionWrite(store, hostId) + } catch (error) { + console.error('[app] Staging session state after partition authority failure:', error) + return true + } +} diff --git a/src/main/ipc/renderer-workspace-session-admission.ts b/src/main/ipc/renderer-workspace-session-admission.ts new file mode 100644 index 000000000000..f0b7735d6d23 --- /dev/null +++ b/src/main/ipc/renderer-workspace-session-admission.ts @@ -0,0 +1,22 @@ +import { app } from 'electron' +import { parseExecutionHostId } from '../../shared/execution-host' +import { listEnvironments } from '../../shared/runtime-environment-store' +import type { Store } from '../persistence' +import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody' + +export function canAdmitRendererSessionWrite(store: Store, hostId?: string | null): boolean { + const parsed = parseExecutionHostId(hostId) + // Retired snapshots reach the Store's archive-only writer. + if (parsed && store.isRuntimeWorkspaceSessionRetired(parsed.id)) { + return true + } + if (parsed?.kind !== 'runtime' || store.getWorkspaceSessionHostIds().includes(parsed.id)) { + return true + } + if (hasMainOwnedRuntimeSessionNamespace(store, parsed.id)) { + return true + } + return listEnvironments(app.getPath('userData'), { requireStoreFile: true }).some( + (entry) => entry.id === parsed.environmentId + ) +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 013cdef9ea3a..e0a0cc69abef 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -1,3 +1,5 @@ +import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { ipcMain } from 'electron' import { addEnvironmentFromPairingCode, @@ -102,11 +104,26 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ) ipcMain.handle( 'runtimeEnvironments:remove', - (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { + async ( + _event, + args: { selector: string } + ): Promise<{ removed: PublicKnownRuntimeEnvironment }> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { throw new Error('Choose another Active Server in Advanced before removing this server.') } + const hostId = toRuntimeExecutionHostId(environment.id) + // Why default to preserving: an unreadable custody verdict is not evidence that nothing owns + // this namespace, and unpair must still succeed rather than fail with an opaque error. + let preserveMainNamespace = true + try { + preserveMainNamespace = hasMainOwnedRuntimeSessionNamespace(store, hostId) + } catch (error) { + console.warn( + '[runtime-environments] Preserving session partition after custody lookup failure:', + error + ) + } const removed = removeEnvironment(getUserDataPath(), args.selector) void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) => store.removeWorkspaceSessionHost(hostId) diff --git a/src/main/ipc/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environment-removal-storage.test.ts index 012df15f47a2..d4a23dd6d61a 100644 --- a/src/main/ipc/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environment-removal-storage.test.ts @@ -1,3 +1,7 @@ +vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({ + hasMainOwnedRuntimeSessionNamespace: () => false +})) + import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, clearStorageMock, removeEnvironmentMock, resolveEnvironmentMock } = vi.hoisted( @@ -47,13 +51,17 @@ describe('runtime environment removal storage clearing', () => { }) clearStorageMock.mockResolvedValue({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods. + store: { + getSettings: () => ({}), + removeRuntimeWorkspaceSessionPartition: vi.fn(), + removeWorkspaceSessionHost: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown }) - expect(removeHandler()(null, { selector: 'environment-a' })).toMatchObject({ + expect(await removeHandler()(null, { selector: 'environment-a' })).toMatchObject({ removed: { id: 'environment-a' } }) await Promise.resolve() @@ -68,8 +76,12 @@ describe('runtime environment removal storage clearing', () => { .mockResolvedValueOnce({ clearedPartitions: [], livePartitions: ['persist:one'] }) .mockResolvedValueOnce({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods. + store: { + getSettings: () => ({}), + removeRuntimeWorkspaceSessionPartition: vi.fn(), + removeWorkspaceSessionHost: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() }) diff --git a/src/main/ipc/runtime-environment-session-retirement.test.ts b/src/main/ipc/runtime-environment-session-retirement.test.ts new file mode 100644 index 000000000000..47ea1a369374 --- /dev/null +++ b/src/main/ipc/runtime-environment-session-retirement.test.ts @@ -0,0 +1,509 @@ +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { createSqliteTestStore, readPersistedStateJson } from '../persistence-test-harness' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceSessionState, + WorkspaceSessionPatch +} from '../../shared/workspace-session-state-types' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type * as RuntimeEnvironmentStore from '../../shared/runtime-environment-store' + +const { handlers, clearStorage, authority } = vi.hoisted(() => ({ + authority: { userDataPath: '', reads: 0 }, + handlers: new Map unknown>(), + clearStorage: vi.fn(async () => ({ clearedPartitions: [], livePartitions: [] })) +})) +vi.mock('electron', () => ({ + app: { + getPath: () => authority.userDataPath || tmpdir(), + getName: () => 'orca-audit', + getVersion: () => '0.0.0', + isPackaged: false, + on() {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { + on: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), + handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) + }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../browser/browser-route-partition-storage-runtime', () => ({ + clearBrowserRoutePartitionStorageForEnvironment: clearStorage +})) + +vi.mock('../../shared/runtime-environment-store', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + listEnvironments: (...args: Parameters) => { + authority.reads++ + return original.listEnvironments(...args) + } + } +}) + +const { Store } = await import('../persistence/loading-store/store') +const { registerRuntimeEnvironmentConnectivityHandlers } = + await import('./runtime-environment-connectivity-handlers') +const { registerSessionHandlers } = await import('./session') +const { registerRendererShutdownCheckpointHandler } = await import('./renderer-shutdown-checkpoint') +const { + addEnvironmentFromPairingCode, + listEnvironments, + updateEnvironmentFromPairingCode, + getEnvironmentStorePath +} = await import('../../shared/runtime-environment-store') +const { encodePairingOffer } = await import('../../shared/pairing') +const { getDefaultWorkspaceSession } = await import('../../shared/constants') +const { toRuntimeExecutionHostId } = await import('../../shared/execution-host') +const cleanups: (() => void)[] = [] +beforeEach(() => { + authority.reads = 0 + vi.restoreAllMocks() +}) +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } +}) + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-partition-audit-')) + authority.userDataPath = dir + const dataFile = join(dir, 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + const invalidateTransport = vi.fn(async () => {}) + registerRuntimeEnvironmentConnectivityHandlers({ + store, + getUserDataPath: () => dir, + invalidateTransport + }) + registerSessionHandlers(store, new OrcaRuntimeService(store)) + registerRendererShutdownCheckpointHandler(store) + cleanups.push(() => { + store.flush() + rmSync(dir, { recursive: true, force: true }) + }) + return { dir, dataFile, store, invalidateTransport } +} + +function pair(dir: string, name: string) { + return addEnvironmentFromPairingCode(dir, { + name, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://192.0.2.10:6768', + deviceToken: 'audit-inert', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + }) +} + +function session(id: string): WorkspaceSessionState { + const worktreeId = `repo-a::/audit/${id}` + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id: `tab-${id}`, + worktreeId, + ptyId: null, + title: 'Audit', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } +} + +async function unpair( + dir: string, + store: InstanceType, + environment: KnownRuntimeEnvironment +) { + await handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id }) + expect(listEnvironments(dir).some((entry) => entry.id === environment.id)).toBe(false) + expect(store.getWorkspaceSessionHostIds()).not.toContain(toRuntimeExecutionHostId(environment.id)) + authority.reads = 0 +} + +async function invokeWrite( + channel: string, + state: WorkspaceSessionState | WorkspaceSessionPatch, + hostId?: string +) { + const event: { returnValue?: unknown } = {} + if (channel === 'app:stage-before-unload-sync') { + handlers.get(channel)!(event, { sessions: [{ state, hostId }], ui: {} }) + return handlers.get('app:await-before-unload-checkpoint')!() + } + await handlers.get(channel)!(event, state, hostId) + if (channel === 'session:set-sync') { + await vi.waitFor(() => expect(event.returnValue).toBe(true)) + } + return event.returnValue +} + +describe('renderer session partition admission', () => { + it.each(['session:set', 'session:patch', 'session:set-sync', 'app:stage-before-unload-sync'])( + 'rejects late %s after actual GUI unpair', + async (channel) => { + const { dir, store } = fixture() + const environment = pair(dir, 'retired') + const hostId = toRuntimeExecutionHostId(environment.id) + store.setWorkspaceSession(session('before'), hostId) + await unpair(dir, store, environment) + await invokeWrite( + channel, + channel === 'session:patch' ? { activeTabId: 'late' } : session('late'), + hostId + ) + expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId) + expect(authority.reads).toBe(0) + } + ) + + it('consults disk for the first valid runtime partition and not its next 64 scalar patches', async () => { + const { dir, store } = fixture() + const environment = pair(dir, 'live') + const hostId = toRuntimeExecutionHostId(environment.id) + await invokeWrite('session:set', session('created'), hostId) + expect(authority.reads).toBe(1) + for (let index = 0; index < 64; index++) { + await invokeWrite('session:patch', { activeTabId: `tab-${index}` }, hostId) + } + expect(authority.reads).toBe(1) + expect(store.getWorkspaceSession(hostId).activeTabId).toBe('tab-63') + }) + + it('preserves local and direct SSH writes without consulting pairing authority', async () => { + const { store } = fixture() + for (const hostId of [undefined, 'local', 'ssh:unpaired-ssh']) { + await invokeWrite('session:set', session(hostId ?? 'omitted'), hostId) + expect(Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)).toHaveLength(1) + } + expect(authority.reads).toBe(0) + }) + + it('retains disconnected and repaired same-ID partitions', async () => { + const { dir, store } = fixture() + const environment = pair(dir, 'repair') + const hostId = toRuntimeExecutionHostId(environment.id) + await invokeWrite('session:set', session('before'), hostId) + handlers.get('runtimeEnvironments:disconnect')!(null, { selector: environment.id }) + const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://192.0.2.11:6768', + deviceToken: 'audit-repair', + publicKeyB64: Buffer.alloc(32, 2).toString('base64') + }) + const repaired = updateEnvironmentFromPairingCode(dir, environment.id, { pairingCode }) + expect(repaired.id).toBe(environment.id) + authority.reads = 0 + await invokeWrite('session:patch', { activeTabId: 'after-repair' }, hostId) + expect(authority.reads).toBe(0) + expect(store.getWorkspaceSession(hostId).activeTabId).toBe('after-repair') + }) + + it('keeps a newly paired same-name ID and rejects the old ID even when used as another pair name', async () => { + const { dir, store } = fixture() + const prior = pair(dir, 'same-name') + const oldHost = toRuntimeExecutionHostId(prior.id) + store.setWorkspaceSession(session('old'), oldHost) + await unpair(dir, store, prior) + const next = pair(dir, 'same-name') + pair(dir, prior.id) + expect(next.id).not.toBe(prior.id) + await invokeWrite('session:set', session('old-late'), oldHost) + await invokeWrite('session:set', session('new'), toRuntimeExecutionHostId(next.id)) + expect(store.getWorkspaceSessionHostIds()).not.toContain(oldHost) + expect(store.getWorkspaceSessionHostIds()).toContain(toRuntimeExecutionHostId(next.id)) + }) + + it('keeps existing historical runtime partitions writable without assuming their suffix is a paired ID', async () => { + const { store } = fixture() + store.setWorkspaceSession(session('legacy'), 'runtime:reported-runtime-id') + await invokeWrite( + 'session:patch', + { activeTabId: 'still-owned' }, + 'runtime:reported-runtime-id' + ) + expect(authority.reads).toBe(0) + expect(store.getWorkspaceSession('runtime:reported-runtime-id').activeTabId).toBe('still-owned') + }) + + it('stages every shutdown session, including a host whose authority is unreadable', async () => { + const { dir, store, dataFile } = fixture() + const environment = pair(dir, 'new') + const hostId = toRuntimeExecutionHostId(environment.id) + const knownHost = 'runtime:known-existing' + store.setWorkspaceSession(session('known-before'), knownHost) + writeFileSync(getEnvironmentStorePath(dir), '{broken') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const event: { returnValue?: unknown } = {} + handlers.get('app:stage-before-unload-sync')!(event, { + sessions: [ + { state: session('unverifiable'), hostId }, + { state: session('local-after'), hostId: 'local' }, + { state: session('ssh-after'), hostId: 'ssh:target' }, + { state: session('known-after'), hostId: knownHost } + ], + ui: { sidebarWidth: 477 } + }) + expect(event.returnValue).toEqual({ ok: true }) + await expect(handlers.get('app:await-before-unload-checkpoint')!()).resolves.toEqual({ + ok: true + }) + expect(error).toHaveBeenCalledWith( + '[app] Staging session state after partition authority failure:', + expect.any(Error) + ) + const disk = JSON.parse(readPersistedStateJson(dataFile)) + expect(disk.ui.sidebarWidth).toBe(477) + expect(Object.keys(disk.workspaceSession.tabsByWorktree)).toEqual([ + 'repo-a::/audit/local-after' + ]) + expect(Object.keys(disk.workspaceSessionsByHostId[hostId].tabsByWorktree)).toEqual([ + 'repo-a::/audit/unverifiable' + ]) + expect(Object.keys(disk.workspaceSessionsByHostId['ssh:target'].tabsByWorktree)).toEqual([ + 'repo-a::/audit/ssh-after' + ]) + expect(Object.keys(disk.workspaceSessionsByHostId[knownHost].tabsByWorktree)).toEqual([ + 'repo-a::/audit/known-after' + ]) + }) + + it('stages a shutdown session whose namespace custody verdict is ambiguous', async () => { + const { store, dataFile } = fixture() + vi.spyOn(store, 'getRepos').mockImplementationOnce(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const event: { returnValue?: unknown } = {} + handlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: session('ambiguous'), hostId: 'runtime:ambiguous' }], + ui: {} + }) + expect(event.returnValue).toEqual({ ok: true }) + await expect(handlers.get('app:await-before-unload-checkpoint')!()).resolves.toEqual({ + ok: true + }) + const disk = JSON.parse(readPersistedStateJson(dataFile)) + expect(Object.keys(disk.workspaceSessionsByHostId['runtime:ambiguous'].tabsByWorktree)).toEqual( + ['repo-a::/audit/ambiguous'] + ) + }) + + it('keeps existing no-flush behavior for actual staging errors', async () => { + const { store } = fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(store, 'stageWorkspaceSessionBeforeUnload').mockImplementation(() => { + throw new Error('actual-stage-error') + }) + const flush = vi.spyOn(store, 'flushPendingOrThrowAsync') + const event: { returnValue?: unknown } = {} + handlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: session('local') }], + ui: {} + }) + expect(event.returnValue).toEqual({ ok: false }) + expect(flush).not.toHaveBeenCalled() + await expect(handlers.get('app:await-before-unload-checkpoint')!()).resolves.toEqual({ + ok: false + }) + }) +}) + +describe('bounded paired mirror retirement', () => { + it('keeps 32 pair/unpair cycles absent in memory, disk, and reload', async () => { + const { dir, store, dataFile, invalidateTransport } = fixture() + const retiredHosts: string[] = [] + for (let index = 0; index < 32; index++) { + const environment = pair(dir, `cycle-${index}`) + const hostId = toRuntimeExecutionHostId(environment.id) + retiredHosts.push(hostId) + await invokeWrite('session:set', session(`cycle-${index}`), hostId) + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) + await unpair(dir, store, environment) + await invokeWrite('session:patch', { activeTabId: 'late' }, hostId) + } + expect(listEnvironments(dir)).toEqual([]) + expect(invalidateTransport).toHaveBeenCalledTimes(32) + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + store.flushOrThrow() + const disk = JSON.parse(readPersistedStateJson(dataFile)) + for (const hostId of retiredHosts) { + expect(disk.workspaceSessionsByHostId[hostId]).toBeUndefined() + } + const reloaded = createSqliteTestStore(Store, { dataFile }) + expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local']) + reloaded.freezeWrites() + }) +}) + +describe('removal failure ordering', () => { + it('writes and acknowledges the legacy sync channel when admission authority is unreadable', async () => { + const { dir, store, dataFile } = fixture() + const environment = pair(dir, 'unreadable') + const hostId = toRuntimeExecutionHostId(environment.id) + const knownHost = 'runtime:known-existing' + store.setWorkspaceSession(session('known-before'), knownHost) + store.setWorkspaceSession(session('local-pending')) + writeFileSync(getEnvironmentStorePath(dir), '{broken') + vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(invokeWrite('session:set-sync', session('unverifiable'), hostId)).resolves.toBe( + true + ) + const disk = JSON.parse(readPersistedStateJson(dataFile)) + expect(Object.keys(disk.workspaceSession.tabsByWorktree)).toEqual([ + 'repo-a::/audit/local-pending' + ]) + expect(Object.keys(disk.workspaceSessionsByHostId[hostId].tabsByWorktree)).toEqual([ + 'repo-a::/audit/unverifiable' + ]) + const readsAfterFailure = authority.reads + await expect(invokeWrite('session:set-sync', session('known-after'), knownHost)).resolves.toBe( + true + ) + expect(authority.reads).toBe(readsAfterFailure) + expect(Object.keys(store.getWorkspaceSession(knownHost).tabsByWorktree)).toEqual([ + 'repo-a::/audit/known-after' + ]) + }) + + it('preserves Store write failures on the legacy sync channel', async () => { + const { store } = fixture() + vi.spyOn(store, 'setWorkspaceSession').mockImplementationOnce(() => { + throw new Error('controlled Store write failure') + }) + await expect(invokeWrite('session:set-sync', session('local'), 'local')).resolves.toBe(true) + }) + + it.each(['controlled custody read failure', 'folder_workspace_connection_ambiguous'])( + 'unpairs but preserves the session partition when custody reports %s', + async (message) => { + const base = fixture() + const environment = pair(base.dir, 'custody-failure') + const hostId = toRuntimeExecutionHostId(environment.id) + base.store.setWorkspaceSession(session('before'), hostId) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(base.store, 'getRepos').mockImplementation(() => { + throw new Error(message) + }) + const removal = vi.spyOn(base.store, 'removeRuntimeWorkspaceSessionPartition') + expect( + await handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id }) + ).toMatchObject({ removed: { id: environment.id } }) + expect(listEnvironments(base.dir).some((entry) => entry.id === environment.id)).toBe(false) + expect(base.invalidateTransport).toHaveBeenCalledWith(environment.id) + expect(removal).not.toHaveBeenCalled() + expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId) + expect(Object.keys(base.store.getWorkspaceSession(hostId).tabsByWorktree)).toEqual([ + 'repo-a::/audit/before' + ]) + expect(warn).toHaveBeenCalledWith( + '[runtime-environments] Preserving session partition after custody lookup failure:', + expect.any(Error) + ) + } + ) + + it('starts transport and browser retirement even if exact Store partition deletion fails', async () => { + const base = fixture() + const environment = pair(base.dir, 'store-deletion-failure') + const hostId = toRuntimeExecutionHostId(environment.id) + base.store.setWorkspaceSession(session('before'), hostId) + clearStorage.mockClear() + vi.spyOn(base.store, 'removeRuntimeWorkspaceSessionPartition').mockImplementation(() => { + throw new Error('controlled partition deletion failure') + }) + await expect( + handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id }) + ).rejects.toThrow('controlled partition deletion failure') + expect(listEnvironments(base.dir)).toEqual([]) + expect(base.invalidateTransport).toHaveBeenCalledWith(environment.id) + await vi.waitFor(() => expect(clearStorage).toHaveBeenCalledWith(environment.id)) + expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId) + }) +}) + +describe('async session admission failures', () => { + it.each(['session:set', 'session:patch'])( + 'still writes %s when the pairing catalog cannot be read', + async (channel) => { + const { dir, store } = fixture() + const environment = pair(dir, 'unreadable') + const hostId = toRuntimeExecutionHostId(environment.id) + writeFileSync(getEnvironmentStorePath(dir), '{broken') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(invokeWrite(channel, session('unverifiable'), hostId)).resolves.toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) + expect(Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)).toEqual([ + 'repo-a::/audit/unverifiable' + ]) + expect(error).toHaveBeenCalledWith( + '[session] Admitting session write after partition authority failure:', + expect.any(Error) + ) + } + ) + + it.each(['session:set', 'session:patch'])( + 'still writes %s when main namespace custody is ambiguous', + async (channel) => { + const { store } = fixture() + vi.spyOn(store, 'getRepos').mockImplementationOnce(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + invokeWrite(channel, session('unverifiable'), 'runtime:unverifiable') + ).resolves.toBeUndefined() + expect(Object.keys(store.getWorkspaceSession('runtime:unverifiable').tabsByWorktree)).toEqual( + ['repo-a::/audit/unverifiable'] + ) + } + ) + + it('still writes session:set-sync when main namespace custody is ambiguous', async () => { + const { store } = fixture() + vi.spyOn(store, 'getRepos').mockImplementationOnce(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + invokeWrite('session:set-sync', session('ambiguous'), 'runtime:ambiguous') + ).resolves.toBe(true) + expect(Object.keys(store.getWorkspaceSession('runtime:ambiguous').tabsByWorktree)).toEqual([ + 'repo-a::/audit/ambiguous' + ]) + }) + + it.each(['session:set', 'session:patch'])( + 'preserves actual Store failures for %s', + async (channel) => { + const { store } = fixture() + const method = channel === 'session:set' ? 'setWorkspaceSession' : 'patchWorkspaceSession' + vi.spyOn(store, method).mockImplementationOnce(() => { + throw new Error('actual Store write failure') + }) + await expect(invokeWrite(channel, session('local'), 'local')).rejects.toThrow( + 'actual Store write failure' + ) + } + ) +}) diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 695eb511bcb7..8c98966fa336 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -1,3 +1,7 @@ +vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({ + hasMainOwnedRuntimeSessionNamespace: () => false +})) + import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -81,6 +85,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType } @@ -91,6 +96,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index d0029c202a3b..704d8b31e760 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -1,3 +1,7 @@ +vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({ + hasMainOwnedRuntimeSessionNamespace: () => false +})) + import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' @@ -99,6 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType } @@ -109,6 +114,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -473,7 +479,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:remove' ) - expect(() => remove(null, { selector: added.environment.id })).toThrow( + await expect(remove(null, { selector: added.environment.id })).rejects.toThrow( 'Choose another Active Server in Advanced' ) expect(activeRuntimeEnvironmentId).toBe(added.environment.id) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 440ed729965a..2ba420860560 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -1,3 +1,7 @@ +vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({ + hasMainOwnedRuntimeSessionNamespace: () => false +})) + import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -89,6 +93,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType } @@ -99,6 +104,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -175,7 +181,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>( 'runtimeEnvironments:remove' ) - expect(remove(null, { selector: added.environment.id })).toMatchObject({ + expect(await remove(null, { selector: added.environment.id })).toMatchObject({ removed: { id: added.environment.id, name: 'desk' } }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index c05e41d552d8..be91706380bc 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,3 +1,4 @@ +import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session' import { registerRuntimeEnvironmentSubscriptionHandlers, type RetainedRemoteRuntimeSubscription, @@ -115,7 +116,10 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) setRuntimeEnvironmentRemovalWatch({ getUserDataPath, - retire: invalidateRuntimeEnvironmentTransport + retire: async (environmentId) => { + await invalidateRuntimeEnvironmentTransport(environmentId) + await retireUnownedRuntimeSession(store, environmentId) + } }) for (const environment of listEnvironments(getUserDataPath())) { if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) { diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 11b118c87315..85a554044062 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,3 +1,4 @@ +import { canAdmitRendererSessionWrite } from './renderer-workspace-session-admission' import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -29,13 +30,13 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic }) ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { - if (!isFenced(hostId)) { + if (!isFenced(hostId) && isRendererSessionAdmitted(store, hostId)) { store.setWorkspaceSession(args, hostId) } }) ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { - if (!isFenced(hostId)) { + if (!isFenced(hostId) && isRendererSessionAdmitted(store, hostId)) { store.patchWorkspaceSession(args, hostId) } }) @@ -72,7 +73,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { void (async () => { try { - if (!isFenced(hostId)) { + if (!isFenced(hostId) && isRendererSessionAdmitted(store, hostId)) { store.setWorkspaceSession(args, hostId) } await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) @@ -93,3 +94,14 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) } + +// Why fail open: an ambiguity raised by an unrelated workspace must never silently drop a user's +// session write. A resurrected partition is recoverable on the next unpair; a lost save is not. +function isRendererSessionAdmitted(store: Store, hostId?: string | null): boolean { + try { + return canAdmitRendererSessionWrite(store, hostId) + } catch (error) { + console.error('[session] Admitting session write after partition authority failure:', error) + return true + } +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 216cd4acafcf..5971eaab1a1a 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -86,6 +86,9 @@ export class PtyBindingPersistenceOperations { ): Promise { const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolved = resolveHostId(hostId) + if (sessions.isRuntimeWorkspaceSessionRetired(resolved)) { + return false + } const publish = (session: WorkspaceSessionState): void => { if (resolved === LOCAL_EXECUTION_HOST_ID) { runtime.state.workspaceSession = session @@ -100,6 +103,9 @@ export class PtyBindingPersistenceOperations { ) } return runtime.runDurableMutation(() => { + if (sessions.isRuntimeWorkspaceSessionRetired(resolved)) { + return { value: false, persist: false } + } const session = sessions.getWorkspaceSession(resolved) const currentId = session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] @@ -143,6 +149,9 @@ export class PtyBindingPersistenceOperations { let outcome: 'refused' | 'fast_lane' | 'flushed' = 'flushed' try { const persisted = await runtime.runDurableMutation(() => { + if (sessions.isRuntimeWorkspaceSessionRetired(resolvedHostId)) { + return { value: false, persist: false } + } const args = typeof input === 'function' ? input() : input if (!args) { return { value: false, persist: false } @@ -240,6 +249,9 @@ function writePtyBinding( const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] const sessionBeforeBinding = cloneWorkspaceSessionState(session) const restore = (restoredSession = sessionBeforeBinding): void => { + if (sessions.isRuntimeWorkspaceSessionRetired(resolvedHostId)) { + return + } if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { runtime.state.workspaceSession = restoredSession } else { diff --git a/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts b/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts new file mode 100644 index 000000000000..42ff0556600c --- /dev/null +++ b/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts @@ -0,0 +1,154 @@ +import { createSqliteTestStore, readPersistedStateJson } from '../../persistence-test-harness' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + getProfileTerminalScrollbackSnapshotRoot, + writeTerminalScrollbackSnapshotSync +} from '../../terminal-scrollback-snapshots' + +const { appPaths } = vi.hoisted(() => ({ appPaths: { userData: '' } })) +vi.mock('electron', () => ({ + app: { + getPath: () => appPaths.userData || tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on() {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on() {}, handle() {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') +const leafId = '33333333-3333-4333-8333-333333333333' +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-retired-snapshots-')) + appPaths.userData = dir + const dataFile = join(dir, 'profile', 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile) + cleanups.push(async () => { + store.freezeWrites() + await store.waitForPendingWrite() + rmSync(dir, { force: true, recursive: true }) + }) + function snapshot(tabId: string, root = snapshotRoot) { + const ref = writeTerminalScrollbackSnapshotSync({ + tabId, + leafId, + buffer: `scrollback for ${tabId}`, + storage: { snapshotRoot: root } + }) + if (!ref) { + throw new Error('Snapshot fixture could not be written') + } + return { ref, path: join(root, `${ref}.bin`) } + } + return { dir, dataFile, store, snapshot } +} + +function session(tabId: string, ref: string): WorkspaceSessionState { + const worktreeId = 'remote-repo::/project' + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + worktreeId, + ptyId: null, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + scrollbackRefsByLeafId: { [leafId]: ref } + } + } + } +} + +describe('removed runtime host scrollback files', () => { + it('removes only the retired host snapshot after persisting its removal', async () => { + const { dataFile, store, snapshot } = fixture() + const removed = snapshot('removed') + const untouched = snapshot('untouched') + store.setWorkspaceSession(session('removed', removed.ref), 'runtime:removed') + store.setWorkspaceSession(session('untouched', untouched.ref), 'runtime:untouched') + store.flushOrThrow() + expect(await store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).toBe(true) + expect( + JSON.parse(readPersistedStateJson(dataFile)).workspaceSessionsByHostId + ).not.toHaveProperty('runtime:removed') + expect(existsSync(removed.path)).toBe(false) + expect(store.readTerminalScrollbackSnapshot(untouched.ref)).toBe('scrollback for untouched') + }) + + it.each(['local', 'ssh:remaining', 'runtime:remaining'] as const)( + 'preserves a snapshot still referenced by %s', + async (hostId) => { + const { store, snapshot } = fixture() + const shared = snapshot('shared') + store.setWorkspaceSession(session('shared', shared.ref), hostId) + store.setWorkspaceSession(session('shared', shared.ref), 'runtime:removed') + expect(await store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).toBe(true) + expect(store.readTerminalScrollbackSnapshot(shared.ref)).toBe('scrollback for shared') + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId.shared.scrollbackRefsByLeafId + ).toEqual({ [leafId]: shared.ref }) + } + ) + + it('preserves the active partition and snapshot when writes are frozen', async () => { + const { store, snapshot } = fixture() + const removed = snapshot('removed') + store.setWorkspaceSession(session('removed', removed.ref), 'runtime:removed') + store.flushOrThrow() + store.freezeWrites() + await expect(store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).rejects.toThrow() + expect(store.getWorkspaceSessionHostIds()).toContain('runtime:removed') + expect(existsSync(removed.path)).toBe(true) + }) + + it('does not remove shared legacy fallback files', async () => { + const { dir, store, snapshot } = fixture() + const legacy = snapshot('legacy', join(dir, 'terminal-scrollback')) + store.setWorkspaceSession(session('legacy', legacy.ref), 'runtime:removed') + await store.removeRuntimeWorkspaceSessionPartition('runtime:removed') + expect(existsSync(legacy.path)).toBe(true) + }) + + it.each(['local', 'ssh:remaining'] as const)('refuses to remove %s', async (hostId) => { + const { store, snapshot } = fixture() + const retained = snapshot('retained') + store.setWorkspaceSession(session('retained', retained.ref), hostId) + expect(await store.removeRuntimeWorkspaceSessionPartition(hostId)).toBe(false) + expect(existsSync(retained.path)).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/runtime-session-recovery-archive.ts b/src/main/persistence/loading-store/runtime-session-recovery-archive.ts new file mode 100644 index 000000000000..9a31872adb96 --- /dev/null +++ b/src/main/persistence/loading-store/runtime-session-recovery-archive.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { dirname, join } from 'node:path' +import { writeSecureJsonFileWithinLimit } from '../../../shared/bounded-secure-json-file' +import { stringifyJsonWithinByteLimit } from '../../../shared/node-bounded-json-stringify' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import type { StoreRuntimeState } from './store-runtime-state' + +export const MAX_RUNTIME_SESSION_RECOVERY_ARCHIVE_BYTES = 64 * 1024 * 1024 + +export function writeRuntimeSessionRecoveryArchive( + dataFile: string, + archiveFile: string, + value: unknown +): void { + writeSecureJsonFileWithinLimit( + join(dirname(dataFile), 'retired-runtime-sessions', archiveFile), + value, + MAX_RUNTIME_SESSION_RECOVERY_ARCHIVE_BYTES, + { durable: true } + ) +} + +function collectDirtyEditorRecords( + session: WorkspaceSessionState +): Record { + const records: Record = {} + for (const workspace of Object.keys(session.openFilesByWorktree ?? {}).sort()) { + const dirty = + session.openFilesByWorktree?.[workspace]?.filter( + (file) => typeof file.dirtyDraftContent === 'string' + ) ?? [] + if (dirty.length > 0) { + records[workspace] = dirty + } + } + return records +} + +function dirtyRecordDigest(records: Record): string | null { + if (Object.keys(records).length === 0) { + return null + } + const entries = Object.entries(records).flatMap(([workspace, files]) => + files.map((file) => [ + workspace, + file.filePath, + file.relativePath, + file.worktreeId, + file.language, + file.runtimeEnvironmentId ?? null, + file.externalSshTargetId ?? null, + file.isPreview === true, + file.readOnly === true, + file.liveTail === true, + file.lastKnownDiskSignature ?? null, + file.dirtyDraftContent + ]) + ) + const payload = stringifyJsonWithinByteLimit( + entries, + MAX_RUNTIME_SESSION_RECOVERY_ARCHIVE_BYTES + ).serialized + return createHash('sha256').update(payload).digest('hex') +} + +export function runtimeEditorDraftDigest(session: WorkspaceSessionState): string | null { + return dirtyRecordDigest(collectDirtyEditorRecords(session)) +} + +export function preserveRetiredRuntimeEditorDrafts( + runtime: Pick< + StoreRuntimeState, + 'state' | 'dataFile' | 'writesFrozen' | 'quitFlushStarted' | 'profileMaintenancePending' + >, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): boolean { + const prior = runtime.state.retiredRuntimeWorkspaceSessions?.[hostId] + if (!prior) { + return false + } + const records = collectDirtyEditorRecords(session) + const digest = dirtyRecordDigest(records) + if (!digest || digest === prior.dirtyDraftDigest) { + return false + } + const hostDigest = createHash('sha256').update(hostId).digest('hex') + const archiveFile = `late-${hostDigest}-${digest}.json` + // Content-addressed files preserve delayed drafts without reviving tabs or multiplying identical copies. + writeRuntimeSessionRecoveryArchive(runtime.dataFile, archiveFile, { + version: 1, + kind: 'late-editor-draft', + hostId, + retiredAt: prior.retiredAt, + session: { openFilesByWorktree: records } + }) + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return false + } + runtime.state.retiredRuntimeWorkspaceSessions = { + ...runtime.state.retiredRuntimeWorkspaceSessions, + [hostId]: { ...prior, dirtyDraftDigest: digest, lateDraftArchiveFile: archiveFile } + } + return true +} diff --git a/src/main/persistence/loading-store/runtime-session-retirement.test.ts b/src/main/persistence/loading-store/runtime-session-retirement.test.ts new file mode 100644 index 000000000000..850c5b5676dc --- /dev/null +++ b/src/main/persistence/loading-store/runtime-session-retirement.test.ts @@ -0,0 +1,284 @@ +import { isRuntimeSessionRetired } from './runtime-session-retirement' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import { createSqliteTestStore, readPersistedStateJson } from '../../persistence-test-harness' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { + getProfileTerminalScrollbackSnapshotRoot, + writeTerminalScrollbackSnapshotSync +} from '../../terminal-scrollback-snapshots' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir(), getName: () => 'orca-test', getVersion: () => '0.0.0', on() {} }, + safeStorage: { isEncryptionAvailable: () => false }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) +const HOST = 'runtime:gone' +const WORKSPACE = 'folder:retired-folder' +const LEAF = '33333333-3333-4333-8333-333333333333' + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-session-archive-')) + const dataFile = join(dir, 'profile', 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + const archiveRoot = join(dir, 'profile', 'retired-runtime-sessions') + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(dir, { recursive: true, force: true }) + }) + return { dir, dataFile, store, archiveRoot } +} + +function draft(content = 'unsaved\n\u0000\r\n🚢'): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [WORKSPACE]: [ + { + filePath: '/gone/file.txt', + relativePath: 'file.txt', + worktreeId: WORKSPACE, + language: 'plaintext', + runtimeEnvironmentId: 'gone', + dirtyDraftContent: content, + lastKnownDiskSignature: 'recorded-baseline' + } + ] + } + } +} + +it('archives exact unsaved text and referenced scrollback before removing active state', async () => { + const { store, dataFile, archiveRoot } = fixture() + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'tab', + leafId: LEAF, + buffer: 'captured terminal\r\n', + storage: { snapshotRoot: getProfileTerminalScrollbackSnapshotRoot(dataFile) } + }) + if (!ref) { + throw new Error('Missing fixture snapshot') + } + const session = { + ...draft(), + tabsByWorktree: { + [WORKSPACE]: [ + { + id: 'tab', + worktreeId: WORKSPACE, + ptyId: null, + title: 'Tab', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf' as const, leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + scrollbackRefsByLeafId: { [LEAF]: ref } + } + } + } + store.setWorkspaceSession(session, HOST) + const prior = structuredClone(store.getWorkspaceSession(HOST)) + await expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).resolves.toBe(true) + expect(store.getWorkspaceSessionHostIds()).not.toContain(HOST) + const files = readdirSync(archiveRoot) + expect(files).toHaveLength(1) + const archive = JSON.parse(readFileSync(join(archiveRoot, files[0]!), 'utf8')) + expect(archive).toMatchObject({ + version: 1, + hostId: HOST, + session: prior, + snapshots: { [ref]: 'captured terminal\r\n' }, + missingSnapshots: [] + }) + const state = JSON.parse(readPersistedStateJson(dataFile)) + expect(state.retiredRuntimeWorkspaceSessions[HOST].archiveFile).toBe(files[0]) + expect(store.readTerminalScrollbackSnapshot(ref)).toBeNull() +}) + +it.each(['full', 'patch', 'before-unload', 'pty'] as const)( + 'keeps a durable fence against a late %s writer after restart', + async (writer) => { + const { store, dataFile } = fixture() + store.setWorkspaceSession(draft(), HOST) + await store.removeRuntimeWorkspaceSessionPartition(HOST) + await store.freezeWritesAsync() + const restarted = createSqliteTestStore(Store, { dataFile }) + cleanups.unshift(() => restarted.freezeWritesAsync()) + expect(restarted.isRuntimeWorkspaceSessionRetired(HOST)).toBe(true) + if (writer === 'full') { + restarted.setWorkspaceSession(draft('late'), HOST) + } + if (writer === 'patch') { + restarted.patchWorkspaceSession({ activeTabId: 'late' }, HOST) + } + if (writer === 'before-unload') { + restarted.stageWorkspaceSessionBeforeUnload(draft('late'), HOST) + } + if (writer === 'pty') { + await expect( + restarted.persistPtyBinding( + { + worktreeId: WORKSPACE, + tabId: 'late', + leafId: LEAF, + ptyId: 'late', + incarnationId: 'late' + }, + HOST + ) + ).resolves.toBe(false) + } + await restarted.flushPendingOrThrowAsync() + expect(restarted.getWorkspaceSessionHostIds()).not.toContain(HOST) + expect( + JSON.parse(readPersistedStateJson(dataFile)).workspaceSessionsByHostId + ).not.toHaveProperty(HOST) + } +) + +it('keeps the active draft and ordinary saves working when archive creation fails', async () => { + const { store, archiveRoot } = fixture() + store.setWorkspaceSession(draft(), HOST) + mkdirSync(join(archiveRoot, '..'), { recursive: true }) + writeFileSync(archiveRoot, 'blocking file') + await expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).rejects.toThrow() + expect(store.getWorkspaceSession(HOST).openFilesByWorktree).toEqual(draft().openFilesByWorktree) + expect(store.isRuntimeWorkspaceSessionRetired(HOST)).toBe(false) + store.patchWorkspaceSession({ activeTabId: 'still writable' }, HOST) + await store.flushPendingOrThrowAsync() + expect(store.getWorkspaceSession(HOST).activeTabId).toBe('still writable') +}) + +it('restores the partition on a failed SQLite commit and leaves its recovery archive intact', async () => { + const { store, dataFile, archiveRoot } = fixture() + store.setWorkspaceSession(draft(), HOST) + store.flushOrThrow() + vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeCompleteSerializedDomains' + ).mockImplementationOnce(() => { + throw new Error('disk full') + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'writeSerializedDomains').mockImplementationOnce( + () => { + throw new Error('disk full') + } + ) + await expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).rejects.toThrow('disk full') + expect(store.isRuntimeWorkspaceSessionRetired(HOST)).toBe(false) + expect(store.getWorkspaceSession(HOST).openFilesByWorktree).toEqual(draft().openFilesByWorktree) + expect(JSON.parse(readPersistedStateJson(dataFile)).workspaceSessionsByHostId).toHaveProperty( + HOST + ) + expect(readdirSync(archiveRoot)).toHaveLength(1) +}) + +it('rechecks custody inside the queue before taking any archive or state away', async () => { + const { store, archiveRoot } = fixture() + store.setWorkspaceSession(draft(), HOST) + const custody = vi.fn(() => true) + await expect(store.removeRuntimeWorkspaceSessionPartition(HOST, custody)).resolves.toBe(false) + expect(custody).toHaveBeenCalledOnce() + expect(store.getWorkspaceSessionHostIds()).toContain(HOST) + expect(() => readdirSync(archiveRoot)).toThrow() +}) + +it('preserves an oversized draft instead of producing a partial archive or freezing other saves', async () => { + const { store, archiveRoot } = fixture() + const oversized = 'x'.repeat(64 * 1024 * 1024) + store.setWorkspaceSession(draft(oversized), HOST) + await expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).rejects.toThrow() + expect( + store.getWorkspaceSession(HOST).openFilesByWorktree?.[WORKSPACE]?.[0]?.dirtyDraftContent + ).toBe(oversized) + expect(store.isRuntimeWorkspaceSessionRetired(HOST)).toBe(false) + expect(() => readdirSync(archiveRoot)).toThrow() + store.setWorkspaceSession(draft('small after refusal'), HOST) + await store.flushPendingOrThrowAsync() +}) + +it('never lets malformed archive metadata fence local or SSH saves', () => { + const valid = { archiveFile: '33333333-3333-4333-8333-333333333333.json', retiredAt: 1 } + const runtime = { + state: { + ...getDefaultPersistedState(tmpdir()), + retiredRuntimeWorkspaceSessions: { + local: valid, + 'ssh:direct': valid, + 'runtime:gone': { archiveFile: '../outside.json', retiredAt: 1 } + } + } + } + expect(isRuntimeSessionRetired(runtime, 'local')).toBe(false) + expect(isRuntimeSessionRetired(runtime, 'ssh:direct')).toBe(false) + expect(isRuntimeSessionRetired(runtime, 'runtime:gone')).toBe(false) +}) + +it.each(['full', 'patch', 'before-unload'] as const)( + 'archives a newer unsaved draft from a late %s without resurrecting the partition', + async (writer) => { + const { store, archiveRoot } = fixture() + store.setWorkspaceSession(draft('first draft'), HOST) + await store.removeRuntimeWorkspaceSessionPartition(HOST) + const newer = draft('newer unsaved draft') + for (let repeat = 0; repeat < 3; repeat++) { + if (writer === 'full') { + store.setWorkspaceSession(newer, HOST) + } + if (writer === 'patch') { + store.patchWorkspaceSession({ openFilesByWorktree: newer.openFilesByWorktree }, HOST) + } + if (writer === 'before-unload') { + store.stageWorkspaceSessionBeforeUnload(newer, HOST) + } + } + await store.flushPendingOrThrowAsync() + expect(store.getWorkspaceSessionHostIds()).not.toContain(HOST) + const archives = readdirSync(archiveRoot).map((file) => + JSON.parse(readFileSync(join(archiveRoot, file), 'utf8')) + ) + expect(archives).toHaveLength(2) + expect(archives).toContainEqual( + expect.objectContaining({ + kind: 'late-editor-draft', + hostId: HOST, + session: expect.objectContaining({ openFilesByWorktree: newer.openFilesByWorktree }) + }) + ) + } +) + +it('keeps identical late drafts from different retired hosts in separate archives', async () => { + const { store, archiveRoot } = fixture() + const otherHost = 'runtime:other-gone' + for (const host of [HOST, otherHost] as const) { + store.setWorkspaceSession(draft(), host) + await store.removeRuntimeWorkspaceSessionPartition(host) + store.setWorkspaceSession(draft('same newer text'), host) + } + const late = readdirSync(archiveRoot) + .map((file) => JSON.parse(readFileSync(join(archiveRoot, file), 'utf8'))) + .filter((archive) => archive.kind === 'late-editor-draft') + expect(late.map((archive) => archive.hostId).sort()).toEqual([HOST, otherHost].sort()) +}) diff --git a/src/main/persistence/loading-store/runtime-session-retirement.ts b/src/main/persistence/loading-store/runtime-session-retirement.ts new file mode 100644 index 000000000000..bbe4b9068476 --- /dev/null +++ b/src/main/persistence/loading-store/runtime-session-retirement.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'node:crypto' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + readTerminalScrollbackSnapshotSync +} from '../../terminal-scrollback-snapshots' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' + +import { + MAX_RUNTIME_SESSION_RECOVERY_ARCHIVE_BYTES, + runtimeEditorDraftDigest, + writeRuntimeSessionRecoveryArchive +} from './runtime-session-recovery-archive' + +export function isRuntimeSessionRetired( + runtime: Pick, + hostId: string +): boolean { + const host = parseExecutionHostId(hostId) + if (host?.kind !== 'runtime') { + return false + } + const archives = runtime.state.retiredRuntimeWorkspaceSessions ?? {} + if (!Object.hasOwn(archives, host.id)) { + return false + } + const archive = archives[host.id] + return ( + typeof archive?.archiveFile === 'string' && + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i.test(archive.archiveFile) && + typeof archive.retiredAt === 'number' && + Number.isFinite(archive.retiredAt) && + archive.retiredAt >= 0 + ) +} + +export async function retireRuntimeSessionPartition( + runtime: Pick< + StoreRuntimeState, + | 'state' + | 'dataFile' + | 'runDurableMutation' + | 'dirtyProfileStateDomains' + | 'terminalScrollbackSnapshotStorage' + >, + sessions: SessionHostPartitionOperations, + hostId: ExecutionHostId, + hasNamespaceCustody?: () => boolean +): Promise { + const refs = new Set() + let archiveError: unknown + const removed = await runtime.runDurableMutation(() => { + const prior = runtime.state.workspaceSessionsByHostId?.[hostId] + if (parseExecutionHostId(hostId)?.kind !== 'runtime' || !prior) { + return { value: false, persist: false } + } + try { + if (hasNamespaceCustody?.()) { + return { value: false, persist: false } + } + } catch (error) { + archiveError = error + return { value: false, persist: false } + } + let snapshotBytes = 0 + const snapshots: Record = {} + const missingSnapshots: string[] = [] + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + refs.add(ref) + const buffer = readTerminalScrollbackSnapshotSync( + ref, + runtime.terminalScrollbackSnapshotStorage + ) + snapshotBytes += + Buffer.byteLength(ref, 'utf8') * 2 + + (buffer === null ? 0 : Buffer.byteLength(buffer, 'utf8')) + if (snapshotBytes > MAX_RUNTIME_SESSION_RECOVERY_ARCHIVE_BYTES) { + archiveError = new Error('Runtime session recovery archive exceeds 64 MiB') + return { value: false, persist: false } + } + if (buffer === null) { + missingSnapshots.push(ref) + } else { + snapshots[ref] = buffer + } + } + const archiveFile = `${randomUUID()}.json` + const retiredAt = Date.now() + let dirtyDraftDigest: string | null = null + // Archive first: a failed profile commit must leave both the active state and recoverable text. + try { + dirtyDraftDigest = runtimeEditorDraftDigest(prior) + writeRuntimeSessionRecoveryArchive(runtime.dataFile, archiveFile, { + version: 1, + hostId, + retiredAt, + session: prior, + snapshots, + missingSnapshots + }) + } catch (error) { + archiveError = error + return { value: false, persist: false } + } + const priorArchive = runtime.state.retiredRuntimeWorkspaceSessions?.[hostId] + const nextPartitions = { ...runtime.state.workspaceSessionsByHostId } + delete nextPartitions[hostId] + runtime.state.workspaceSessionsByHostId = nextPartitions + runtime.state.retiredRuntimeWorkspaceSessions = { + ...runtime.state.retiredRuntimeWorkspaceSessions, + [hostId]: { archiveFile, retiredAt, ...(dirtyDraftDigest ? { dirtyDraftDigest } : {}) } + } + runtime.dirtyProfileStateDomains?.add('workspaceSessionsByHostId') + runtime.dirtyProfileStateDomains?.add('retiredRuntimeWorkspaceSessions') + return { + value: true, + persist: true, + rollback: () => { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [hostId]: prior + } + const archives = { ...runtime.state.retiredRuntimeWorkspaceSessions } + if (priorArchive) { + archives[hostId] = priorArchive + } else { + delete archives[hostId] + } + runtime.state.retiredRuntimeWorkspaceSessions = archives + } + } + }) + if (archiveError !== undefined) { + throw archiveError + } + if (!removed) { + return false + } + invalidateLocalWorktreeMetadataPruneInputs() + for (const owner of sessions.getWorkspaceSessionHostIds()) { + for (const ref of collectTerminalScrollbackSnapshotRefs(sessions.getWorkspaceSession(owner))) { + refs.delete(ref) + } + } + // Shared fallback snapshots may still belong to another profile. + const storage = { ...runtime.terminalScrollbackSnapshotStorage, fallbackSnapshotRoot: null } + for (const ref of refs) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } + return true +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 74c34ec1a68e..b7b0069f594f 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -1,3 +1,4 @@ +import { preserveRetiredRuntimeEditorDrafts } from './runtime-session-recovery-archive' import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' @@ -12,6 +13,10 @@ import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-s import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { + isRuntimeSessionRetired, + retireRuntimeSessionPartition +} from './runtime-session-retirement' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' @@ -31,7 +36,14 @@ import type { TerminalBindingRecoveryOperations } from './terminal-binding-recov type SessionHostPartitionOperationsRuntime = Pick< StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'dataFile' + | 'runDurableMutation' + | 'dirtyProfileStateDomains' + | 'writesFrozen' + | 'quitFlushStarted' + | 'profileMaintenancePending' > const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') @@ -95,6 +107,22 @@ export class SessionHostPartitionOperations { ]) } + isRuntimeWorkspaceSessionRetired(hostId: string): boolean { + return isRuntimeSessionRetired(this[sessionHostPartitionOperationsContext].runtime, hostId) + } + + removeRuntimeWorkspaceSessionPartition( + hostId: ExecutionHostId, + hasNamespaceCustody?: () => boolean + ): Promise { + return retireRuntimeSessionPartition( + this[sessionHostPartitionOperationsContext].runtime, + this, + hostId, + hasNamespaceCustody + ) + } + readTerminalScrollbackSnapshot(ref: string): string | null { return readTerminalScrollbackSnapshotSync( ref, @@ -193,6 +221,13 @@ export function setHostWorkspaceSession( hostId: ExecutionHostId, session: WorkspaceSessionState ): void { + if (owner.isRuntimeWorkspaceSessionRetired(hostId)) { + const { runtime, scheduling } = owner[sessionHostPartitionOperationsContext] + if (preserveRetiredRuntimeEditorDrafts(runtime, hostId, session)) { + scheduleSave(scheduling, ['retiredRuntimeWorkspaceSessions']) + } + return + } const prior = owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] // Why here and not at the callers: the before-unload stage path writes the renderer's payload diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 13174ef18b5a..092d3319c422 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -1,3 +1,4 @@ +import { preserveRetiredRuntimeEditorDrafts } from './runtime-session-recovery-archive' import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionPatch, @@ -27,6 +28,7 @@ type SessionSnapshotOperationsRuntime = Pick< | 'profileMaintenancePending' | 'quitFlushStarted' | 'state' + | 'dataFile' | 'terminalScrollbackSnapshotStorage' | 'retainedScrollbackRefsByMigrationId' | 'writesFrozen' @@ -54,6 +56,12 @@ export class SessionSnapshotOperations { setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = resolveHostId(hostId) + if ( + this[sessionSnapshotOperationsContext].sessions.isRuntimeWorkspaceSessionRetired(resolved) + ) { + this.archiveRetiredEditorDrafts(session, resolved) + return + } const { runtime } = this[sessionSnapshotOperationsContext] if (runtime.durableMutationPhase === 'rollback') { this.assertSnapshotAdmission(true) @@ -74,6 +82,12 @@ export class SessionSnapshotOperations { hostId?: string | null ): void { const resolved = resolveHostId(hostId) + if ( + this[sessionSnapshotOperationsContext].sessions.isRuntimeWorkspaceSessionRetired(resolved) + ) { + this.archiveRetiredEditorDrafts(session, resolved) + return + } if (resolved === LOCAL_EXECUTION_HOST_ID) { this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session, true) @@ -84,6 +98,18 @@ export class SessionSnapshotOperations { patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { const resolved = resolveHostId(hostId) + if ( + this[sessionSnapshotOperationsContext].sessions.isRuntimeWorkspaceSessionRetired(resolved) + ) { + this.archiveRetiredEditorDrafts( + { + ...this[sessionSnapshotOperationsContext].sessions.getWorkspaceSession(resolved), + ...patch + }, + resolved + ) + return + } // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. let next: WorkspaceSessionState = { ...this[sessionSnapshotOperationsContext].sessions.getWorkspaceSession(resolved), @@ -99,6 +125,16 @@ export class SessionSnapshotOperations { this.publishSession(next, resolved) } + private archiveRetiredEditorDrafts( + session: WorkspaceSessionState, + hostId: ExecutionHostId + ): void { + const { runtime, scheduling } = this[sessionSnapshotOperationsContext] + if (preserveRetiredRuntimeEditorDrafts(runtime, hostId, session)) { + scheduleSave(scheduling, ['retiredRuntimeWorkspaceSessions']) + } + } + private publishSession(session: WorkspaceSessionState, hostId: ExecutionHostId): void { const { runtime, scheduling } = this[sessionSnapshotOperationsContext] if (hostId === LOCAL_EXECUTION_HOST_ID) { diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c4346545ef4c..2162a8d21a65 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -107,6 +107,11 @@ export class StateSerializationSecretHandlingOperations { ) } break + case 'retiredRuntimeWorkspaceSessions': + if (this.runtime.state.retiredRuntimeWorkspaceSessions !== undefined) { + stateToSave[domain] = this.runtime.state.retiredRuntimeWorkspaceSessions + } + break case 'sshRemotePtyLeases': stateToSave[domain] = this.runtime.state.sshRemotePtyLeases break diff --git a/src/main/runtime-environment-host-session-self-heal.test.ts b/src/main/runtime-environment-host-session-self-heal.test.ts new file mode 100644 index 000000000000..2e1447afb270 --- /dev/null +++ b/src/main/runtime-environment-host-session-self-heal.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { encodePairingOffer } from '../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + removeEnvironment +} from '../shared/runtime-environment-store' +import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { createSqliteTestStore } from './persistence-test-harness' +import { Store } from './persistence/loading-store/store' +import { selfHealRuntimeHostWorkspaceSessions } from './runtime-environment-host-session-self-heal' + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir(), getName: () => 'orca-test', getVersion: () => '0.0.0', on() {} }, + safeStorage: { isEncryptionAvailable: () => false }, + BrowserWindow: { getAllWindows: () => [] } +})) +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-historical-sessions-')) + const store = createSqliteTestStore(Store, { dataFile: join(dir, 'profile', 'orca-data.json') }) + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(dir, { recursive: true, force: true }) + }) + const pair = (name: string) => + addEnvironmentFromPairingCode(dir, { + name, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://192.0.2.10:6768', + deviceToken: 'test', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + }) + const gone = pair('gone') + const kept = pair('kept') + removeEnvironment(dir, gone.id) + const hostId = toRuntimeExecutionHostId(gone.id) + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeWorktreeId: 'historical' }, + hostId + ) + return { dir, store, hostId, kept } +} + +it('archives a historical removed paired host while preserving local, SSH and legacy namespaces', async () => { + const { dir, store, hostId, kept } = fixture() + const survivors = [ + 'local', + 'ssh:direct', + 'runtime:legacy-namespace', + toRuntimeExecutionHostId(kept.id) + ] + for (const owner of survivors) { + store.setWorkspaceSession(getDefaultWorkspaceSession(), owner) + } + await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId) + expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(true) + expect(store.getWorkspaceSessionHostIds()).toEqual(expect.arrayContaining(survivors)) +}) + +it.each(['missing', 'corrupt', 'unsupported', 'empty'] as const)( + 'preserves historical partitions when the registry is %s', + async (kind) => { + const { dir, store, hostId } = fixture() + const file = getEnvironmentStorePath(dir) + if (kind === 'missing') { + rmSync(file) + } + if (kind === 'corrupt') { + writeFileSync(file, '{broken') + } + if (kind === 'unsupported') { + writeFileSync(file, '{"version":999,"environments":[]}') + } + if (kind === 'empty') { + writeFileSync(file, '{"version":1,"environments":[]}') + } + await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) + expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(false) + } +) + +it('preserves a paired-looking namespace that the current main catalog owns', async () => { + const { dir, store, hostId } = fixture() + store.addRepo({ + id: 'custody', + path: '/custody', + displayName: 'Custody', + badgeColor: 'gray', + addedAt: 1, + executionHostId: hostId + }) + await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) +}) diff --git a/src/main/runtime-environment-host-session-self-heal.ts b/src/main/runtime-environment-host-session-self-heal.ts new file mode 100644 index 000000000000..33012b46678f --- /dev/null +++ b/src/main/runtime-environment-host-session-self-heal.ts @@ -0,0 +1,44 @@ +import { listEnvironments } from '../shared/runtime-environment-store' +import { parseExecutionHostId } from '../shared/execution-host' +import type { Store } from './persistence' +import { retireUnownedRuntimeSession } from './runtime/retire-unowned-runtime-session' + +export async function selfHealRuntimeHostWorkspaceSessions({ + store, + userDataPath, + listKnownEnvironments = listEnvironments, + log = console.warn +}: { + store: Store + userDataPath: string + listKnownEnvironments?: typeof listEnvironments + log?: (message: string, error?: unknown) => void +}): Promise { + let environments: ReturnType + try { + environments = listKnownEnvironments(userDataPath, { requireStoreFile: true }) + } catch { + return + } + // An empty registry may be a lost concurrent write; explicit observed removal handles the last host. + if (environments.length === 0) { + return + } + const known = new Set(environments.map((environment) => environment.id)) + for (const hostId of store.getWorkspaceSessionHostIds()) { + const host = parseExecutionHostId(hostId) + // Older runtime namespaces need not be paired-host IDs. + if ( + host?.kind !== 'runtime' || + !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(host.environmentId) || + known.has(host.environmentId) + ) { + continue + } + try { + await retireUnownedRuntimeSession(store, host.environmentId) + } catch (error) { + log('[runtime-host-session] Retaining session after archive or persistence failure:', error) + } + } +} diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index 98e22b90b568..f77e3c52f283 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -120,6 +120,23 @@ function rendererSaveWithout( } describe('renderer close intents', () => { + it('durably closes a tab with no PTY and fences a stale save after restart', async () => { + const empty = makeSession() + empty.tabsByWorktree[WORKTREE_ID] = empty.tabsByWorktree[WORKTREE_ID]!.map((tab) => ({ + ...tab, + ptyId: null + })) + empty.terminalLayoutsByTabId = {} + const { store, runtime, reload } = createPersistedRuntime(empty) + const stale = structuredClone(store.getWorkspaceSession()) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID } + }) + store.setWorkspaceSession(stale) + expect((await reload()).tabsByWorktree[WORKTREE_ID]).toEqual([]) + }) + it('keeps a closed tab closed across a renderer save and a reload', async () => { const { store, runtime, reload } = createPersistedRuntime(makeSession()) diff --git a/src/main/runtime/retire-unowned-runtime-session.ts b/src/main/runtime/retire-unowned-runtime-session.ts new file mode 100644 index 000000000000..96c9d9645b06 --- /dev/null +++ b/src/main/runtime/retire-unowned-runtime-session.ts @@ -0,0 +1,21 @@ +import { toRuntimeExecutionHostId } from '../../shared/execution-host' +import type { Store } from '../persistence' +import { hasMainOwnedRuntimeSessionNamespace } from './runtime-workspace-session-namespace-custody' + +export async function retireUnownedRuntimeSession( + store: Store, + environmentId: string +): Promise { + const hostId = toRuntimeExecutionHostId(environmentId) + try { + if (hasMainOwnedRuntimeSessionNamespace(store, hostId)) { + return false + } + } catch (error) { + console.warn('[runtime-environments] Preserving session after custody lookup failure:', error) + return false + } + return store.removeRuntimeWorkspaceSessionPartition(hostId, () => + hasMainOwnedRuntimeSessionNamespace(store, hostId) + ) +} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index ac1dd878c165..cb38fcb6fd41 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -82,6 +82,9 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { persist: originals.size > 0 || adopted, rollback: () => { for (const [hostId, original] of originals) { + if (store.getWorkspaceSessionHostIds?.().includes(hostId) === false) { + continue + } const stagedSession = staged.get(hostId) const current = getWorkspaceSession(hostId) if (!stagedSession || !current) { diff --git a/src/main/runtime/runtime-workspace-session-namespace-custody.test.ts b/src/main/runtime/runtime-workspace-session-namespace-custody.test.ts new file mode 100644 index 000000000000..f310c9199f72 --- /dev/null +++ b/src/main/runtime/runtime-workspace-session-namespace-custody.test.ts @@ -0,0 +1,270 @@ +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { createSqliteTestStore } from '../persistence-test-harness' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +import type { LegacyWorkerRecoveryCandidate } from './runtime-legacy-worker-terminal-recovery-types' +import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' +import { hasMainOwnedRuntimeSessionNamespace } from './runtime-workspace-session-namespace-custody' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on() {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on() {}, handle() {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('../persistence/loading-store/store') +const cleanups: (() => void)[] = [] +const HOST = 'runtime:historical-self-stamp' +const WORKTREE = 'repo-a::/audit/worktree' +const TAB = 'legacy-worker-tab' +const LEAF = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const PANE = `${TAB}:${LEAF}` +const INCARNATION = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } + vi.restoreAllMocks() +}) + +function fixture(hostId: ExecutionHostId, folder = false) { + const dir = mkdtempSync(join(tmpdir(), 'orca-session-namespace-')) + const dataFile = join(dir, 'orca-data.json') + const state = getDefaultPersistedState(dir) + if (folder) { + state.projectGroups = [ + { + id: 'group-a', + name: 'Audit group', + parentPath: '/audit', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ] + state.folderWorkspaces = [ + { + id: 'folder-a', + projectGroupId: 'group-a', + name: 'Audit folder', + folderPath: '/audit', + executionHostId: hostId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + } else { + state.repos = [ + { + id: 'repo-a', + path: '/audit', + displayName: 'Audit', + badgeColor: 'blue', + addedAt: 1, + executionHostId: hostId, + kind: 'folder' + } + ] + } + writeFileSync(dataFile, JSON.stringify(state)) + const store = createSqliteTestStore(Store, { dataFile }) + if (folder) { + const declaredOwner = store.getFolderWorkspace('folder-a') + if (!declaredOwner) { + throw new Error('Folder fixture failed to load') + } + expect(declaredOwner.executionHostId).toBeUndefined() + // This exercises explicit in-memory custody; load deliberately strips renderer host stamps. + declaredOwner.executionHostId = hostId + } + cleanups.push(() => { + store.flush() + rmSync(dir, { recursive: true, force: true }) + }) + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: () => null, + hasRuntimeOwnedPtyCandidate: () => false + }) + return { store, controller, workspaceId: folder ? 'folder:folder-a' : WORKTREE } +} + +function session(workspaceId = WORKTREE): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeTabId: TAB, + tabsByWorktree: { + [workspaceId]: [ + { + id: TAB, + worktreeId: workspaceId, + ptyId: 'pty-a', + title: 'Audit', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-a' } + } + }, + terminalPtyIncarnationsByPaneKey: { [PANE]: INCARNATION }, + sleepingAgentSessionsByPaneKey: { + [PANE]: { + paneKey: PANE, + tabId: TAB, + worktreeId: workspaceId, + agent: 'codex', + providerSession: { key: 'session_id', id: 'audit-codex' }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + } +} + +function candidate(): LegacyWorkerRecoveryCandidate { + return { + dispatchId: 'dispatch-a', + dispatchStatus: 'dispatched', + contractVersion: 1, + taskId: 'task-a', + worktreeId: WORKTREE, + terminalHandle: 'term-a', + paneKey: PANE, + tabId: TAB, + leafId: LEAF, + processIncarnation: `pty-a:${INCARNATION}`, + ptyId: 'pty-a', + incarnationId: INCARNATION + } +} + +describe('main workspace session namespace custody', () => { + it.each([false, true])( + 'preserves a main-declared namespace before its first session write with folder=%s', + (folder) => { + const { store, controller, workspaceId } = fixture(HOST, folder) + expect(store.getWorkspaceSessionHostIds()).not.toContain(HOST) + expect(controller.getHostId(workspaceId)).toBe(HOST) + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true) + } + ) + + it.each([false, true])('follows the actual unique persisted alias with folder=%s', (folder) => { + const { store, controller, workspaceId } = fixture('runtime:new-stamp', folder) + store.setWorkspaceSession(session(workspaceId), HOST) + expect(controller.getHostId(workspaceId)).toBe(HOST) + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true) + store.setWorkspaceSession(session(workspaceId), 'runtime:second-alias') + expect(controller.getHostId(workspaceId)).toBe('runtime:new-stamp') + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false) + }) + + it.each(['local', 'ssh:direct-target'] as const)( + 'does not adopt same-ID tabs from %s', + (preferred) => { + const { store, controller } = fixture(preferred) + store.setWorkspaceSession(session(), HOST) + expect(controller.getHostId(WORKTREE)).toBe(preferred) + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false) + } + ) + + it('re-evaluates current catalog custody and removes only the exact runtime partition', async () => { + const { store } = fixture(HOST) + for (const hostId of [HOST, 'runtime:sibling', 'local', 'ssh:direct-target']) { + store.setWorkspaceSession(session(), hostId) + } + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true) + store.removeProjectForHost('repo-a', HOST) + expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false) + expect(await store.removeRuntimeWorkspaceSessionPartition(HOST)).toBe(true) + expect(await store.removeRuntimeWorkspaceSessionPartition(HOST)).toBe(false) + expect(await store.removeRuntimeWorkspaceSessionPartition('local')).toBe(false) + expect(await store.removeRuntimeWorkspaceSessionPartition('ssh:direct-target')).toBe(false) + expect(store.getWorkspaceSessionHostIds()).toEqual([ + 'local', + 'runtime:sibling', + 'ssh:direct-target' + ]) + }) +}) + +describe('legacy recovery rollback partition presence', () => { + it.each(['local', 'ssh:direct-target', HOST] as const)( + 'restores adopted state in existing %s', + async (hostId) => { + const { store, controller } = fixture(hostId) + store.setWorkspaceSession(session(), hostId) + store.flushOrThrow() + vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeSerializedDomains' + ).mockImplementationOnce(() => { + throw new Error('controlled write failure') + }) + vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeCompleteSerializedDomains' + ).mockImplementationOnce(() => { + throw new Error('controlled write failure') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const recovery = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => { + throw new Error('Unexpected database access') + }, + (id) => controller.tryGetHostId(id) + ) + await expect( + recovery.persist([{ candidate: candidate(), resolution: 'adopted' }]) + ).resolves.toEqual(new Set()) + expect(store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[PANE]?.prompt).toBe( + 'continue' + ) + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[TAB]).toBeDefined() + } + ) +}) diff --git a/src/main/runtime/runtime-workspace-session-namespace-custody.ts b/src/main/runtime/runtime-workspace-session-namespace-custody.ts new file mode 100644 index 000000000000..1a3cb353d877 --- /dev/null +++ b/src/main/runtime/runtime-workspace-session-namespace-custody.ts @@ -0,0 +1,58 @@ +import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' +import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import type { Store } from '../persistence' + +export function hasMainOwnedRuntimeSessionNamespace(store: Store, hostId: string): boolean { + if (parseExecutionHostId(hostId)?.kind !== 'runtime') { + return false + } + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + if (repos.some((repo) => getRepoExecutionHostId(repo) === hostId)) { + return true + } + if (folders.some((folder) => parseExecutionHostId(folder.executionHostId)?.id === hostId)) { + return true + } + + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: (workspace) => { + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups: store.getProjectGroups(), + repos + }) + if (connection.kind === 'ambiguous') { + throw new Error('folder_workspace_connection_ambiguous') + } + return connection.kind === 'ssh' ? connection.connectionId : null + }, + hasRuntimeOwnedPtyCandidate: () => false + }) + for (const workspaceId of Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)) { + const scope = parseWorkspaceKey(workspaceId) + const catalogHost = + scope?.type === 'folder' + ? folders.find((folder) => folder.id === scope.folderWorkspaceId)?.executionHostId + : (() => { + const repo = store.getRepo( + getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : workspaceId) + ) + return repo ? getRepoExecutionHostId(repo) : null + })() + // Only a current runtime-owned catalog row can exercise the controller's legacy alias fallback. + if ( + parseExecutionHostId(catalogHost)?.kind === 'runtime' && + controller.tryGetHostId(workspaceId) === hostId + ) { + return true + } + } + return false +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 4403659a2b92..3a742bdd143f 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -1,3 +1,4 @@ +import { selfHealRuntimeHostWorkspaceSessions } from '../runtime-environment-host-session-self-heal' import { app, session } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' @@ -154,6 +155,7 @@ export async function initializeReadyFoundation(): Promise { migrated: profileState.migrated } const store = profileState.store + await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: app.getPath('userData') }) state.store = store // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index c1f1b6de7275..d6ef28df1b1e 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -98,6 +98,18 @@ export type PersistedState = { workspaceSession: WorkspaceSessionState /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ workspaceSessionsByHostId?: Partial> + /** Recovery archive and write fence for explicitly retired paired-host sessions. */ + retiredRuntimeWorkspaceSessions?: Partial< + Record< + ExecutionHostId, + { + archiveFile: string + retiredAt: number + dirtyDraftDigest?: string + lateDraftArchiveFile?: string + } + > + > sshTargets: SshTarget[] /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ sshTargetGenerationCounter?: number diff --git a/tests/e2e/removed-runtime-session-archive.spec.ts b/tests/e2e/removed-runtime-session-archive.spec.ts new file mode 100644 index 000000000000..a9c7109c4323 --- /dev/null +++ b/tests/e2e/removed-runtime-session-archive.spec.ts @@ -0,0 +1,163 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { encodePairingOffer } from '../../src/shared/pairing' +import { listEnvironments } from '../../src/shared/runtime-environment-store' +import { runProcess } from '../../src/shared/child-process/run-process' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../src/shared/protocol-version' +import { + closeSharedControlTestServers, + createSharedControlTestServer +} from '../../src/shared/remote-runtime-shared-control-test-server' + +test.afterAll(closeSharedControlTestServers) + +for (const removal of ['GUI', 'CLI'] as const) { + test(`${removal} removal archives the last host's unsaved draft before retiring its session`, async ({ + electronApp, + orcaPage + }) => { + const userDataPath = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const server = await createSharedControlTestServer({ + resultForRequest: (method) => + method === 'status.get' + ? { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], runtimeId: 'archive-test' } + : { method } + }) + const { environment } = await orcaPage.evaluate( + (pairingCode) => + window.api.runtimeEnvironments.addFromPairingCode({ name: 'archive-fixture', pairingCode }), + encodePairingOffer(server.pairing) + ) + const status = await orcaPage.evaluate( + (selector) => window.api.runtimeEnvironments.connect({ selector, timeoutMs: 10_000 }), + environment.id + ) + expect(status.ok).toBe(true) + await expect + .poll(() => ({ open: server.openClientCount(), authenticated: server.auths.length }), { + timeout: 20_000 + }) + .toEqual({ open: 1, authenticated: 2 }) + const hostId = toRuntimeExecutionHostId(environment.id) + const workspace = 'folder:archive-proof' + const content = 'unsaved runtime draft\n\u0000\r\n🚢' + const session = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [workspace]: [ + { + filePath: '/removed/file.txt', + relativePath: 'file.txt', + worktreeId: workspace, + language: 'plaintext', + runtimeEnvironmentId: environment.id, + dirtyDraftContent: content, + lastKnownDiskSignature: 'recorded-baseline' + } + ] + } + } + await orcaPage.evaluate( + async ({ session, hostId }) => { + await window.api.session.set(session, hostId) + await window.api.session.flush() + }, + { session, hostId } + ) + expect( + await orcaPage.evaluate( + (hostId) => window.api.session.listHostIds().then((ids) => ids.includes(hostId)), + hostId + ) + ).toBe(true) + const localBefore = await orcaPage.evaluate(() => window.api.session.get()) + if (removal === 'GUI') { + const result = await orcaPage.evaluate( + (selector) => window.api.runtimeEnvironments.remove({ selector }), + environment.id + ) + expect(result.removed.id).toBe(environment.id) + } else { + const cli = await runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out/cli/index.js'), + 'environment', + 'rm', + '--environment', + environment.id, + '--json' + ], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataPath }, + timeoutMs: 30_000 + }) + expect(cli.code, cli.stderr || cli.stdout).toBe(0) + } + expect(listEnvironments(userDataPath)).toEqual([]) + await expect + .poll(() => orcaPage.evaluate(() => window.api.session.listHostIds()), { timeout: 30_000 }) + .not.toContain(hostId) + await expect.poll(() => server.openClientCount(), { timeout: 20_000 }).toBe(0) + const profiles = await orcaPage.evaluate(() => window.api.orcaProfiles.list()) + const archiveRoot = path.join( + userDataPath, + 'profiles', + profiles.activeProfileId, + 'retired-runtime-sessions' + ) + const archives = readdirSync(archiveRoot).map((file) => + JSON.parse(readFileSync(path.join(archiveRoot, file), 'utf8')) + ) + expect(archives).toContainEqual( + expect.objectContaining({ + version: 1, + hostId, + session: expect.objectContaining({ openFilesByWorktree: session.openFilesByWorktree }) + }) + ) + await orcaPage.evaluate( + async ({ session, hostId }) => { + await window.api.session.set(session, hostId) + await window.api.session.patch({ activeTabId: 'late' }, hostId) + await window.api.session.flush() + }, + { session, hostId } + ) + expect(await orcaPage.evaluate(() => window.api.session.listHostIds())).not.toContain(hostId) + const newer = { + ...session, + openFilesByWorktree: { + [workspace]: session.openFilesByWorktree[workspace].map((file) => ({ + ...file, + dirtyDraftContent: `${content} latest before unload` + })) + } + } + await orcaPage.evaluate( + async ({ newer, hostId }) => { + await window.api.session.set(newer, hostId) + await window.api.session.patch({ openFilesByWorktree: newer.openFilesByWorktree }, hostId) + window.api.session.setSync(newer, hostId) + await window.api.session.flush() + }, + { newer, hostId } + ) + const finalArchives = readdirSync(archiveRoot).map((file) => + JSON.parse(readFileSync(path.join(archiveRoot, file), 'utf8')) + ) + expect(finalArchives).toHaveLength(2) + expect(finalArchives).toContainEqual( + expect.objectContaining({ + kind: 'late-editor-draft', + hostId, + session: expect.objectContaining({ openFilesByWorktree: newer.openFilesByWorktree }) + }) + ) + expect(await orcaPage.evaluate(() => window.api.session.listHostIds())).not.toContain(hostId) + const localAfter = await orcaPage.evaluate(() => window.api.session.get()) + expect(localAfter.openFilesByWorktree).toEqual(localBefore.openFilesByWorktree) + }) +} From 85bc3d286ca994aa47d49f9b0af85748aed73e95 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 15:52:09 -0700 Subject: [PATCH 2/9] test: keep retired-session SQLite regressions in Node --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index e837c26efb27..bd11a18420ff 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -1,5 +1,8 @@ // Database opening and lifecycle fixtures exercise Node SQLite behavior. export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ + 'src/main/ipc/runtime-environment-session-retirement.test.ts', + 'src/main/runtime-environment-host-session-self-heal.test.ts', + 'src/main/runtime/runtime-workspace-session-namespace-custody.test.ts', 'src/main/ai-vault-search/session-search-file-write.test.ts', 'src/main/ai-vault-search/session-search-live-transcript.test.ts', 'src/main/ai-vault/session-scanner-devin-contention.test.ts', From 53255d6a6ac8f04e0249f2dff566eee7ccd991d3 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 16:07:45 -0700 Subject: [PATCH 3/9] Archive complete stored scrollback and retain uncopied files --- .../runtime-host-session-retirement.md | 4 +- .../runtime-session-retirement.test.ts | 97 +++++++++++++++++++ .../runtime-session-retirement.ts | 5 +- src/main/terminal-scrollback-snapshots.ts | 22 ++++- 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/docs/reference/runtime-host-session-retirement.md b/docs/reference/runtime-host-session-retirement.md index 20e7f6f56cac..a48f54e63aa1 100644 --- a/docs/reference/runtime-host-session-retirement.md +++ b/docs/reference/runtime-host-session-retirement.md @@ -7,7 +7,9 @@ Local, direct SSH, and historical runtime namespace aliases remain separate auth Before removing active state, Orca durably writes a private recovery file under the active profile's `retired-runtime-sessions/` directory, beside `profile-state.db`. Each version-1 JSON file records the execution host, the exact session (including unsaved editor text and disk signatures), and -available referenced terminal scrollback. Missing pre-existing snapshots are listed explicitly. +available referenced terminal scrollback, including the full stored buffer rather than the smaller +replay window. Missing, unreadable, or oversized legacy snapshots are listed explicitly and their +source files are kept. Only successfully archived, unshared profile snapshots are deleted. Archives use the profile's secure file writer and are limited to 64 MiB. An archive failure keeps the active session and permits ordinary saves; a failed database commit restores the partition. diff --git a/src/main/persistence/loading-store/runtime-session-retirement.test.ts b/src/main/persistence/loading-store/runtime-session-retirement.test.ts index 850c5b5676dc..2a12b905c263 100644 --- a/src/main/persistence/loading-store/runtime-session-retirement.test.ts +++ b/src/main/persistence/loading-store/runtime-session-retirement.test.ts @@ -9,6 +9,7 @@ import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqli import { Store } from './store' import { getProfileTerminalScrollbackSnapshotRoot, + readTerminalScrollbackSnapshotSync, writeTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -282,3 +283,99 @@ it('keeps identical late drafts from different retired hosts in separate archive .filter((archive) => archive.kind === 'late-editor-draft') expect(late.map((archive) => archive.hostId).sort()).toEqual([HOST, otherHost].sort()) }) + +function draftWithScrollback(tabId: string, ref: string): WorkspaceSessionState { + return { + ...draft(), + tabsByWorktree: { + [WORKSPACE]: [ + { + id: tabId, + worktreeId: WORKSPACE, + ptyId: null, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + scrollbackRefsByLeafId: { [LEAF]: ref } + } + } + } +} + +it('archives stored scrollback beyond the smaller replay window without losing its prefix', async () => { + const { store, dataFile, archiveRoot } = fixture() + const buffer = `original prefix\n${'x'.repeat(1024 * 1024)}original tail` + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'large', + leafId: LEAF, + buffer, + storage: { snapshotRoot: getProfileTerminalScrollbackSnapshotRoot(dataFile) } + }) + if (!ref) { + throw new Error('Missing snapshot') + } + store.setWorkspaceSession(draftWithScrollback('large', ref), HOST) + await store.removeRuntimeWorkspaceSessionPartition(HOST) + const file = readdirSync(archiveRoot)[0]! + const archive = JSON.parse(readFileSync(join(archiveRoot, file), 'utf8')) + expect(archive.snapshots[ref]?.length).toBe(buffer.length) + expect(archive.snapshots[ref]?.startsWith('original prefix\n')).toBe(true) + expect(archive.snapshots[ref] === buffer).toBe(true) +}) + +it('keeps an oversized legacy snapshot when it cannot be fully archived', async () => { + const { store, dataFile, archiveRoot } = fixture() + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile) + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'oversized', + leafId: LEAF, + buffer: 'original', + storage: { snapshotRoot } + }) + if (!ref) { + throw new Error('Missing snapshot') + } + const rawPath = join(snapshotRoot, `${ref}.bin`) + const original = 'x'.repeat(5 * 1024 * 1024 + 1) + writeFileSync(rawPath, original) + store.setWorkspaceSession(draftWithScrollback('oversized', ref), HOST) + await store.removeRuntimeWorkspaceSessionPartition(HOST) + const archive = JSON.parse(readFileSync(join(archiveRoot, readdirSync(archiveRoot)[0]!), 'utf8')) + expect(archive.missingSnapshots).toContain(ref) + expect(readFileSync(rawPath, 'utf8')).toBe(original) +}) + +it('does not substitute a stale fallback for an oversized primary archive', () => { + const { dir, dataFile } = fixture() + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile) + const fallbackSnapshotRoot = join(dir, 'fallback') + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'fallback', + leafId: LEAF, + buffer: 'primary', + storage: { snapshotRoot } + }) + if (!ref) { + throw new Error('Missing snapshot') + } + mkdirSync(fallbackSnapshotRoot, { recursive: true }) + writeFileSync(join(fallbackSnapshotRoot, `${ref}.bin`), 'stale fallback') + writeFileSync(join(snapshotRoot, `${ref}.bin`), 'x'.repeat(5 * 1024 * 1024 + 1)) + expect( + readTerminalScrollbackSnapshotSync( + ref, + { snapshotRoot, fallbackSnapshotRoot }, + { purpose: 'archive' } + ) === null + ).toBe(true) +}) diff --git a/src/main/persistence/loading-store/runtime-session-retirement.ts b/src/main/persistence/loading-store/runtime-session-retirement.ts index bbe4b9068476..d11e63006da8 100644 --- a/src/main/persistence/loading-store/runtime-session-retirement.ts +++ b/src/main/persistence/loading-store/runtime-session-retirement.ts @@ -69,10 +69,10 @@ export async function retireRuntimeSessionPartition( const snapshots: Record = {} const missingSnapshots: string[] = [] for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - refs.add(ref) const buffer = readTerminalScrollbackSnapshotSync( ref, - runtime.terminalScrollbackSnapshotStorage + runtime.terminalScrollbackSnapshotStorage, + { purpose: 'archive' } ) snapshotBytes += Buffer.byteLength(ref, 'utf8') * 2 + @@ -85,6 +85,7 @@ export async function retireRuntimeSessionPartition( missingSnapshots.push(ref) } else { snapshots[ref] = buffer + refs.add(ref) } } const archiveFile = `${randomUUID()}.json` diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 352edb5ebb4b..e89df898bd75 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence' import { closeSync, mkdirSync, @@ -201,12 +202,27 @@ export async function writeTerminalScrollbackSnapshot(args: { export function readTerminalScrollbackSnapshotSync( ref: string, - storage?: TerminalScrollbackSnapshotStorage + storage?: TerminalScrollbackSnapshotStorage, + options?: { purpose: 'archive' } ): string | null { for (const path of snapshotReadPaths(ref, storage)) { try { - return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) - } catch { + if ( + options?.purpose === 'archive' && + statSync(path).size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + ) { + return null + } + return readTrailingUtf8( + path, + options?.purpose === 'archive' + ? TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + : TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT + ) + } catch (error) { + if (options?.purpose === 'archive' && !isDefinitiveAbsence(error)) { + return null + } // Try the legacy/global fallback when a profile-local snapshot is absent. } } From b09068d354e22213252e4a3389cbfc0a3b36846f Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 17:33:24 -0700 Subject: [PATCH 4/9] test: include session retirement checks in restart stores --- src/main/ipc/pty-pane-restart-replace.test.ts | 1 + tests/e2e/terminal-account-restart.unit.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 5fc47deac664..2057abe2ad72 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -155,6 +155,7 @@ function installRestartHarness( setLocalPtyProvider(provider as unknown as Parameters[0]) let session = seedSession('pty-old') const store = { + isRuntimeWorkspaceSessionRetired: vi.fn(() => false), getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), setWorkspaceSession: vi.fn((next) => { session = next diff --git a/tests/e2e/terminal-account-restart.unit.test.ts b/tests/e2e/terminal-account-restart.unit.test.ts index 23bc83dd4801..d665780a76af 100644 --- a/tests/e2e/terminal-account-restart.unit.test.ts +++ b/tests/e2e/terminal-account-restart.unit.test.ts @@ -150,6 +150,7 @@ function installRestartHarness( terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } } const store = { + isRuntimeWorkspaceSessionRetired: vi.fn(() => false), getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next From 1087e4853032c2d2848970673447fee60417e565 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 17:41:53 -0700 Subject: [PATCH 5/9] Archive complete scrollback through the bounded file reader --- .../terminal-scrollback-archive-read.test.ts | 44 +++++++++++++++++++ src/main/terminal-scrollback-snapshots.ts | 18 +++----- 2 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 src/main/terminal-scrollback-archive-read.test.ts diff --git a/src/main/terminal-scrollback-archive-read.test.ts b/src/main/terminal-scrollback-archive-read.test.ts new file mode 100644 index 000000000000..b8db3109f03a --- /dev/null +++ b/src/main/terminal-scrollback-archive-read.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + readTerminalScrollbackSnapshotSync, + writeTerminalScrollbackSnapshotSync +} from './terminal-scrollback-snapshots' + +vi.mock('node:fs', async (importOriginal) => { + const fs = await importOriginal() + return { + ...fs, + readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number) { + return fs.readSync(fd, buffer, offset, Math.min(length, 7), position) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +it('archives every stored byte when the filesystem returns short reads', () => { + const snapshotRoot = mkdtempSync(join(tmpdir(), 'orca-short-scrollback-read-')) + directories.push(snapshotRoot) + const buffer = 'complete scrollback\r\n🚢\u0000'.repeat(20) + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'tab', + leafId: '33333333-3333-4333-8333-333333333333', + buffer, + storage: { snapshotRoot } + }) + if (!ref) { + throw new Error('Missing fixture snapshot') + } + expect(readTerminalScrollbackSnapshotSync(ref, { snapshotRoot }, { purpose: 'archive' })).toBe( + buffer + ) +}) diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index e89df898bd75..89ea127d8c99 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence' +import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' import { closeSync, mkdirSync, @@ -207,18 +208,11 @@ export function readTerminalScrollbackSnapshotSync( ): string | null { for (const path of snapshotReadPaths(ref, storage)) { try { - if ( - options?.purpose === 'archive' && - statSync(path).size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - return null - } - return readTrailingUtf8( - path, - options?.purpose === 'archive' - ? TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - : TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT - ) + return options?.purpose === 'archive' + ? readNodeFileSyncWithinLimit(path, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT).buffer.toString( + 'utf-8' + ) + : readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) } catch (error) { if (options?.purpose === 'archive' && !isDefinitiveAbsence(error)) { return null From 6fbe002570261c198ef9c1cc40b70d7c4cc06272 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 18:38:53 -0700 Subject: [PATCH 6/9] Select concrete headless server tests before Vitest project discovery --- config/scripts/node-server-change-scope.mjs | 13 +++--------- .../scripts/node-server-change-scope.test.mjs | 8 +++++++ config/scripts/node-server-test-paths.mjs | 21 +++++++++++++++++++ config/scripts/run-node-server-tests.mjs | 20 +++--------------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 71e788867e1b..bb8732cc49c9 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -1,7 +1,7 @@ -import { appendFileSync, globSync, readFileSync } from 'node:fs' +import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { nodeServerTestPaths } from './node-server-test-paths.mjs' +import { nodeServerTestFiles, nodeServerTestPaths } from './node-server-test-paths.mjs' import { nodeServerQualification } from './node-server-qualification.mjs' const ROOT = resolve(import.meta.dirname, '../..') @@ -54,14 +54,7 @@ const ALWAYS_PREFIXES = [ ] export function discoverNodeServerTests(root = ROOT) { - const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) - return globSync( - ['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'], - { cwd: root } - ) - .map((file) => file.replaceAll('\\', '/')) - .filter((file) => selectors.some((selector) => file.includes(selector))) - .sort() + return nodeServerTestFiles({ root, artifact: true, crossRuntime: true }) } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 38b56f0d6000..4fdd5e470dda 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -215,6 +215,14 @@ describe('the actual Bun build and profile-test dependency graph', () => { expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(true) }) + it('keeps desktop Electron probes outside the headless server selection', () => { + const tests = discoverNodeServerTests() + expect(tests).toContain('src/main/persistence/profile-state/profile-state-access.test.ts') + expect(tests).not.toContain( + 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts' + ) + }) + it('retains all selected tests and the selectors the Bun runner uses', () => { const tests = discoverNodeServerTests() expect(tests.length).toBeGreaterThan(80) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 8d9e8eb67102..410299611d7b 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -1,3 +1,7 @@ +import { globSync } from 'node:fs' +import { resolve } from 'node:path' +import { UNIT_INCLUDE } from './ci-unit-files.mjs' + /** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */ export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' @@ -40,3 +44,20 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = ...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []) ] } + +export function nodeServerTestFiles({ + root = resolve(import.meta.dirname, '../..'), + artifact = false, + crossRuntime = false +} = {}) { + const selectors = nodeServerTestPaths({ artifact, crossRuntime }) + return globSync(UNIT_INCLUDE, { cwd: root }) + .map((file) => file.replaceAll('\\', '/')) + .filter( + (file) => + !file.endsWith('.electron.test.ts') && + (crossRuntime || !CROSS_RUNTIME_TEST_PATHS.includes(file)) && + selectors.some((selector) => file.includes(selector)) + ) + .sort() +} diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 2183b9665503..5a3d1b46bc03 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -1,7 +1,7 @@ // Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's. import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' -import { globSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { @@ -11,13 +11,8 @@ import { import { packagedNodeRuntimePath } from './build-orcad-node.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' import { currentTarget } from './server-build-target.mjs' -import { UNIT_INCLUDE } from './ci-unit-files.mjs' import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' -import { - CROSS_RUNTIME_TEST_PATHS, - nodeServerTestPaths, - REQUIRED_TEST_INPUTS_ENV -} from './node-server-test-paths.mjs' +import { nodeServerTestFiles, REQUIRED_TEST_INPUTS_ENV } from './node-server-test-paths.mjs' const root = resolve(import.meta.dirname, '../..') const target = currentTarget() @@ -85,14 +80,5 @@ run(runtimePath, [ ]) function defaultTestArgs() { - // Why: vitest 5 drops CLI --exclude for inline projects, so the selectors' substring matches - // are resolved to files here and filtered before vitest sees them. - // Electron probes run in desktop jobs; headless compatibility containers have no display. - const selectors = nodeServerTestPaths({ artifact, crossRuntime }) - const skipped = new Set(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS) - return globSync(UNIT_INCLUDE, { cwd: root }) - .map((path) => path.replaceAll('\\', '/')) - .filter((path) => selectors.some((selector) => path.includes(selector))) - .filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path)) - .sort() + return nodeServerTestFiles({ root, artifact, crossRuntime }) } From de2be60ab236df46a7147334d504f431cd32d3fa Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 13:06:37 -0700 Subject: [PATCH 7/9] Preserve retired sessions through managed SSH server cleanup --- .../vitest-sqlite-database-runtime-files.mjs | 2 +- src/main/ipc/managed-orcad-actions.ts | 4 +- ...ntime-environment-connectivity-handlers.ts | 20 +-- ...untime-environment-removal-cleanup.test.ts | 27 ++++ .../runtime-environment-removal-cleanup.ts | 14 +- ...untime-environment-removal-storage.test.ts | 6 +- ...time-environment-session-reconcile.test.ts | 133 +++++++++++++----- .../runtime-environment-session-reconcile.ts | 72 +++++----- ...ime-environment-session-retirement.test.ts | 2 +- ...time-environments-capability-cache.test.ts | 2 - .../ipc/runtime-environments-pairing.test.ts | 8 +- ...environments-subscription-teardown.test.ts | 2 - src/main/ipc/runtime-environments.ts | 10 +- ...environment-host-session-self-heal.test.ts | 108 -------------- ...time-environment-host-session-self-heal.ts | 44 ------ .../startup/main-process-ready-foundation.ts | 4 +- 16 files changed, 190 insertions(+), 268 deletions(-) delete mode 100644 src/main/runtime-environment-host-session-self-heal.test.ts delete mode 100644 src/main/runtime-environment-host-session-self-heal.ts diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index bd11a18420ff..76e143b84ec7 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -1,7 +1,7 @@ // Database opening and lifecycle fixtures exercise Node SQLite behavior. export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/ipc/runtime-environment-session-retirement.test.ts', - 'src/main/runtime-environment-host-session-self-heal.test.ts', + 'src/main/ipc/runtime-environment-session-reconcile.test.ts', 'src/main/runtime/runtime-workspace-session-namespace-custody.test.ts', 'src/main/ai-vault-search/session-search-file-write.test.ts', 'src/main/ai-vault-search/session-search-live-transcript.test.ts', diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index 7c610b23d5a7..7fde9eb6ab0e 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -17,8 +17,8 @@ export type ManagedOrcadActionOptions = { invalidateTransport: (environmentId: string) => Promise | void /** Drops the SSH host's stale managed-server state once its server is unlinked. */ clearHostServerStatus: (sshTargetId: string) => void - /** Drops the unlinked server's workspace session partition. */ - forgetHostSession: (hostId: ExecutionHostId) => void + /** Preserves and retires the unlinked server's workspace session partition. */ + forgetHostSession: (hostId: ExecutionHostId) => Promise | void } export function createManagedOrcadActions( diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index e0a0cc69abef..8b43782d7c97 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -1,5 +1,4 @@ -import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody' -import { toRuntimeExecutionHostId } from '../../shared/execution-host' +import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session' import { ipcMain } from 'electron' import { addEnvironmentFromPairingCode, @@ -112,23 +111,10 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { throw new Error('Choose another Active Server in Advanced before removing this server.') } - const hostId = toRuntimeExecutionHostId(environment.id) - // Why default to preserving: an unreadable custody verdict is not evidence that nothing owns - // this namespace, and unpair must still succeed rather than fail with an opaque error. - let preserveMainNamespace = true - try { - preserveMainNamespace = hasMainOwnedRuntimeSessionNamespace(store, hostId) - } catch (error) { - console.warn( - '[runtime-environments] Preserving session partition after custody lookup failure:', - error - ) - } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) => - store.removeWorkspaceSessionHost(hostId) - ) + void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport) closeLegacySelectorTransport(args.selector, removed.id) + await retireUnownedRuntimeSession(store, removed.id) return { removed: redactRuntimeEnvironment(removed) } } ) diff --git a/src/main/ipc/runtime-environment-removal-cleanup.test.ts b/src/main/ipc/runtime-environment-removal-cleanup.test.ts index 261a24f652d0..71a8a3b41416 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.test.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.test.ts @@ -15,4 +15,31 @@ describe('retiring a removed runtime environment', () => { await retireRemovedRuntimeEnvironment('env 1', vi.fn(), forgetHostSession) expect(forgetHostSession).toHaveBeenCalledWith('runtime:env%201') }) + + it('waits for the durable session archive before completing managed-server cleanup', async () => { + const archive = Promise.withResolvers() + const invalidateTransport = vi.fn() + const completed = vi.fn() + const retiring = retireRemovedRuntimeEnvironment( + 'env', + invalidateTransport, + () => archive.promise + ).then(completed) + await Promise.resolve() + expect(invalidateTransport).toHaveBeenCalledWith('env') + expect(completed).not.toHaveBeenCalled() + archive.resolve() + await retiring + expect(completed).toHaveBeenCalledOnce() + }) + + it('starts transport cleanup and reports a failed session archive', async () => { + const invalidateTransport = vi.fn() + await expect( + retireRemovedRuntimeEnvironment('env', invalidateTransport, async () => { + throw new Error('archive failed') + }) + ).rejects.toThrow('archive failed') + expect(invalidateTransport).toHaveBeenCalledWith('env') + }) }) diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts index c1961c137d90..ece1f58137b5 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.ts @@ -4,15 +4,13 @@ import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -/** Retires a removed server's client-side state; resolves once its transport is invalidated. */ -export function retireRemovedRuntimeEnvironment( +/** Joins transport cleanup and durable session retirement for a removed server. */ +export async function retireRemovedRuntimeEnvironment( environmentId: string, invalidateTransport: (environmentId: string) => Promise | void, - forgetHostSession?: (hostId: ExecutionHostId) => void + forgetHostSession?: (hostId: ExecutionHostId) => Promise | void ): Promise { clearRuntimeEnvironmentCapabilityEvidence(environmentId) - // Why: listings enumerate session partitions as known hosts, so a kept one names a dead server. - forgetHostSession?.(toRuntimeExecutionHostId(environmentId)) clearRuntimeEnvironmentManualDisconnect(environmentId) const retiring = Promise.resolve(invalidateTransport(environmentId)) // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes @@ -27,5 +25,9 @@ export function retireRemovedRuntimeEnvironment( }).catch((error) => { console.warn('[runtime-environments] browser partition storage clear failed:', error) }) - return retiring + // Release transports even when the archive fails; only a durable archive can retire the session. + await Promise.all([ + retiring, + Promise.resolve().then(() => forgetHostSession?.(toRuntimeExecutionHostId(environmentId))) + ]) } diff --git a/src/main/ipc/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environment-removal-storage.test.ts index d4a23dd6d61a..6b0b88e6ed1a 100644 --- a/src/main/ipc/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environment-removal-storage.test.ts @@ -54,8 +54,7 @@ describe('runtime environment removal storage clearing', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods. store: { getSettings: () => ({}), - removeRuntimeWorkspaceSessionPartition: vi.fn(), - removeWorkspaceSessionHost: vi.fn() + removeRuntimeWorkspaceSessionPartition: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown @@ -79,8 +78,7 @@ describe('runtime environment removal storage clearing', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods. store: { getSettings: () => ({}), - removeRuntimeWorkspaceSessionPartition: vi.fn(), - removeWorkspaceSessionHost: vi.fn() + removeRuntimeWorkspaceSessionPartition: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() diff --git a/src/main/ipc/runtime-environment-session-reconcile.test.ts b/src/main/ipc/runtime-environment-session-reconcile.test.ts index ffd234a7f75b..2a5733478ae0 100644 --- a/src/main/ipc/runtime-environment-session-reconcile.test.ts +++ b/src/main/ipc/runtime-environment-session-reconcile.test.ts @@ -1,47 +1,108 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { ExecutionHostId } from '../../shared/execution-host' -import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' -import { getEnvironmentStorePath } from '../../shared/runtime-environment-store-file' -import { pairingCode } from './runtime-environments-ipc-test-harness' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + removeEnvironment +} from '../../shared/runtime-environment-store' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' +import { createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' import { reconcileOrphanedRuntimeSessions } from './runtime-environment-session-reconcile' -function sessionStore(hostIds: ExecutionHostId[]) { - return { - getWorkspaceSessionHostIds: () => hostIds, - removeWorkspaceSessionHost: vi.fn() +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir(), getName: () => 'orca-test', getVersion: () => '0.0.0', on() {} }, + safeStorage: { isEncryptionAvailable: () => false }, + BrowserWindow: { getAllWindows: () => [] } +})) +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() } +}) +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-historical-sessions-')) + const store = createSqliteTestStore(Store, { dataFile: join(dir, 'profile', 'orca-data.json') }) + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(dir, { recursive: true, force: true }) + }) + const pair = (name: string) => + addEnvironmentFromPairingCode(dir, { + name, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://192.0.2.10:6768', + deviceToken: 'test', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + }) + const gone = pair('gone') + const kept = pair('kept') + removeEnvironment(dir, gone.id) + const hostId = toRuntimeExecutionHostId(gone.id) + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeWorktreeId: 'historical' }, + hostId + ) + return { dir, store, hostId, kept } } -describe('reconciling orphaned runtime sessions at startup', () => { - it('drops only runtime sessions whose server is gone, never local or ssh', () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-reconcile-')) - const kept = addEnvironmentFromPairingCode(userDataPath, { - name: 'kept', - pairingCode: pairingCode() - }) - const store = sessionStore([ - 'local', - 'ssh:target-1', - `runtime:${kept.id}`, - 'runtime:removed-env' - ]) - expect(reconcileOrphanedRuntimeSessions(store, userDataPath)).toEqual(['runtime:removed-env']) - expect(store.removeWorkspaceSessionHost).toHaveBeenCalledTimes(1) - expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith('runtime:removed-env') - }) +it('archives a historical removed paired host while preserving local, SSH and legacy namespaces', async () => { + const { dir, store, hostId, kept } = fixture() + const survivors = [ + 'local', + 'ssh:direct', + 'runtime:legacy-namespace', + toRuntimeExecutionHostId(kept.id) + ] + for (const owner of survivors) { + store.setWorkspaceSession(getDefaultWorkspaceSession(), owner) + } + await reconcileOrphanedRuntimeSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId) + expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(true) + expect(store.getWorkspaceSessionHostIds()).toEqual(expect.arrayContaining(survivors)) +}) - it('deletes nothing when the environment store is missing or unreadable', () => { - const missing = mkdtempSync(join(tmpdir(), 'orca-session-reconcile-missing-')) - const store = sessionStore(['runtime:removed-env']) - expect(reconcileOrphanedRuntimeSessions(store, missing)).toEqual([]) +it.each(['missing', 'corrupt', 'unsupported', 'empty'] as const)( + 'preserves historical partitions when the registry is %s', + async (kind) => { + const { dir, store, hostId } = fixture() + const file = getEnvironmentStorePath(dir) + if (kind === 'missing') { + rmSync(file) + } + if (kind === 'corrupt') { + writeFileSync(file, '{broken') + } + if (kind === 'unsupported') { + writeFileSync(file, '{"version":999,"environments":[]}') + } + if (kind === 'empty') { + writeFileSync(file, '{"version":1,"environments":[]}') + } + await reconcileOrphanedRuntimeSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) + expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(false) + } +) - const corrupt = mkdtempSync(join(tmpdir(), 'orca-session-reconcile-corrupt-')) - writeFileSync(getEnvironmentStorePath(corrupt), '{not json', { mode: 0o600 }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - expect(reconcileOrphanedRuntimeSessions(store, corrupt)).toEqual([]) - expect(store.removeWorkspaceSessionHost).not.toHaveBeenCalled() +it('preserves a paired-looking namespace that the current main catalog owns', async () => { + const { dir, store, hostId } = fixture() + store.addRepo({ + id: 'custody', + path: '/custody', + displayName: 'Custody', + badgeColor: 'gray', + addedAt: 1, + executionHostId: hostId }) + await reconcileOrphanedRuntimeSessions({ store, userDataPath: dir }) + expect(store.getWorkspaceSessionHostIds()).toContain(hostId) }) diff --git a/src/main/ipc/runtime-environment-session-reconcile.ts b/src/main/ipc/runtime-environment-session-reconcile.ts index 9aeb3396519b..f91783eb89de 100644 --- a/src/main/ipc/runtime-environment-session-reconcile.ts +++ b/src/main/ipc/runtime-environment-session-reconcile.ts @@ -1,42 +1,44 @@ -/** - * Startup reconcile for workspace sessions of servers that no longer exist. Unlinking a server drops - * its `runtime:` session, but a crash between the two, or a build that unlinked before that - * drop existed, leaves an orphan that listings keep naming as an unselectable host. - */ -import { existsSync } from 'node:fs' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' -import { getEnvironmentStorePath } from '../../shared/runtime-environment-store-file' +import { parseExecutionHostId } from '../../shared/execution-host' +import type { Store } from '../persistence' +import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session' -type SessionHostStore = { - getWorkspaceSessionHostIds: () => ExecutionHostId[] - removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void -} - -/** Returns the dropped hosts. Local and ssh: sessions are never touched. */ -export function reconcileOrphanedRuntimeSessions( - store: SessionHostStore, +export async function reconcileOrphanedRuntimeSessions({ + store, + userDataPath, + listKnownEnvironments = listEnvironments, + log = console.warn +}: { + store: Store userDataPath: string -): ExecutionHostId[] { - // Why both guards: a missing file reads as "no servers", and a session must never be deleted - // on evidence that only means the environment store could not be read. - if (!existsSync(getEnvironmentStorePath(userDataPath))) { - return [] - } - let known: Set + listKnownEnvironments?: typeof listEnvironments + log?: (message: string, error?: unknown) => void +}): Promise { + let environments: ReturnType try { - known = new Set(listEnvironments(userDataPath).map((environment) => environment.id)) - } catch (error) { - console.warn('[runtime-environments] skipped orphaned session reconcile:', error) - return [] + environments = listKnownEnvironments(userDataPath, { requireStoreFile: true }) + } catch { + return + } + // An empty registry may be a lost concurrent write; explicit observed removal handles the last host. + if (environments.length === 0) { + return } - // Safe because a runtime: session is only ever written after that server is registered. - const dropped = store.getWorkspaceSessionHostIds().filter((hostId) => { - const parsed = parseExecutionHostId(hostId) - return parsed?.kind === 'runtime' && !known.has(parsed.environmentId) - }) - for (const hostId of dropped) { - store.removeWorkspaceSessionHost(hostId) + const known = new Set(environments.map((environment) => environment.id)) + for (const hostId of store.getWorkspaceSessionHostIds()) { + const host = parseExecutionHostId(hostId) + // Older runtime namespaces need not be paired-host IDs. + if ( + host?.kind !== 'runtime' || + !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(host.environmentId) || + known.has(host.environmentId) + ) { + continue + } + try { + await retireUnownedRuntimeSession(store, host.environmentId) + } catch (error) { + log('[runtime-host-session] Retaining session after archive or persistence failure:', error) + } } - return dropped } diff --git a/src/main/ipc/runtime-environment-session-retirement.test.ts b/src/main/ipc/runtime-environment-session-retirement.test.ts index 47ea1a369374..c81a192afb98 100644 --- a/src/main/ipc/runtime-environment-session-retirement.test.ts +++ b/src/main/ipc/runtime-environment-session-retirement.test.ts @@ -416,7 +416,7 @@ describe('removal failure ordering', () => { 'repo-a::/audit/before' ]) expect(warn).toHaveBeenCalledWith( - '[runtime-environments] Preserving session partition after custody lookup failure:', + '[runtime-environments] Preserving session after custody lookup failure:', expect.any(Error) ) } diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 8c98966fa336..54be8efcf6d3 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -87,7 +87,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -95,7 +94,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), - removeWorkspaceSessionHost: vi.fn(), removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 704d8b31e760..4d00e6c58573 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -105,7 +105,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -113,7 +112,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), - removeWorkspaceSessionHost: vi.fn(), removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId @@ -289,9 +287,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) expect(activeRuntimeEnvironmentId).toBeNull() expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - // A removed server's session partition goes too, so listings stop naming it as a host. - expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith( - `runtime:${encodeURIComponent(added.environment.id)}` + expect(store.removeRuntimeWorkspaceSessionPartition).toHaveBeenCalledWith( + `runtime:${encodeURIComponent(added.environment.id)}`, + expect.any(Function) ) expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 2ba420860560..6af4084bd825 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -95,7 +95,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -103,7 +102,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), - removeWorkspaceSessionHost: vi.fn(), removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index be91706380bc..8ab39ada617d 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -6,6 +6,7 @@ import { } from './runtime-environment-subscription-handlers' import { app, ipcMain } from 'electron' import { listEnvironments } from '../../shared/runtime-environment-store' +import { parseExecutionHostId } from '../../shared/execution-host' import type { Store } from '../persistence' import { isRuntimeEnvironmentManuallyDisconnected, @@ -26,7 +27,6 @@ import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversi import { registerOrcadDeltaMoveHandlers } from './orcad-delta-move-handlers' import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' import { clearPublishedManagedServer } from './ssh-renderer-broadcast' -import { reconcileOrphanedRuntimeSessions } from './runtime-environment-session-reconcile' import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' @@ -101,7 +101,6 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { ipcMain.removeHandler(channel) } ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary') - reconcileOrphanedRuntimeSessions(store, getUserDataPath()) registerRuntimeEnvironmentConnectivityHandlers({ store, @@ -138,7 +137,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, invalidateTransport: invalidateRuntimeEnvironmentTransport, clearHostServerStatus: clearPublishedManagedServer, - forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId) + forgetHostSession: async (hostId) => { + const host = parseExecutionHostId(hostId) + if (host?.kind === 'runtime') { + await retireUnownedRuntimeSession(store, host.environmentId) + } + } }) registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath, diff --git a/src/main/runtime-environment-host-session-self-heal.test.ts b/src/main/runtime-environment-host-session-self-heal.test.ts deleted file mode 100644 index 2e1447afb270..000000000000 --- a/src/main/runtime-environment-host-session-self-heal.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { encodePairingOffer } from '../shared/pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - removeEnvironment -} from '../shared/runtime-environment-store' -import { toRuntimeExecutionHostId } from '../shared/execution-host' -import { createSqliteTestStore } from './persistence-test-harness' -import { Store } from './persistence/loading-store/store' -import { selfHealRuntimeHostWorkspaceSessions } from './runtime-environment-host-session-self-heal' - -vi.mock('electron', () => ({ - app: { getPath: () => tmpdir(), getName: () => 'orca-test', getVersion: () => '0.0.0', on() {} }, - safeStorage: { isEncryptionAvailable: () => false }, - BrowserWindow: { getAllWindows: () => [] } -})) -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0)) { - await cleanup() - } -}) -function fixture() { - const dir = mkdtempSync(join(tmpdir(), 'orca-historical-sessions-')) - const store = createSqliteTestStore(Store, { dataFile: join(dir, 'profile', 'orca-data.json') }) - cleanups.push(async () => { - await store.freezeWritesAsync() - rmSync(dir, { recursive: true, force: true }) - }) - const pair = (name: string) => - addEnvironmentFromPairingCode(dir, { - name, - pairingCode: encodePairingOffer({ - v: 2, - endpoint: 'ws://192.0.2.10:6768', - deviceToken: 'test', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - }) - const gone = pair('gone') - const kept = pair('kept') - removeEnvironment(dir, gone.id) - const hostId = toRuntimeExecutionHostId(gone.id) - store.setWorkspaceSession( - { ...getDefaultWorkspaceSession(), activeWorktreeId: 'historical' }, - hostId - ) - return { dir, store, hostId, kept } -} - -it('archives a historical removed paired host while preserving local, SSH and legacy namespaces', async () => { - const { dir, store, hostId, kept } = fixture() - const survivors = [ - 'local', - 'ssh:direct', - 'runtime:legacy-namespace', - toRuntimeExecutionHostId(kept.id) - ] - for (const owner of survivors) { - store.setWorkspaceSession(getDefaultWorkspaceSession(), owner) - } - await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) - expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId) - expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(true) - expect(store.getWorkspaceSessionHostIds()).toEqual(expect.arrayContaining(survivors)) -}) - -it.each(['missing', 'corrupt', 'unsupported', 'empty'] as const)( - 'preserves historical partitions when the registry is %s', - async (kind) => { - const { dir, store, hostId } = fixture() - const file = getEnvironmentStorePath(dir) - if (kind === 'missing') { - rmSync(file) - } - if (kind === 'corrupt') { - writeFileSync(file, '{broken') - } - if (kind === 'unsupported') { - writeFileSync(file, '{"version":999,"environments":[]}') - } - if (kind === 'empty') { - writeFileSync(file, '{"version":1,"environments":[]}') - } - await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) - expect(store.getWorkspaceSessionHostIds()).toContain(hostId) - expect(store.isRuntimeWorkspaceSessionRetired(hostId)).toBe(false) - } -) - -it('preserves a paired-looking namespace that the current main catalog owns', async () => { - const { dir, store, hostId } = fixture() - store.addRepo({ - id: 'custody', - path: '/custody', - displayName: 'Custody', - badgeColor: 'gray', - addedAt: 1, - executionHostId: hostId - }) - await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: dir }) - expect(store.getWorkspaceSessionHostIds()).toContain(hostId) -}) diff --git a/src/main/runtime-environment-host-session-self-heal.ts b/src/main/runtime-environment-host-session-self-heal.ts deleted file mode 100644 index 33012b46678f..000000000000 --- a/src/main/runtime-environment-host-session-self-heal.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { listEnvironments } from '../shared/runtime-environment-store' -import { parseExecutionHostId } from '../shared/execution-host' -import type { Store } from './persistence' -import { retireUnownedRuntimeSession } from './runtime/retire-unowned-runtime-session' - -export async function selfHealRuntimeHostWorkspaceSessions({ - store, - userDataPath, - listKnownEnvironments = listEnvironments, - log = console.warn -}: { - store: Store - userDataPath: string - listKnownEnvironments?: typeof listEnvironments - log?: (message: string, error?: unknown) => void -}): Promise { - let environments: ReturnType - try { - environments = listKnownEnvironments(userDataPath, { requireStoreFile: true }) - } catch { - return - } - // An empty registry may be a lost concurrent write; explicit observed removal handles the last host. - if (environments.length === 0) { - return - } - const known = new Set(environments.map((environment) => environment.id)) - for (const hostId of store.getWorkspaceSessionHostIds()) { - const host = parseExecutionHostId(hostId) - // Older runtime namespaces need not be paired-host IDs. - if ( - host?.kind !== 'runtime' || - !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(host.environmentId) || - known.has(host.environmentId) - ) { - continue - } - try { - await retireUnownedRuntimeSession(store, host.environmentId) - } catch (error) { - log('[runtime-host-session] Retaining session after archive or persistence failure:', error) - } - } -} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3a742bdd143f..24c388d61a78 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -1,4 +1,4 @@ -import { selfHealRuntimeHostWorkspaceSessions } from '../runtime-environment-host-session-self-heal' +import { reconcileOrphanedRuntimeSessions } from '../ipc/runtime-environment-session-reconcile' import { app, session } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' @@ -155,7 +155,7 @@ export async function initializeReadyFoundation(): Promise { migrated: profileState.migrated } const store = profileState.store - await selfHealRuntimeHostWorkspaceSessions({ store, userDataPath: app.getPath('userData') }) + await reconcileOrphanedRuntimeSessions({ store, userDataPath: app.getPath('userData') }) state.store = store // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell From 2a906169b4adeb4b059dedc9289502d6b80106af Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 13:16:16 -0700 Subject: [PATCH 8/9] Keep shared test file patterns independent of installed Vitest --- config/scripts/ci-unit-files.mjs | 11 ++--------- config/scripts/node-server-change-scope.test.mjs | 15 +++++++++------ config/scripts/node-server-test-paths.mjs | 2 +- config/scripts/unit-test-file-patterns.mjs | 8 ++++++++ 4 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 config/scripts/unit-test-file-patterns.mjs diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 3e0cdb67cd8f..21e65a2e2fd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -1,18 +1,11 @@ import { globSync } from 'node:fs' import { defaultExclude } from 'vitest/config' +import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' +export { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' // Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update. export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/' -export const UNIT_INCLUDE = [ - 'src/**/*.test.ts', - 'src/**/*.test.tsx', - 'config/scripts/**/*.test.ts', - 'config/scripts/**/*.test.mjs', - 'tests/tools/**/*.test.mjs', - 'tests/e2e/**/*.unit.test.ts' -] - export const UNIT_EXCLUDE = [ ...defaultExclude, 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4fdd5e470dda..01d8ca8c11c1 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -135,12 +135,15 @@ it.each([ ])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => { const root = moduleTree( Object.fromEntries( - ['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map( - (name) => [ - `config/scripts/${name}.mjs`, - readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') - ] - ) + [ + 'node-server-change-scope', + 'node-server-test-paths', + 'node-server-qualification', + 'unit-test-file-patterns' + ].map((name) => [ + `config/scripts/${name}.mjs`, + readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + ]) ) ) const changes = join(root, 'changes') diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 410299611d7b..20fa00e8cb2c 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -1,6 +1,6 @@ import { globSync } from 'node:fs' import { resolve } from 'node:path' -import { UNIT_INCLUDE } from './ci-unit-files.mjs' +import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' /** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */ export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' diff --git a/config/scripts/unit-test-file-patterns.mjs b/config/scripts/unit-test-file-patterns.mjs new file mode 100644 index 000000000000..e345ce88ecb8 --- /dev/null +++ b/config/scripts/unit-test-file-patterns.mjs @@ -0,0 +1,8 @@ +export const UNIT_INCLUDE = [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'config/scripts/**/*.test.ts', + 'config/scripts/**/*.test.mjs', + 'tests/tools/**/*.test.mjs', + 'tests/e2e/**/*.unit.test.ts' +] From 1b6f7ddb3c6a79a4b9ca2e26744268543fc09659 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 13:19:52 -0700 Subject: [PATCH 9/9] Release the managed SSH claim when local session archival fails --- .../ipc/runtime-environments-pairing.test.ts | 25 +++++++++++++++++++ src/main/ipc/runtime-environments.ts | 10 +++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 4d00e6c58573..c36790707339 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -12,6 +12,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../shared/protocol-ver import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import * as runtimeMaintenance from './orcad-runtime-maintenance-handlers' const { handleMock, @@ -141,6 +142,30 @@ describe('registerRuntimeEnvironmentHandlers', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + it('preserves failed managed-server archives without stranding stop cleanup', async () => { + const registration = vi + .spyOn(runtimeMaintenance, 'registerOrcadRuntimeMaintenanceHandlers') + .mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + store.removeRuntimeWorkspaceSessionPartition.mockRejectedValue(new Error('archive failed')) + registerRuntimeEnvironmentHandlers(store as never) + const options = registration.mock.calls[0][0] + await expect(options.forgetHostSession('runtime:removed')).resolves.toBeUndefined() + expect(store.removeRuntimeWorkspaceSessionPartition).toHaveBeenCalledWith( + 'runtime:removed', + expect.any(Function) + ) + expect(warn).toHaveBeenCalledWith( + '[runtime-environments] Retaining managed-server session after archive failure:', + expect.any(Error) + ) + } finally { + registration.mockRestore() + warn.mockRestore() + } + }) + it('registers desktop runtime environment management handlers', () => { registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 8ab39ada617d..55809809f9bf 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -140,7 +140,15 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { forgetHostSession: async (hostId) => { const host = parseExecutionHostId(hostId) if (host?.kind === 'runtime') { - await retireUnownedRuntimeSession(store, host.environmentId) + try { + await retireUnownedRuntimeSession(store, host.environmentId) + } catch (error) { + // The server is already unlinked; retain its session without holding the SSH claim. + console.warn( + '[runtime-environments] Retaining managed-server session after archive failure:', + error + ) + } } } })