diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts index 6b778a6f284..31151dcb596 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -24,7 +24,12 @@ import { reconcileManagedOrcadSshTargets, visibleRepos } from '../../ssh/orcad-r import { SshConnectionStore } from '../../ssh/connection/ssh-connection-store' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' import { toSshExecutionHostId } from '../../../shared/execution-host' -import { makeTerminalScrollbackSnapshotRef } from '../../terminal-scrollback-snapshots' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' +import { + getProfileTerminalScrollbackSnapshotRoot, + getTerminalScrollbackSnapshotPath, + makeTerminalScrollbackSnapshotRef +} from '../../terminal-scrollback-snapshots' const mocks = vi.hoisted(() => { const state: { targetStore: unknown } = { targetStore: null } @@ -183,6 +188,11 @@ describe('converting an SSH host into a managed server', () => { mkdirSync(reportDirectory) const dataFile = join(reportDirectory, 'orca-data.json') writeFileSync(dataFile, JSON.stringify(profile)) + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile) + mkdirSync(snapshotRoot) + const snapshotPath = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot })! + writeFileSync(snapshotPath, '') + truncateSync(snapshotPath, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1) store = createSqliteTestStore(Store, { dataFile }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry wraps this real test store. mocks.state.targetStore = new SshConnectionStore(store as never) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts index 6e58013c091..388efa619c4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration/orcad-migration-scrollback' -import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import { + makeTerminalScrollbackSnapshotRef, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { + hasDuplicateOrcadMigrationScrollbackDescriptors, + projectOrcadMigrationSessionScrollback +} from './orcad-source-scrollback-state' const FIRST: OrcadMigrationTerminalScrollbackSnapshot = { tabId: 'tab-1', @@ -10,7 +23,83 @@ const FIRST: OrcadMigrationTerminalScrollbackSnapshot = { byteLength: 1 } +let root: string +let storage: TerminalScrollbackSnapshotStorage + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orcad-source-scrollback-')) + storage = { snapshotRoot: join(root, 'primary'), fallbackSnapshotRoot: join(root, 'fallback') } + mkdirSync(join(root, 'primary')) + mkdirSync(join(root, 'fallback')) +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function project(layout: Partial) { + return projectOrcadMigrationSessionScrollback( + { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null, ...layout } + } + }, + storage + ) +} + +function storeSnapshot(leafId: string, contents: string | number, dir = 'primary'): string { + const ref = makeTerminalScrollbackSnapshotRef('tab-1', leafId) + const path = join(root, dir, `${ref}.bin`) + writeFileSync(path, typeof contents === 'string' ? contents : '') + if (typeof contents === 'number') { + truncateSync(path, contents) + } + return ref +} + describe('orcad source scrollback projection', () => { + it('moves panes that saved no terminal history without blocking the move', () => { + const projected = project({ + buffersByLeafId: { 'leaf-empty': '' }, + scrollbackRefsByLeafId: { + 'leaf-deleted': makeTerminalScrollbackSnapshotRef('tab-1', 'leaf-deleted'), + 'leaf-zero': storeSnapshot('leaf-zero', 0) + } + }) + + expect(projected.blockedItems).toEqual([]) + expect(projected.snapshots).toEqual([]) + expect(projected.session.terminalLayoutsByTabId['tab-1'].scrollbackRefsByLeafId).toBeUndefined() + }) + + it('carries history from the fallback root when the primary copy is empty or missing', () => { + const zeroRef = storeSnapshot('leaf-zero', 0) + storeSnapshot('leaf-zero', 'zero', 'fallback') + const missingRef = storeSnapshot('leaf-missing', 'missing', 'fallback') + + const projected = project({ + scrollbackRefsByLeafId: { 'leaf-zero': zeroRef, 'leaf-missing': missingRef } + }) + + expect(projected.blockedItems).toEqual([]) + expect(projected.snapshots.map((snapshot) => [snapshot.leafId, snapshot.byteLength])).toEqual([ + ['leaf-missing', 7], + ['leaf-zero', 4] + ]) + }) + + it('still blocks the move on saved history it cannot read', () => { + const projected = project({ + scrollbackRefsByLeafId: { + 'leaf-big': storeSnapshot('leaf-big', TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1) + } + }) + + expect(projected.blockedItems).toEqual([ + 'saved terminal history Orca could not read for tab tab-1' + ]) + }) + it('refuses duplicate refs or tab/leaf identities across merged fragments', () => { expect( hasDuplicateOrcadMigrationScrollbackDescriptors([ diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index 7c27194ed9d..88ec5f6b08e 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -10,6 +10,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { makeTerminalScrollbackSnapshotRef, readTerminalScrollbackStoredBytesSync, + readTerminalScrollbackStoredSnapshotSync, type TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' import { compareKeys } from './orcad-source-key-order' @@ -64,14 +65,13 @@ export function projectOrcadMigrationSessionScrollback( const bytes = buffer ? Buffer.from(buffer, 'utf8') : sourceRef - ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) - : null - if ( - !ref || - !bytes || - bytes.length === 0 || - bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { + ? readTerminalScrollbackStoredSnapshotSync(sourceRef, storage) + : 'absent' + if (bytes === 'absent') { + // Nothing was saved, so the pane moves with no history to lose. + continue + } + if (!ref || !bytes || bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { ;(leaveBehind ? leftBehindItems : blockedItems).push( `saved terminal history Orca could not read for tab ${tabId}` ) diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index a2936ff2c40..a64d221d37b 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -79,18 +79,32 @@ export function readTerminalScrollbackStoredBytesSync( ref: string, storage?: TerminalScrollbackSnapshotStorage ): Buffer | null { - for (const path of snapshotReadPaths(ref, storage)) { + const bytes = readTerminalScrollbackStoredSnapshotSync(ref, storage) + return bytes === 'absent' ? null : bytes +} + +/** `'absent'` when no root holds saved bytes; `null` when a snapshot exists but can't be read. */ +export function readTerminalScrollbackStoredSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): Buffer | 'absent' | null { + const paths = snapshotReadPaths(ref, storage) + let unreadable = paths.length === 0 + for (const path of paths) { try { - const size = statSync(path).size - if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { + if (statSync(path).size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { return null } - return readFileSync(path) - } catch { - // Try the next snapshot root when this read fails. + const bytes = readFileSync(path) + // An empty file holds no history, but another root may. + if (bytes.length > 0) { + return bytes + } + } catch (error) { + unreadable ||= !(error instanceof Error && 'code' in error && error.code === 'ENOENT') } } - return null + return unreadable ? null : 'absent' } function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { diff --git a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts index c6bc02d7489..e775a164081 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -1,6 +1,7 @@ // Upgrade a live detached 1.4.224 relay to managed orcad while preserving its original tab/shell across SSH loss and explicit disconnect. // Retire the pane/lease only on exit; never touch another desktop’s relay. If its project already moved, keep connected without moving until the shell exits. // COMPAT(legacy-relay-bridge): revisit 2027-01-15, with src/main/ssh/legacy-relay/. +import { mkdirSync, truncateSync, writeFileSync } from 'node:fs' import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' import { z } from 'zod' import { expect, test } from './helpers/orca-app' @@ -13,7 +14,14 @@ import { readPersistedProfileState } from './helpers/persisted-profile-state' import { workspaceSessionStateSchema } from '../../src/shared/workspace-session-schema' -import { makeTerminalScrollbackSnapshotRef } from '../../src/main/terminal-scrollback-snapshots' +import { + getProfileTerminalScrollbackSnapshotRoot, + getTerminalScrollbackSnapshotPath, + makeTerminalScrollbackSnapshotRef +} from '../../src/main/terminal-scrollback-snapshots' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../src/shared/terminal-scrollback-limits' +import { getOrcaProfileDataFile } from '../../src/shared/profile-state-storage-paths' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { getOrcadSshDeployment } from '../../src/shared/runtime-environments' import { toSshExecutionHostId } from '../../src/shared/execution-host' import { toAppSshPtyId } from '../../src/shared/ssh-pty-id' @@ -102,10 +110,23 @@ function launchUpgraded(session: ReturnType) { }) } +function writeOverLimitScrollbackSnapshot(userDataDir: string, ref: string): void { + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot( + getOrcaProfileDataFile(DEFAULT_LOCAL_ORCA_PROFILE_ID, userDataDir) + ) + const path = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot }) + if (!path) { + throw new Error(`Expected a valid scrollback ref, got ${ref}`) + } + mkdirSync(snapshotRoot, { recursive: true }) + writeFileSync(path, '') + truncateSync(path, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1) +} + async function verifyRunningRelayUpgrade( testRepoPath: string, testInfo: TestInfo, - missingScrollback = false + unreadableScrollback = false ): Promise { test.setTimeout(25 * 60_000) host = startDockerSshHostTarget(testInfo) @@ -140,7 +161,10 @@ async function verifyRunningRelayUpgrade( { targetId, relayPtyId: ours.relayPtyId } ) const terminalTabId = seeded.terminalTabId! - if (missingScrollback) { + if (unreadableScrollback) { + const ref = makeTerminalScrollbackSnapshotRef(terminalTabId, seeded.terminalLeafId) + // A missing file is no history at all; an over-limit one is saved history Orca cannot read. + writeOverLimitScrollbackSnapshot(session.userDataDir, ref) mutateStoppedProfileState(session.userDataDir, (state) => { const partitions = z .record(z.string(), workspaceSessionStateSchema) @@ -150,12 +174,7 @@ async function verifyRunningRelayUpgrade( if (!layout) { throw new Error('Expected the released terminal layout') } - layout.scrollbackRefsByLeafId = { - [seeded.terminalLeafId]: makeTerminalScrollbackSnapshotRef( - terminalTabId, - seeded.terminalLeafId - ) - } + layout.scrollbackRefsByLeafId = { [seeded.terminalLeafId]: ref } state.workspaceSessionsByHostId = partitions state.sshRemotePtyLeases = [ ...z.array(z.unknown()).parse(state.sshRemotePtyLeases), @@ -193,7 +212,7 @@ async function verifyRunningRelayUpgrade( } ) - if (missingScrollback) { + if (unreadableScrollback) { await expect .poll(() => managedServer(page, targetId), { timeout: 120_000 }) .toMatchObject({ kind: 'relay', reason: 'refused', canLeaveBehind: true })