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.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..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') @@ -215,6 +218,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..20fa00e8cb2c 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 './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' @@ -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 }) } 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' +] diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index e837c26efb27..76e143b84ec7 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/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', 'src/main/ai-vault/session-scanner-devin-contention.test.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..a48f54e63aa1 --- /dev/null +++ b/docs/reference/runtime-host-session-retirement.md @@ -0,0 +1,30 @@ +# 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, 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. + +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/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/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/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..8b43782d7c97 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -1,3 +1,4 @@ +import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session' import { ipcMain } from 'electron' import { addEnvironmentFromPairingCode, @@ -102,16 +103,18 @@ 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 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 012df15f47a2..6b0b88e6ed1a 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,16 @@ 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() + } 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 +75,11 @@ 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() + } 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 new file mode 100644 index 000000000000..c81a192afb98 --- /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 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..54be8efcf6d3 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,8 +85,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -90,7 +94,7 @@ 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 d0029c202a3b..c36790707339 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' @@ -8,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, @@ -99,8 +104,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -108,7 +113,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), - removeWorkspaceSessionHost: vi.fn(), + removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -137,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) @@ -283,9 +312,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([]) @@ -473,7 +502,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..6af4084bd825 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,8 +93,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { let activeRuntimeEnvironmentId: string | null let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } + removeRuntimeWorkspaceSessionPartition: ReturnType updateSettings: ReturnType - removeWorkspaceSessionHost: ReturnType } beforeEach(() => { @@ -98,7 +102,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), - removeWorkspaceSessionHost: vi.fn(), + removeRuntimeWorkspaceSessionPartition: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -175,7 +179,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..55809809f9bf 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, @@ -5,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, @@ -25,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' @@ -100,7 +101,6 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { ipcMain.removeHandler(channel) } ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary') - reconcileOrphanedRuntimeSessions(store, getUserDataPath()) registerRuntimeEnvironmentConnectivityHandlers({ store, @@ -115,7 +115,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)) { @@ -134,7 +137,20 @@ 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') { + 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 + ) + } + } + } }) registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath, 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..2a12b905c263 --- /dev/null +++ b/src/main/persistence/loading-store/runtime-session-retirement.test.ts @@ -0,0 +1,381 @@ +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, + readTerminalScrollbackSnapshotSync, + 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()) +}) + +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 new file mode 100644 index 000000000000..d11e63006da8 --- /dev/null +++ b/src/main/persistence/loading-store/runtime-session-retirement.ts @@ -0,0 +1,155 @@ +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)) { + const buffer = readTerminalScrollbackSnapshotSync( + ref, + runtime.terminalScrollbackSnapshotStorage, + { purpose: 'archive' } + ) + 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 + refs.add(ref) + } + } + 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/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..24c388d61a78 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -1,3 +1,4 @@ +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' @@ -154,6 +155,7 @@ export async function initializeReadyFoundation(): Promise { migrated: profileState.migrated } const store = profileState.store + 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 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 352edb5ebb4b..89ea127d8c99 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -1,4 +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, @@ -201,12 +203,20 @@ 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 { + 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 + } // Try the legacy/global fallback when a profile-local snapshot is absent. } } 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) + }) +} 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