diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts index 2981cabc114..dede6bf8747 100644 --- a/src/main/persistence/loading-store/terminal-session-cleanup.ts +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -8,6 +8,11 @@ import { type TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' +// Why localOnlyScrollbackByTabId is deliberately NOT here despite TERMINAL_SCROLLBACK_SESSION_HOMES +// pairing it with terminalLayoutsByTabId: full normalization reaches the fail-closed strip in +// workspace-session-terminal-buffers.ts, which drops a leaf whose worktree main cannot attribute — +// the renderer already capped that field with attribution in hand, and re-stripping it here is the +// scrollback loss the local-only home exists to prevent. Add it only together with that strip. const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ 'tabsByWorktree', 'terminalLayoutsByTabId' diff --git a/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts b/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts index ef28a9a0a4a..0b9c19dd62e 100644 --- a/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts +++ b/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts @@ -1,5 +1,43 @@ import { describe, expect, it } from 'vitest' -import { resolveLeafScrollbackBuffers } from './leaf-scrollback-resolution' +import { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers' +import { + resolveLeafScrollbackBuffers, + resolveTabScrollbackBuffers, + type TerminalScrollbackSessionHomes +} from './leaf-scrollback-resolution' + +// Why enumerated off the cap's constant: the invariant "no consumer reads a home directly" only +// holds while the resolver reads every home the session can persist. A third entry in +// TERMINAL_SCROLLBACK_SESSION_HOMES fails here (and the Pick type above) until the resolver reads it. +describe.each(TERMINAL_SCROLLBACK_SESSION_HOMES)('resolveTabScrollbackBuffers reads %s', (home) => { + const sessionWithBytesOnlyIn: Record< + (typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number], + TerminalScrollbackSessionHomes + > = { + terminalLayoutsByTabId: { + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + buffersByLeafId: { 'leaf-1': 'from-shared' } + } + }, + localOnlyScrollbackByTabId: {} + }, + localOnlyScrollbackByTabId: { + terminalLayoutsByTabId: {}, + localOnlyScrollbackByTabId: { 'tab-1': { 'leaf-1': 'from-local-only' } } + } + } + + it('returns the bytes that home holds for the tab', () => { + expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-1')).toEqual({ + 'leaf-1': home === 'terminalLayoutsByTabId' ? 'from-shared' : 'from-local-only' + }) + expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-other')).toBeUndefined() + }) +}) describe('resolveLeafScrollbackBuffers', () => { it('returns the shared layout buffers when nothing is held locally', () => { diff --git a/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.ts b/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.ts index f9a0643100e..f1fcf512a17 100644 --- a/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.ts +++ b/src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.ts @@ -1,4 +1,13 @@ import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers' + +/** The session fields a tab's scrollback can live in — typed off the same constant the cap + * enumerates, so a third home is a compile error here until this resolver reads it. */ +export type TerminalScrollbackSessionHomes = Pick< + WorkspaceSessionState, + (typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number] +> export type LeafScrollbackHomes = { /** `TerminalLayoutSnapshot.buffersByLeafId` — shared with peers through the remote projection. */ @@ -20,3 +29,14 @@ export function resolveLeafScrollbackBuffers({ } return sharedBuffers ? { ...sharedBuffers, ...localOnly } : localOnly } + +/** Same read, addressed by tab over a session-shaped record (the store or a parsed session file). */ +export function resolveTabScrollbackBuffers( + session: Partial, + tabId: string +): Record | undefined { + return resolveLeafScrollbackBuffers({ + shared: session.terminalLayoutsByTabId?.[tabId], + localOnly: session.localOnlyScrollbackByTabId?.[tabId] + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts index 9fca8a2ca5b..87880a47f31 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -4,6 +4,8 @@ import { type TerminalColdParkPolicyOverrides } from './terminal-hidden-view-parking' import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' +import { resolveTabScrollbackBuffers } from './leaf-scrollback-resolution' +import { useAppStore } from '@/store' export type TerminalWorktreeParkingDebugVerdict = { worktreeId: string @@ -51,6 +53,9 @@ export function registerTerminalParkingDebugHandle(): void { parkDelayMs: getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, parkedTabIds: () => getParkedTerminalWatcherTabIds(), + // Why through the resolver: a spec that reads one store home directly reports a false zero + // whenever the bytes live in the other one. + resolveLeafScrollback: (tabId) => resolveTabScrollbackBuffers(useAppStore.getState(), tabId), retentionLimit: getTerminalParkingPolicyOverrides().retentionLimit ?? null, worktreeVerdicts: () => worktreeVerdicts } diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e851bd7e63a..63a0b063ddd 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -84,6 +84,8 @@ declare global { __terminalParkingDebug?: { parkDelayMs: number parkedTabIds: () => string[] + /** A tab's scrollback across both store homes, via the one resolver production reads through. */ + resolveLeafScrollback: (tabId: string) => Record | undefined retentionLimit: number | null worktreeVerdicts: () => TerminalWorktreeParkingDebugVerdict[] } diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index f23a3afe295..0fc20c25110 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { @@ -482,6 +483,112 @@ describe('fetchWorkspaceSessionFromHosts', () => { }) }) +describe('patchWorkspaceSessionByHost tab-keyed routing', () => { + const remoteWorktreeId = 'remote-repo::/srv/remote' + const localWorktreeId = 'local-repo::/home/me/local' + const remoteTab = { + id: 'remote-tab', + ptyId: null, + worktreeId: remoteWorktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const parkedLayout: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + buffersByLeafId: { 'leaf-1': 'scrollback captured at park' } + } + const catalog = { + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], + 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + } + } satisfies HostPersistenceState + + it('routes a layouts-only patch to the partition of the tab the live catalog names', async () => { + // A park capture changes only terminalLayoutsByTabId, so the patch carries no tab rows. Routed + // by the payload alone it fell into 'local', where main strips scrollback it cannot attribute + // to a remote worktree — the runtime partition never received the capture (#21295). + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, + { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } + ) + + expect(patch).toHaveBeenCalledWith( + { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, + 'runtime:env-1' + ) + expect(patch).toHaveBeenCalledWith({ terminalLayoutsByTabId: {} }) + }) + + it('routes a remote-session-id-only patch the same way', async () => { + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, + { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } + ) + + expect(patch).toHaveBeenCalledWith( + { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, + 'runtime:env-1' + ) + expect(patch).toHaveBeenCalledWith({ remoteSessionIdsByTabId: {} }) + }) + + it('resolves a tab only the unified catalog lists', async () => { + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, + { + ...catalog, + unifiedTabsByWorktree: { + [remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }] + } + } + ) + + expect(patch).toHaveBeenCalledWith( + { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, + 'runtime:env-1' + ) + }) + + it("lets the payload's own tab row outrank the live catalog", async () => { + // Main merges the payload's tabsByWorktree into whichever partition it lands in, so the layout + // must follow the tab row in this write even when the store has since moved the tab. + const patch = vi.fn().mockResolvedValue(undefined) + + await patchWorkspaceSessionByHost( + { get: vi.fn(), patch, setSync: vi.fn() }, + { + tabsByWorktree: { [localWorktreeId]: [{ ...remoteTab, worktreeId: localWorktreeId }] }, + terminalLayoutsByTabId: { 'remote-tab': parkedLayout } + }, + { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } + ) + + expect(patch).toHaveBeenCalledTimes(1) + expect(patch).toHaveBeenCalledWith( + expect.objectContaining({ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }) + ) + }) +}) + describe('buildHostIdByWorktreeId nested ownership', () => { it('persists an SSH worktree in its paired HUB session partition', () => { const worktreeId = 'nested-repo::/srv/remote-wt' diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 85343eca51e..10fd1a0b237 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -31,8 +31,13 @@ import { indexWorkspaceRuntimeHostOwnership, type WorkspaceRuntimeOwnerProjection } from './workspace-runtime-host-ownership' +import { + buildWorktreeIdByTabId, + extendWorktreeIdByTabId, + type WorkspaceTabOwnerCatalog +} from '../../../shared/workspace-session-host-records' -export type HostPersistenceState = { +export type HostPersistenceState = WorkspaceTabOwnerCatalog & { repos: readonly Pick[] projectGroups?: readonly { id: string; executionHostId?: string | null }[] folderWorkspaces?: readonly { @@ -214,7 +219,14 @@ function splitWorkspaceSessionForWrite( mode: HostSessionWriteMode ): HostSessionSlices { const routing = buildHostSessionRouting(state) - const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId) + // Why the live catalogs: a debounced patch carries only the fields that changed, so a park + // capture's layouts-only patch names no tab rows. Routed by the payload alone, every tab-keyed + // row fell into 'local', where main pruned the scrollback it could not attribute to a remote + // worktree — the runtime partition never received the capture (#21295). + const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state) + const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, { + worktreeIdByTabId + }) attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode) return slices } diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index facfbc20195..e2f090df121 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -210,6 +210,37 @@ describe('splitWorkspaceSessionByHost', () => { expect(slices[RUNTIME_A]).toBeUndefined() }) + it('routes tab-keyed rows through a caller-supplied tab index when the payload has no tab rows', () => { + // A debounced patch that changed only layouts (a park capture) or only PTY bindings carries + // no tabsByWorktree; the index stands in for the rows the payload never mentioned. + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { 't-a': makeLayout() }, + remoteSessionIdsByTabId: { 't-a': 'sess-a' }, + terminalPtyIncarnationsByPaneKey: { 't-a:leaf-1': 'inc-3' } + } + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), { + worktreeIdByTabId: new Map([['t-a', 'a-wt-1']]) + }) + expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a') + expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' }) + expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({ 't-a:leaf-1': 'inc-3' }) + expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toEqual({}) + expect(slices[LOCAL_EXECUTION_HOST_ID]?.remoteSessionIdsByTabId).toEqual({}) + }) + + it('keeps a tab the supplied index does not name in the local slice', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { orphan: makeLayout() } + } + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), { + worktreeIdByTabId: new Map([['t-a', 'a-wt-1']]) + }) + expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan') + expect(slices[RUNTIME_A]).toBeUndefined() + }) + it('routes browser pages via their record worktreeId', () => { const state: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index afd8efdb962..1c6f23c7cb3 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -147,11 +147,13 @@ function assignKeyedByResolvedWorktree( /** Partition a unified session into per-host slices keyed by ExecutionHostId. * Global fields are copied to the 'local' slice; worktree-scoped data is routed - * to its owner host. Entries whose owning worktree is unknown (orphan tabs, - * files, pages) stay in 'local' so they are never silently dropped. */ + * to its owner host. Entries whose owning worktree is unknown to the payload and + * to `worktreeIdByTabId` (orphan tabs, files, pages) stay in 'local' so they are + * never silently dropped. */ export function splitWorkspaceSessionByHost( state: WorkspaceSessionState, - hostIdByWorktreeId: HostIdByWorktreeId + hostIdByWorktreeId: HostIdByWorktreeId, + options: { worktreeIdByTabId?: Map } = {} ): HostSessionSlices { // Template carries only the global fields; per-field assigners add the rest. // Why: copy only own-keys so a partial patch (where most globals are absent) @@ -176,7 +178,7 @@ export function splitWorkspaceSessionByHost( const ctx: SplitContext = { hostIdByWorktreeId, - worktreeIdByTabId: buildWorktreeIdByTabId(state), + worktreeIdByTabId: options.worktreeIdByTabId ?? buildWorktreeIdByTabId(state), worktreeIdByFileId: buildWorktreeIdByFileId(state) } diff --git a/src/shared/workspace-session-host-records.ts b/src/shared/workspace-session-host-records.ts index 658402fa9a0..68867cffe8f 100644 --- a/src/shared/workspace-session-host-records.ts +++ b/src/shared/workspace-session-host-records.ts @@ -24,6 +24,36 @@ export function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map> + unifiedTabsByWorktree?: Readonly> +} + +/** Fill tabs the payload never mentioned from the live catalogs. Payload rows win: main merges the + * payload's own `tabsByWorktree` into whichever partition it lands in, so a tab-keyed row has to + * follow the tab row in THIS write, not a newer store state the debounce has not emitted yet. */ +export function extendWorktreeIdByTabId( + byTab: Map, + catalog: WorkspaceTabOwnerCatalog | undefined +): Map { + for (const [worktreeId, tabs] of Object.entries(catalog?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!byTab.has(tab.id)) { + byTab.set(tab.id, worktreeId) + } + } + } + for (const tabs of Object.values(catalog?.unifiedTabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!byTab.has(tab.id)) { + byTab.set(tab.id, tab.worktreeId) + } + } + } + return byTab +} + /** The workspace a pane key belongs to. A pane key is `:`; both the split and the * stranded-partition adoption resolve it here so neither can parse it its own way. */ export function worktreeIdForPaneKey( diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts new file mode 100644 index 00000000000..5d83bef49f4 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts @@ -0,0 +1,448 @@ +/** + * Does a parked remote pane's scrollback survive a full client restart? + * + * The within-session case is covered by paired-remote-terminal-parked-scrollback-survives.spec.ts. + * This restarts the client on the SAME profile — the shape of an app update — and reports, hop by + * hop, where the park capture lands. + * + * The load-bearing oracle is ON DISK, not the reveal. The paired host's pty stays alive across the + * client restart and keeps a retained tail (terminal-multiplex-initial-snapshot: + * `data: serialized?.data ?? read.tail…`), so a reveal can be served by the host rather than by the + * client's persisted copy — it is kept as an end-to-end check, not the proof. The contradiction- + * capable signal for this fix is which partition on disk holds the buffer after a debounced write. + * + * Two quits, because they exercise different writers: + * - clean quit: the beforeunload checkpoint writes a full per-partition snapshot, which carries + * tabsByWorktree and so routes the layout correctly even on `main` — a control that the harness + * works, not a proof of this fix. + * - hard kill: only the debounced patch writer ran. A park capture's patch changes only + * terminalLayoutsByTabId, so it carries no tab rows; before the fix every layout fell into the + * 'local' partition, where main strips scrollback it cannot attribute to a remote worktree, and + * the remote host's partition never received the capture (#21295). This is the test that fails + * with the fix reverted. + * + * The on-disk reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId` + * partition, and names the partition each reading came from — the issue's original "onDisk: 0" was a + * reader that inspected only the local blob while the capture sat in the runtime partition, a + * reading that could not contradict itself. An empty list means no session file at all (a deleted + * profile), distinguished from an empty buffer. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { resolveLeafScrollbackBuffers } from '../../src/renderer/src/components/terminal-pane/leaf-scrollback-resolution' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { cleanupE2EDaemons, forceQuitElectronAppForE2E } from './helpers/electron-process-shutdown' +import { + callEnvironment, + createPairedHostTerminal, + openPairedClientTab, + waitForPairedPaneMarker +} from './helpers/paired-host-terminal' +import { focusActiveTerminalInput } from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 2_000 +const PAINT_BUDGET_MS = 30_000 +/** Renderer debounce (150 ms) + main's save debounce (1 s, 5 s max wait), with slack. */ +const DEBOUNCED_WRITE_BUDGET_MS = 20_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-restart-')) +const fixturePath = path.join(scratch, 'echo-terminal.mjs') +writeFileSync( + fixturePath, + [ + "process.stdout.write('READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' process.stdout.write(`LINE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ') +} + +type OnDiskPartitionReading = { + partition: string + hasTabRow: boolean + hasLayout: boolean + bufferLength: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readSessionPartition( + partition: string, + session: unknown, + webTabId: string +): OnDiskPartitionReading | null { + if (!isRecord(session)) { + return null + } + const tabsByWorktree = isRecord(session.tabsByWorktree) ? session.tabsByWorktree : {} + const hasTabRow = Object.values(tabsByWorktree).some( + (tabs) => Array.isArray(tabs) && tabs.some((tab) => isRecord(tab) && tab.id === webTabId) + ) + const layouts = isRecord(session.terminalLayoutsByTabId) ? session.terminalLayoutsByTabId : {} + const layout = layouts[webTabId] + const hasLayout = isRecord(layout) + const localOnlyHomes = isRecord(session.localOnlyScrollbackByTabId) + ? session.localOnlyScrollbackByTabId + : {} + // Why the resolver: an ordinary park writes localOnlyScrollbackByTabId, not buffersByLeafId, so + // a reader of one home reports a false zero. Same read production restores through. + const buffers = resolveLeafScrollbackBuffers({ + shared: hasLayout ? { buffersByLeafId: stringRecord(layout.buffersByLeafId) } : undefined, + localOnly: stringRecord(localOnlyHomes[webTabId]) + }) + const bufferLength = Object.values(buffers ?? {}).join('').length + return { partition, hasTabRow, hasLayout, bufferLength } +} + +function stringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined + } + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) +} + +/** Walks the local `workspaceSession` and every `workspaceSessionsByHostId` partition. An empty + * list means no session file was found at all — a reader problem, not an empty buffer. */ +function readOnDiskPartitions(userDataDir: string, webTabId: string): OnDiskPartitionReading[] { + const readings: OnDiskPartitionReading[] = [] + for (const file of globSync(path.join(userDataDir, '**', 'orca-data.json'))) { + try { + const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (!isRecord(parsed)) { + continue + } + const local = readSessionPartition('local', parsed.workspaceSession, webTabId) + if (local) { + readings.push(local) + } + const partitions = isRecord(parsed.workspaceSessionsByHostId) + ? parsed.workspaceSessionsByHostId + : {} + for (const [hostId, session] of Object.entries(partitions)) { + const reading = readSessionPartition(hostId, session, webTabId) + if (reading) { + readings.push(reading) + } + } + } catch { + // A partially written profile is itself a datapoint; keep scanning the rest. + } + } + return readings +} + +/** Bytes the remote host's own partition holds for the tab; -1 when no partition names it. */ +function runtimePartitionBufferLength(readings: OnDiskPartitionReading[]): number { + const runtime = readings.filter((reading) => reading.partition.startsWith('runtime:')) + return runtime.length === 0 ? -1 : Math.max(...runtime.map((reading) => reading.bufferLength)) +} + +/** Bytes any 'local' partition reading holds for the tab; the pre-fix bug parked a stripped (0) or + * buffered copy here instead of in the remote host's partition. */ +function localPartitionBufferLength(readings: OnDiskPartitionReading[]): number { + const local = readings.filter((reading) => reading.partition === 'local') + return local.length === 0 ? -1 : Math.max(...local.map((reading) => reading.bufferLength)) +} + +async function waitForRuntimePartitionCapture( + userDataDir: string, + webTabId: string +): Promise { + const deadline = Date.now() + DEBOUNCED_WRITE_BUDGET_MS + let readings = readOnDiskPartitions(userDataDir, webTabId) + while (runtimePartitionBufferLength(readings) <= 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 500)) + readings = readOnDiskPartitions(userDataDir, webTabId) + } + return readings +} + +async function readStoreBufferLength(page: Page, webTabId: string): Promise { + // Why the debug handle: this body runs in the renderer, so it reaches the resolver through the + // e2e handle rather than reading either store home directly. + return page.evaluate((id) => { + const buffers = window.__terminalParkingDebug?.resolveLeafScrollback(id) + return Object.values(buffers ?? {}).join('').length + }, webTabId) +} + +async function activateWorktree(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) +} + +type ParkedRemoteTerminal = { + client: PairedElectronClient + worktreeId: string + webTabId: string + token: string + tokenBeforePark: boolean + storeAtPark: number +} + +/** Open a remote-runtime terminal on the paired client, type a token into it, and cold-park it + * behind two decoy tabs. The token exists only in that pane's buffer — nothing replays stdin. + * Every host terminal it creates is pushed into `createdTerminals` as it is created, so the + * caller's `finally` can close them even if this throws partway. */ +async function parkRemoteTerminalWithToken( + orcaPage: Page, + client: PairedElectronClient, + createdTerminals: string[] +): Promise { + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('headed host has no active worktree') + } + return id + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + await activateWorktree(client.page, worktreeId) + + const target = await createPairedHostTerminal( + client.page, + client.environmentId, + worktreeId, + fixtureCommand() + ) + createdTerminals.push(target.terminal) + const decoys = [] + for (let index = 0; index < 2; index += 1) { + const decoy = await createPairedHostTerminal( + client.page, + client.environmentId, + worktreeId, + fixtureCommand() + ) + createdTerminals.push(decoy.terminal) + decoys.push(decoy) + } + + await openPairedClientTab(client.page, worktreeId, target.webTabId) + await waitForPairedPaneMarker(client.page, target.webTabId, 'READY', PAINT_BUDGET_MS) + const token = `LINE:token-${randomUUID()}` + await focusActiveTerminalInput(client.page) + await client.page.keyboard.type(token.slice('LINE:'.length)) + await client.page.keyboard.press('Enter') + const tokenBeforePark = await waitForPairedPaneMarker( + client.page, + target.webTabId, + token, + PAINT_BUDGET_MS + ) + + for (const decoy of decoys) { + await openPairedClientTab(client.page, worktreeId, decoy.webTabId) + } + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + const storeAtPark = await readStoreBufferLength(client.page, target.webTabId) + return { client, worktreeId, webTabId: target.webTabId, token, tokenBeforePark, storeAtPark } +} + +async function relaunchAndReveal( + offer: Awaited>, + testInfo: TestInfo, + parked: ParkedRemoteTerminal, + extraEnv: Record +): Promise<{ + relaunched: PairedElectronClient + storeAfterRelaunch: number + tokenAfterReveal: boolean +}> { + const relaunched = await launchPairedElectronClient(offer, testInfo, 'parked-restart-relaunch', { + extraEnv, + reuseUserDataDir: parked.client.userDataDir + }) + await activateWorktree(relaunched.page, parked.worktreeId) + const storeAfterRelaunch = await readStoreBufferLength(relaunched.page, parked.webTabId) + await openPairedClientTab(relaunched.page, parked.worktreeId, parked.webTabId) + const tokenAfterReveal = await waitForPairedPaneMarker( + relaunched.page, + parked.webTabId, + parked.token, + PAINT_BUDGET_MS + ) + return { relaunched, storeAfterRelaunch, tokenAfterReveal } +} + +async function closeCreatedTerminals( + client: PairedElectronClient, + createdTerminals: readonly string[] +): Promise { + for (const terminal of createdTerminals) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } +} + +test.describe('host retains nothing', () => { + test.use({ + orcaAppExtraEnv: { ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE: '1' } + }) + + test('the debounced write alone lands the parked scrollback in the remote host’s partition, so a hard kill loses nothing', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(600_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) } + const first = await launchPairedElectronClient(offer, testInfo, 'parked-kill', { extraEnv }) + const userDataDir = first.userDataDir + const createdTerminals: string[] = [] + let relaunched: PairedElectronClient | null = null + try { + const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals) + + // Why no beforeunload and no ordinary close: both run the shutdown checkpoint, whose full + // snapshot routes the layout correctly and would mask a misrouted debounced patch. Only the + // debounced patch writer runs between the park and this kill. + const onDiskAfterPark = await waitForRuntimePartitionCapture(userDataDir, parked.webTabId) + await forceQuitElectronAppForE2E(first.app) + await cleanupE2EDaemons(userDataDir) + + const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv) + relaunched = reveal.relaunched + + console.log( + `[parked-restart] hard-kill ${JSON.stringify({ + tokenBeforePark: parked.tokenBeforePark, + storeAtPark: parked.storeAtPark, + onDiskAfterPark, + storeAfterRelaunch: reveal.storeAfterRelaunch, + tokenAfterReveal: reveal.tokenAfterReveal + })}` + ) + expect({ + tokenBeforePark: parked.tokenBeforePark, + capturedAtPark: parked.storeAtPark > 0, + profileSurvived: onDiskAfterPark.length > 0, + // The load-bearing assertion: the debounced writer routed the capture to the remote host's + // own partition, and did not leave it stripped in 'local'. This is what fails on `main`. + runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterPark) > 0, + localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterPark) <= 0, + // Secondary: the reveal may be served by the live host's retained tail rather than the + // disk copy, so it is not the fix's oracle — it confirms relaunch and a visible pane. + tokenAfterReveal: reveal.tokenAfterReveal + }).toEqual({ + tokenBeforePark: true, + capturedAtPark: true, + profileSurvived: true, + runtimePartitionHoldsCapture: true, + localPartitionDidNotKeepCapture: true, + tokenAfterReveal: true + }) + } finally { + const live = relaunched ?? first + await closeCreatedTerminals(live, createdTerminals) + await live.dispose().catch(() => undefined) + } + }) + + test('a clean quit also lands the parked scrollback in the remote host’s partition (control)', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(600_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) } + const first = await launchPairedElectronClient(offer, testInfo, 'parked-restart', { extraEnv }) + const userDataDir = first.userDataDir + const createdTerminals: string[] = [] + let relaunched: PairedElectronClient | null = null + try { + const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals) + + await first.page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await first.quitPreservingProfile() + const onDiskAfterQuit = readOnDiskPartitions(userDataDir, parked.webTabId) + + const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv) + relaunched = reveal.relaunched + + console.log( + `[parked-restart] clean-quit ${JSON.stringify({ + tokenBeforePark: parked.tokenBeforePark, + storeAtPark: parked.storeAtPark, + onDiskAfterQuit, + storeAfterRelaunch: reveal.storeAfterRelaunch, + tokenAfterReveal: reveal.tokenAfterReveal + })}` + ) + // Why tokenAfterReveal is logged and not asserted here: this control proves the harness + // (park, quit, profile, partition routing), not the reveal. On identical product code the + // clean-quit reveal measured true, true, false across three runs — the live host may serve + // it from its own tail — so it cannot carry an assertion. The hard-kill test keeps it, + // because there the host is forced unavailable and the reveal must come from the client copy. + expect({ + tokenBeforePark: parked.tokenBeforePark, + capturedAtPark: parked.storeAtPark > 0, + // Distinguishes a deleted profile (no partitions) from an empty buffer. + profileSurvived: onDiskAfterQuit.length > 0, + runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterQuit) > 0, + localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterQuit) <= 0 + }).toEqual({ + tokenBeforePark: true, + capturedAtPark: true, + profileSurvived: true, + runtimePartitionHoldsCapture: true, + localPartitionDidNotKeepCapture: true + }) + } finally { + const live = relaunched ?? first + await closeCreatedTerminals(live, createdTerminals) + await live.dispose().catch(() => undefined) + } + }) +})