diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 69f11298965..ece83ba8055 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -103,6 +103,18 @@ export function resolvePtyShellPath(env: Record): string { return env.SHELL || process.env.SHELL || '/bin/zsh' } +/** + * The shell a Unix pane asks for, before the executable fallback: the pane's own override, the + * host's default-shell setting, then SHELL. + */ +export function resolveUnixPaneShellPath( + env: Record, + shellOverride: string | undefined, + defaultShell: string | undefined +): string { + return shellOverride || defaultShell?.trim() || resolvePtyShellPath(env) +} + export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean { const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() // Why fish: markerless, its startup command is written before fish's reader owns diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index a56b3b42ab3..8dae0166b28 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -281,6 +281,10 @@ async function startOrcadRuntime( // Why before the RPC server binds: like `--serve`, the first client must find a ready graph. const { publishHeadlessRuntimeGraph } = await import('../runtime/headless-runtime-graph') publishHeadlessRuntimeGraph(runtime) + // Store load skipped moved shell history: only the runtime can tell which worktrees run a shell. + void runtime.installPendingOrcadMigrationShellHistory().catch((error: unknown) => { + console.error('[orcad-migration] installing pending shell history failed:', error) + }) const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 787d83de9f0..20f905a9a37 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -130,6 +130,7 @@ vi.mock('../runtime/orca-runtime', () => ({ restoreHostAccountLaunchEnv() {} async refreshRestoredOrchestrationAuthority() {} async reconcileLegacyWorkerTerminals() {} + async installPendingOrcadMigrationShellHistory() {} async stopLegacyWorkerTerminalRecovery() {} async closeNotificationDismissals() {} syncWindowGraph() {} diff --git a/src/main/persistence-orcad-migration-blobs.test.ts b/src/main/persistence-orcad-migration-blobs.test.ts index 0d95320d2bd..1b7ac054de3 100644 --- a/src/main/persistence-orcad-migration-blobs.test.ts +++ b/src/main/persistence-orcad-migration-blobs.test.ts @@ -19,10 +19,11 @@ vi.mock('electron', () => ({ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) -const bytes = Buffer.from('history line one\nhistory line two\n') +// A kind with no installer yet, so the channel's own behaviour is all that runs. +const bytes = Buffer.from('session record one\nsession record two\n') const blob: OrcadMigrationBlobDescriptor = { - blobKind: 'shell-history', - key: `${'d'.repeat(16)}-zsh`, + blobKind: 'agent-session-records', + key: 'session-kept', sha256: createHash('sha256').update(bytes).digest('hex'), byteLength: bytes.length } @@ -64,7 +65,7 @@ describe('orcad migration blobs through the store', () => { blobUploads: [{ ...blob, receivedBytes: 8 }] }) expect(() => restarted.commitStagedOrcadMigrationCatalog(input)).toThrow( - `orcad_migration_blob_incomplete:shell-history:${blob.key}` + `orcad_migration_blob_incomplete:agent-session-records:${blob.key}` ) expect(restarted.getOrcadMigrationCatalogState(input).state).toBe('staged') @@ -91,7 +92,7 @@ describe('orcad migration blobs through the store', () => { }) ).toThrow('orcad_migration_blob_set_mismatch:agent-session-records:session-lost') expect(() => store.commitStagedOrcadMigrationCatalog(input, { expectedBlobs: [] })).toThrow( - `orcad_migration_blob_set_mismatch:shell-history:${blob.key}` + `orcad_migration_blob_set_mismatch:agent-session-records:${blob.key}` ) expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') expect(store.commitStagedOrcadMigrationCatalog(input, { expectedBlobs: [blob] }).state).toBe( diff --git a/src/main/persistence-orcad-migration-shell-history-fixture.ts b/src/main/persistence-orcad-migration-shell-history-fixture.ts new file mode 100644 index 00000000000..404f7b245ef --- /dev/null +++ b/src/main/persistence-orcad-migration-shell-history-fixture.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { OrcadMigrationBlobDescriptor } from '../shared/orcad-migration-blob-transfer' +import type { OrcadMigrationManifest } from '../shared/orcad-migration-manifest' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' +import { buildWslMoveShellHistoryBlobs } from './persistence/moving-wsl-projects/wsl-move-shell-history-blobs' +import { commitStagedOrcadMigrationCatalogDurably } from './runtime/orcad-migration-catalog-import' +import { getHistoryRoot } from './terminal-history-paths' +import { hashWorktreeId } from './terminal-history-id' +import { FOLDER_WORKSPACE } from './persistence-orcad-migration-catalog-fixture' +import { testState, type createStore } from './persistence-test-harness' + +type Store = ReturnType +export type EncodedBlob = { descriptor: OrcadMigrationBlobDescriptor; bytes: Buffer } + +export const FOLDER_KEY = folderWorkspaceKey(FOLDER_WORKSPACE.id) +export const ZSH = ': 1700000000:0;make test\n' +export const BASH = 'git status\n' + +// What the desktop sends for the moved worktree: its source ids already equal the host's here. +export async function encodedHistories( + desktopHistory: string, + files: { worktreeId: string; file: string; content: string }[] +): Promise { + for (const { worktreeId, file, content } of files) { + const directory = join(desktopHistory, hashWorktreeId(worktreeId)) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, file), content) + } + const ids = [...new Set(files.map((entry) => entry.worktreeId))] + const blobs = await buildWslMoveShellHistoryBlobs({ + distro: 'Ubuntu', + historyRoot: desktopHistory, + worktrees: ids.map((id) => ({ sourceWorktreeId: id, destinationWorktreeId: id })) + }) + return Promise.all( + blobs.descriptors.map(async (descriptor) => ({ + descriptor, + bytes: await blobs.source.readChunk(descriptor, 0, descriptor.byteLength) + })) + ) +} + +export function rawBlob(key: string, bytes: Buffer): EncodedBlob { + return { + bytes, + descriptor: { + blobKind: 'shell-history', + key, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + } + } +} + +export function stage(store: Store, input: OrcadMigrationManifest, blobs: EncodedBlob[]): void { + store.stageOrcadMigrationCatalog(input) + for (const blob of blobs) { + store.stageOrcadMigrationBlobChunk({ + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + blob: blob.descriptor, + offset: 0, + bytesBase64: blob.bytes.toString('base64') + }) + } +} + +export function receivedPath(input: OrcadMigrationManifest, key: string): string { + return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { + blobKind: 'shell-history', + key + }) +} + +export function hostHistoryDirectory(worktreeId: string): string { + return join(getHistoryRoot(), hashWorktreeId(worktreeId)) +} + +export function hostHistory(worktreeId: string, file: string): string | null { + const path = join(hostHistoryDirectory(worktreeId), file) + return existsSync(path) ? readFileSync(path, 'utf8') : null +} + +/** A runtime's live-shell check on a host where no shell runs. */ +export const noLiveShells = (): boolean => false + +export async function commitDurably(store: Store, input: OrcadMigrationManifest): Promise { + const result = await commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + worktreeHasLiveShells: noLiveShells, + onDurableCommit: () => {} + }) + return result.state +} diff --git a/src/main/persistence-orcad-migration-shell-history-install-safety.test.ts b/src/main/persistence-orcad-migration-shell-history-install-safety.test.ts new file mode 100644 index 00000000000..afcf6c5b755 --- /dev/null +++ b/src/main/persistence-orcad-migration-shell-history-install-safety.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { commitStagedOrcadMigrationCatalogDurably } from './runtime/orcad-migration-catalog-import' +import { readHistoryMeta } from './terminal-history' +import { getHistoryRoot } from './terminal-history-paths' +import { DORMANT_WORKTREE_ID, manifest } from './persistence-orcad-migration-catalog-fixture' +import { + ZSH, + encodedHistories, + hostHistory, + hostHistoryDirectory, + noLiveShells, + receivedPath, + stage +} from './persistence-orcad-migration-shell-history-fixture' +import { createStore, testState } from './persistence-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +let desktopHistory: string + +function zshHostStore(): ReturnType { + return createStore({ paneShellPath: () => '/usr/bin/zsh' }) +} + +async function stageZsh( + store: ReturnType, + content: string +): Promise<{ input: ReturnType; key: string; bytes: Buffer }> { + const input = manifest() + const [blob] = await encodedHistories(desktopHistory, [ + { worktreeId: DORMANT_WORKTREE_ID, file: 'zsh_history', content } + ]) + stage(store, input, [blob]) + return { input, key: blob.descriptor.key, bytes: blob.bytes } +} + +describe('installing moved shell history safely', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-history-safety-')) + desktopHistory = mkdtempSync(join(tmpdir(), 'orca-desktop-history-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + rmSync(desktopHistory, { recursive: true, force: true }) + rmSync(getHistoryRoot(), { recursive: true, force: true }) + }) + + it('waits while a shell runs in the worktree and installs once the last one exits', async () => { + const store = zshHostStore() + const { input, key } = await stageZsh(store, ZSH) + let live = true + const worktreeHasLiveShells = (worktreeId: string): boolean => + live && worktreeId === DORMANT_WORKTREE_ID + + const result = await commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + worktreeHasLiveShells, + onDurableCommit: () => {} + }) + + expect(result.state).toBe('committed') + expect(existsSync(hostHistoryDirectory(DORMANT_WORKTREE_ID))).toBe(false) + expect(existsSync(receivedPath(input, key))).toBe(true) + + // An exit in another worktree changes nothing. + store.installDeferredOrcadMigrationShellHistory('repo-other::/srv/other', { + worktreeHasLiveShells + }) + expect(existsSync(receivedPath(input, key))).toBe(true) + + live = false + store.installDeferredOrcadMigrationShellHistory(DORMANT_WORKTREE_ID, { worktreeHasLiveShells }) + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + expect(existsSync(receivedPath(input, key))).toBe(false) + }) + + it('records the installed blob, so a retry after a shell trimmed the file never re-prepends', async () => { + const store = zshHostStore() + const { input, key, bytes } = await stageZsh(store, ZSH) + store.commitStagedOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + store.installOrcadMigrationReceivedBlobs(input, { worktreeHasLiveShells: noLiveShells }) + const directory = hostHistoryDirectory(DORMANT_WORKTREE_ID) + expect(readHistoryMeta(directory)).toMatchObject({ + worktreeId: DORMANT_WORKTREE_ID, + installedMigrationBlobs: [{ shell: 'zsh' }] + }) + + // HISTSIZE trimmed the file, then a crash left the received blob behind. + writeFileSync(join(directory, 'zsh_history'), ': 9:0;newest\n') + writeFileSync(receivedPath(input, key), bytes) + // A pane shell that changed since must not install it into the other file either. + createStore({ paneShellPath: () => '/bin/bash' }).installPendingOrcadMigrationReceivedBlobs({ + worktreeHasLiveShells: noLiveShells + }) + + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(': 9:0;newest\n') + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + expect(existsSync(receivedPath(input, key))).toBe(false) + }) + + it('ends the moved history with a newline before it lands', async () => { + const store = zshHostStore() + const { input } = await stageZsh(store, ': 1:0;no newline') + store.commitStagedOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + store.installOrcadMigrationReceivedBlobs(input, { worktreeHasLiveShells: noLiveShells }) + + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(': 1:0;no newline\n') + }) + + it.skipIf(process.platform === 'win32')( + 'writes through a symlinked history file and keeps the link', + async () => { + const store = zshHostStore() + const { input } = await stageZsh(store, ZSH) + const target = join(testState.dir, 'dotfiles-zsh_history') + writeFileSync(target, ': 2:0;host\n') + const directory = hostHistoryDirectory(DORMANT_WORKTREE_ID) + mkdirSync(directory, { recursive: true }) + const link = join(directory, 'zsh_history') + symlinkSync(target, link) + + store.commitStagedOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + store.installOrcadMigrationReceivedBlobs(input, { worktreeHasLiveShells: noLiveShells }) + + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe(`${ZSH}: 2:0;host\n`) + } + ) +}) diff --git a/src/main/persistence-orcad-migration-shell-history-login-shell.test.ts b/src/main/persistence-orcad-migration-shell-history-login-shell.test.ts new file mode 100644 index 00000000000..e570dd691d1 --- /dev/null +++ b/src/main/persistence-orcad-migration-shell-history-login-shell.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { injectHistoryEnv } from './terminal-history' +import { getHistoryRoot } from './terminal-history-paths' +import { DORMANT_WORKTREE_ID, manifest } from './persistence-orcad-migration-catalog-fixture' +import { + BASH, + ZSH, + commitDurably, + encodedHistories, + hostHistory, + hostHistoryDirectory, + stage +} from './persistence-orcad-migration-shell-history-fixture' +import { createStore, testState } from './persistence-test-harness' + +const { missingShells, presentShells } = vi.hoisted(() => ({ + missingShells: new Set(), + presentShells: new Set() +})) + +// Lets cases hide or provide shells for the real pane resolution; every other path is the real fs. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: (path: Parameters[0]) => + presentShells.has(String(path)) || + (!missingShells.has(String(path)) && actual.existsSync(path)), + accessSync: (...args: Parameters) => { + if (!presentShells.has(String(args[0]))) { + actual.accessSync(...args) + } + } + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +let desktopHistory: string + +// The WSL bridge's lone `bash_history`, written by whatever the distro's login shell is. +async function moveLoneBridgeHistory( + content: string, + paneShellPath?: () => string, + terminalDefaultShell?: string +): Promise { + const input = manifest() + const store = createStore(paneShellPath ? { paneShellPath } : {}) + if (terminalDefaultShell) { + store.updateSettings({ terminalDefaultShell }) + } + stage( + store, + input, + await encodedHistories(desktopHistory, [ + { worktreeId: DORMANT_WORKTREE_ID, file: 'bash_history', content } + ]) + ) + expect(await commitDurably(store, input)).toBe('committed') +} + +describe('moved WSL history lands under the shell a host pane runs', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-login-shell-')) + desktopHistory = mkdtempSync(join(tmpdir(), 'orca-desktop-history-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + rmSync(desktopHistory, { recursive: true, force: true }) + rmSync(getHistoryRoot(), { recursive: true, force: true }) + missingShells.clear() + presentShells.clear() + vi.unstubAllEnvs() + }) + + it('a zsh pane gets zsh_history, the HISTFILE a new zsh pane reads', async () => { + await moveLoneBridgeHistory(ZSH, () => '/usr/bin/zsh') + + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + const env: Record = {} + const injected = injectHistoryEnv(env, DORMANT_WORKTREE_ID, '/usr/bin/zsh', '/srv/x') + expect(injected.histFile).toBe(join(hostHistoryDirectory(DORMANT_WORKTREE_ID), 'zsh_history')) + }) + + it('a bash pane keeps bash_history', async () => { + await moveLoneBridgeHistory(BASH, () => '/bin/bash') + + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBeNull() + }) + + it.each(['/usr/bin/fish', '/usr/local/bin/nu', ''])( + 'any other pane shell (%j) keeps bash_history', + async (shell) => { + await moveLoneBridgeHistory(BASH, () => shell) + + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBeNull() + } + ) + + it.skipIf(process.platform === 'win32')( + 'a configured zsh that is not executable falls back as the spawn does, to bash', + async () => { + // Real pane resolution: SHELL names zsh, but no zsh exists, so the spawn falls back to bash. + vi.stubEnv('SHELL', '/usr/bin/zsh') + missingShells.add('/usr/bin/zsh') + missingShells.add('/bin/zsh') + await moveLoneBridgeHistory(BASH) + + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBeNull() + } + ) + + it.skipIf(process.platform === 'win32')( + "the host's default-shell setting wins over SHELL, as it does for a new pane", + async () => { + vi.stubEnv('SHELL', '/bin/bash') + presentShells.add('/usr/bin/zsh') + await moveLoneBridgeHistory(ZSH, undefined, '/usr/bin/zsh') + + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + } + ) + + it('a worktree that sent both files keeps their own names', async () => { + const input = manifest() + const store = createStore({ paneShellPath: () => '/usr/bin/zsh' }) + stage( + store, + input, + await encodedHistories(desktopHistory, [ + { worktreeId: DORMANT_WORKTREE_ID, file: 'zsh_history', content: ZSH }, + { worktreeId: DORMANT_WORKTREE_ID, file: 'bash_history', content: BASH } + ]) + ) + expect(await commitDurably(store, input)).toBe('committed') + + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + }) +}) diff --git a/src/main/persistence-orcad-migration-shell-history.test.ts b/src/main/persistence-orcad-migration-shell-history.test.ts new file mode 100644 index 00000000000..1bfa3846a93 --- /dev/null +++ b/src/main/persistence-orcad-migration-shell-history.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { encodeOrcadMigrationShellHistoryHeader } from './persistence/migrating-orcad-catalog/orcad-migration-shell-history-format' +import { getOrcadMigrationCatalogState } from './runtime/orcad-migration-catalog-import' +import { injectHistoryEnv, readHistoryMeta } from './terminal-history' +import { getHistoryRoot } from './terminal-history-paths' +import { hashWorktreeId } from './terminal-history-id' +import { DORMANT_WORKTREE_ID, manifest } from './persistence-orcad-migration-catalog-fixture' +import { + BASH, + FOLDER_KEY, + ZSH, + commitDurably, + encodedHistories as encodeFrom, + hostHistory, + hostHistoryDirectory, + noLiveShells, + rawBlob, + receivedPath, + stage, + type EncodedBlob +} from './persistence-orcad-migration-shell-history-fixture' +import { createStore, testState } from './persistence-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +let desktopHistory: string + +// A lone WSL bash history follows the host pane's shell; these cases pin it to bash. +function bashHostStore(): ReturnType { + return createStore({ paneShellPath: () => '/bin/bash' }) +} + +function encodedHistories( + files: { worktreeId: string; file: string; content: string }[] +): Promise { + return encodeFrom(desktopHistory, files) +} + +describe('orcad migration shell history through the store', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-shell-history-')) + desktopHistory = mkdtempSync(join(tmpdir(), 'orca-desktop-history-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + rmSync(desktopHistory, { recursive: true, force: true }) + rmSync(getHistoryRoot(), { recursive: true, force: true }) + }) + + it('installs each history and its meta.json where a new pane in that worktree reads it', async () => { + const input = manifest() + const store = bashHostStore() + const blobs = await encodedHistories([ + { worktreeId: DORMANT_WORKTREE_ID, file: 'zsh_history', content: ZSH }, + { worktreeId: DORMANT_WORKTREE_ID, file: 'bash_history', content: BASH }, + { worktreeId: FOLDER_KEY, file: 'bash_history', content: 'ls\n' } + ]) + stage(store, input, blobs) + + expect(await commitDurably(store, input)).toBe('committed') + + for (const blob of blobs) { + expect(existsSync(receivedPath(input, blob.descriptor.key))).toBe(false) + } + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + expect(hostHistory(FOLDER_KEY, 'bash_history')).toBe('ls\n') + const directory = hostHistoryDirectory(DORMANT_WORKTREE_ID) + expect(readHistoryMeta(directory)?.worktreeId).toBe(DORMANT_WORKTREE_ID) + if (process.platform !== 'win32') { + expect(statSync(directory).mode & 0o777).toBe(0o700) + expect(statSync(join(directory, 'zsh_history')).mode & 0o777).toBe(0o600) + } + + // The HISTFILE a zsh and a bash pane in the moved worktree get on this host. + for (const [shell, file] of [ + ['/bin/zsh', 'zsh_history'], + ['/bin/bash', 'bash_history'] + ]) { + const env: Record = {} + const injected = injectHistoryEnv(env, DORMANT_WORKTREE_ID, shell, '/srv/repo-1-worktree') + expect(injected.histFile).toBe(join(directory, file)) + } + expect(readHistoryMeta(directory)?.worktreeId).toBe(DORMANT_WORKTREE_ID) + }) + + it('refuses before commit a history whose worktree the catalog does not move', async () => { + const input = manifest() + const store = bashHostStore() + stage( + store, + input, + await encodedHistories([ + { worktreeId: 'repo-other::/srv/other', file: 'zsh_history', content: ZSH } + ]) + ) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_blob_content_invalid:shell-history:orcad_migration_shell_history_worktree_not_migrated' + ) + expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') + expect(existsSync(getHistoryRoot())).toBe(false) + }) + + it('refuses before commit a header that does not match its key, an empty history, and fish', () => { + const header = encodeOrcadMigrationShellHistoryHeader(DORMANT_WORKTREE_ID) + const hash = hashWorktreeId(DORMANT_WORKTREE_ID) + const cases: [EncodedBlob, string][] = [ + [ + rawBlob(`${hashWorktreeId(FOLDER_KEY)}-zsh`, Buffer.concat([header, Buffer.from(ZSH)])), + 'orcad_migration_shell_history_key_mismatch' + ], + [rawBlob(`${hash}-zsh`, header), 'orcad_migration_shell_history_empty'], + [ + rawBlob(`${hash}-fish`, Buffer.concat([header, Buffer.from('- cmd: ls\n')])), + 'orcad_migration_shell_history_shell_unsupported' + ], + [ + rawBlob(`${hash}-bash`, Buffer.from('no header\n')), + 'orcad_migration_shell_history_header_invalid' + ] + ] + for (const [blob, reason] of cases) { + const input = manifest({ migrationId: `migration-${blob.descriptor.key}` }) + const store = bashHostStore() + stage(store, input, [blob]) + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + `orcad_migration_blob_content_invalid:shell-history:${reason}` + ) + } + }) + + it('keeps existing host history after the moved lines, and is idempotent on a retry', async () => { + const input = manifest() + const store = bashHostStore() + const blobs = await encodedHistories([ + { worktreeId: DORMANT_WORKTREE_ID, file: 'zsh_history', content: ': 1:0;moved' } + ]) + stage(store, input, blobs) + const directory = hostHistoryDirectory(DORMANT_WORKTREE_ID) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'zsh_history'), ': 2:0;host\n') + writeFileSync( + join(directory, 'meta.json'), + JSON.stringify({ worktreeId: DORMANT_WORKTREE_ID, createdAt: 'then' }) + ) + + expect(await commitDurably(store, input)).toBe('committed') + const merged = ': 1:0;moved\n: 2:0;host\n' + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(merged) + expect(readHistoryMeta(directory)?.createdAt).toBe('then') + + // A crash after the install but before the received blob was deleted leaves it in place. + const [blob] = blobs + writeFileSync(receivedPath(input, blob.descriptor.key), blob.bytes) + expect(await commitDurably(bashHostStore(), input)).toBe('committed') + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(merged) + expect(existsSync(receivedPath(input, blob.descriptor.key))).toBe(false) + }) + + it('retries a failed install on a catalog-state query, not at store load', async () => { + const input = manifest() + const store = bashHostStore() + const blobs = await encodedHistories([ + { worktreeId: DORMANT_WORKTREE_ID, file: 'zsh_history', content: ZSH }, + { worktreeId: FOLDER_KEY, file: 'bash_history', content: BASH } + ]) + stage(store, input, blobs) + // A file where the history directory belongs makes that one install fail. + mkdirSync(getHistoryRoot(), { recursive: true }) + writeFileSync(hostHistoryDirectory(DORMANT_WORKTREE_ID), '') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(await commitDurably(store, input)).toBe('committed') + expect(error).toHaveBeenCalledWith( + '[orcad-migration] installing received blobs failed:', + expect.any(Error) + ) + // The other history still installed. + expect(hostHistory(FOLDER_KEY, 'bash_history')).toBe(BASH) + const pendingKey = `${hashWorktreeId(DORMANT_WORKTREE_ID)}-zsh` + expect(existsSync(receivedPath(input, pendingKey))).toBe(true) + + bashHostStore() + expect(existsSync(receivedPath(input, pendingKey))).toBe(true) + error.mockRestore() + + rmSync(hostHistoryDirectory(DORMANT_WORKTREE_ID)) + // Store load cannot see live shells, so history waits for a runtime-driven retry. + bashHostStore() + expect(existsSync(receivedPath(input, pendingKey))).toBe(true) + expect( + getOrcadMigrationCatalogState({ + store, + manifest: input, + worktreeHasLiveShells: noLiveShells + }).state + ).toBe('committed') + expect(existsSync(receivedPath(input, pendingKey))).toBe(false) + expect(hostHistory(DORMANT_WORKTREE_ID, 'zsh_history')).toBe(ZSH) + }) + + it('leaves history to the post-attach pass at store load, which still waits on live shells', async () => { + const input = manifest() + const store = bashHostStore() + const blobs = await encodedHistories([ + { worktreeId: DORMANT_WORKTREE_ID, file: 'bash_history', content: BASH } + ]) + stage(store, input, blobs) + store.commitStagedOrcadMigrationCatalog(input) + await store.flushPendingOrThrowAsync() + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + + const reloaded = bashHostStore() + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + expect(existsSync(receivedPath(input, blobs[0].descriptor.key))).toBe(true) + + reloaded.installPendingOrcadMigrationReceivedBlobs({ worktreeHasLiveShells: () => true }) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBeNull() + + reloaded.installPendingOrcadMigrationReceivedBlobs({ worktreeHasLiveShells: noLiveShells }) + expect(hostHistory(DORMANT_WORKTREE_ID, 'bash_history')).toBe(BASH) + expect(existsSync(receivedPath(input, blobs[0].descriptor.key))).toBe(false) + }) +}) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index ca2ccaacab5..5a9d54a8348 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -86,10 +86,10 @@ export function readPersistedStateJson(path = dataFile(), profileId = 'persisten } /** Create a profile store without rebuilding its large module graph inside each test timeout. */ -export function createStore(): Store { +export function createStore(options: Pick = {}): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return createSqliteTestStore(Store, { dataFile: dataFile() }) + return createSqliteTestStore(Store, { ...options, dataFile: dataFile() }) } /** Leases now arrive only from a relay-era profile, so tests seed them on disk before load. */ diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index e70f3c24da3..e701dcd7c11 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -23,6 +23,7 @@ import type { } from '../scheduling-automations/automation-owner-projection' import type { ProfileStatePersistenceAuthority } from './profile-state-authority' import type { AutomationRun } from '../../../shared/automations-types' +import { resolveNewPaneShellPath } from '../migrating-orcad-catalog/orcad-shell-history-installer' import { OrcadTerminalRestoreHintStore } from '../migrating-orcad-catalog/orcad-terminal-restore-hint-store' export type DurableProfileStateMutation = { @@ -36,6 +37,8 @@ export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority profileStateAuthority?: ProfileStatePersistenceAuthority + /** The shell a new pane on this host runs, given its default-shell setting; tests pass a fake. */ + paneShellPath?: (defaultShell: string | undefined) => string } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -44,6 +47,9 @@ export class StoreRuntimeState { readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority readonly profileStateAuthority: ProfileStatePersistenceAuthority | undefined + readonly paneShellPath: (defaultShell: string | undefined) => string + /** Worktrees whose moved shell history waits for their live shells to exit. */ + readonly deferredShellHistoryWorktreeIds = new Set() automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage @@ -103,6 +109,7 @@ export class StoreRuntimeState { this.dataFile = options.dataFile ?? getDataFile() this.storageAuthority = options.storageAuthority ?? 'desktop' this.profileStateAuthority = options.profileStateAuthority + this.paneShellPath = options.paneShellPath ?? resolveNewPaneShellPath this.staleProfileStateTempCleanup = this.profileStateAuthority ? removeStaleDurableWriteTempFiles(this.dataFile, { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index a228f6305b9..9a231075311 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -126,7 +126,8 @@ export class Store { const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() // Imported snapshots cannot own the live hook server or write their source file. if (!imported) { - // A committed migration whose blob install failed or was cut short is finished here. + // A committed migration whose blob install failed or was cut short is finished here; shell + // history waits for the runtime's post-attach pass, which can see live shells. this.domains.orcadMigrationBlobInstalls.installPendingOrcadMigrationReceivedBlobs() for (const entry of initial?.unboundPaneAliases ?? []) { agentHookServer.registerPaneKeyAlias( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts index 3f3ca260650..56cd41d1426 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts @@ -1,6 +1,7 @@ import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { getHistoryRoot } from '../../terminal-history-paths' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import { findOrcadMigrationImportReceipt } from './orcad-catalog-receipt-ledger' import { installOrcadMigrationReceivedBlobs } from './orcad-migration-blob-installers' @@ -8,10 +9,18 @@ import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-fil type OrcadMigrationBlobInstallRuntime = Pick< StoreRuntimeState, - 'state' | 'orcadMigrationBlobRoot' | 'orcadTerminalRestoreHints' + | 'state' + | 'orcadMigrationBlobRoot' + | 'orcadTerminalRestoreHints' + | 'paneShellPath' + | 'deferredShellHistoryWorktreeIds' > type ClockOptions = { now?: () => Date } +type InstallOptions = ClockOptions & { + /** The runtime's live-shell check; a moved history waits while a shell runs in its worktree. */ + worktreeHasLiveShells?: (worktreeId: string) => boolean +} const orcadMigrationBlobInstallContext = Symbol('OrcadMigrationBlobInstallPersistence') type OrcadMigrationBlobInstallContext = { @@ -28,11 +37,12 @@ export class OrcadMigrationBlobInstallPersistence { /** * Run only once the commit is durable: installing first and then losing the commit would leave - * live hints for a catalog this host never received. A no-op before commit and on repeats. + * live hints or history for a catalog this host never received. A no-op before commit and on + * repeats. */ installOrcadMigrationReceivedBlobs( manifest: OrcadMigrationManifest, - options: ClockOptions = {} + options: InstallOptions = {} ): void { const receipt = findOrcadMigrationImportReceipt( this[orcadMigrationBlobInstallContext].runtime.state, @@ -45,7 +55,7 @@ export class OrcadMigrationBlobInstallPersistence { } /** Finishes installs a crash or failed write left behind; every receipt on disk is durable. */ - installPendingOrcadMigrationReceivedBlobs(options: ClockOptions = {}): void { + installPendingOrcadMigrationReceivedBlobs(options: InstallOptions = {}): void { const { runtime } = this[orcadMigrationBlobInstallContext] for (const receipt of [ ...(runtime.state.orcadMigrationImportReceipts ?? []), @@ -62,6 +72,17 @@ export class OrcadMigrationBlobInstallPersistence { } } + /** Retries a history install that waited on this worktree's shells; a no-op for any other. */ + installDeferredOrcadMigrationShellHistory( + worktreeId: string, + options: InstallOptions = {} + ): void { + const { runtime } = this[orcadMigrationBlobInstallContext] + if (runtime.deferredShellHistoryWorktreeIds.has(worktreeId)) { + this.installPendingOrcadMigrationReceivedBlobs(options) + } + } + /** The moved pane's last folder while its hint is installed for this worktree; never removes it. */ peekOrcadMigrationTerminalRestoreHint( paneKey: string, @@ -87,12 +108,16 @@ export class OrcadMigrationBlobInstallPersistence { function installCommitted( runtime: OrcadMigrationBlobInstallRuntime, migration: OrcadMigrationRequestIdentity, - options: ClockOptions + options: InstallOptions ): void { installOrcadMigrationReceivedBlobs({ migration, root: runtime.orcadMigrationBlobRoot, terminalRestoreHints: runtime.orcadTerminalRestoreHints, + shellHistoryRoot: getHistoryRoot(), + paneShellPath: () => runtime.paneShellPath(runtime.state.settings?.terminalDefaultShell), + worktreeHasLiveShells: options.worktreeHasLiveShells, + deferredShellHistoryWorktreeIds: runtime.deferredShellHistoryWorktreeIds, paneWorktreeId: (paneKey) => findPaneWorktreeId(runtime.state, paneKey), now: clock(options) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts index 7613ced0e72..494e8ff6bc5 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts @@ -21,19 +21,32 @@ import { orcadMigrationStagingKey, type OrcadMigrationRequestIdentity } from './orcad-migration-staged-file' +import { + assertOrcadMigrationShellHistoryValid, + installOrcadMigrationShellHistory +} from './orcad-shell-history-installer' import type { OrcadTerminalRestoreHintPlacement, OrcadTerminalRestoreHintStore } from './orcad-terminal-restore-hint-store' -type OrcadMigrationBlobContentCheck = (bytes: Buffer) => void +type OrcadMigrationStagedBlobFile = { + key: string + path: string + manifest: OrcadMigrationManifest +} + +type OrcadMigrationBlobContentCheck = (file: OrcadMigrationStagedBlobFile) => void const UTF8 = new TextDecoder('utf-8', { fatal: true }) const BLOB_CONTENT_CHECKS: Partial> = { - 'terminal-restore-hints': (bytes) => { - parseOrcadMigrationTerminalRestoreHintsText(decodeUtf8(bytes)) + 'terminal-restore-hints': ({ path }) => { + parseOrcadMigrationTerminalRestoreHintsText(decodeUtf8(readFileSync(path))) + }, + 'shell-history': ({ key, path, manifest }) => { + assertOrcadMigrationShellHistoryValid(key, path, manifest.payload) } } @@ -51,7 +64,7 @@ export function assertOrcadMigrationBlobContentsValid( continue } try { - check(readFileSync(path)) + check({ key: descriptor.key, path, manifest }) } catch (error) { const reason = error instanceof Error ? error.message : String(error) throw new Error(`orcad_migration_blob_content_invalid:${descriptor.blobKind}:${reason}`) @@ -63,6 +76,17 @@ export type OrcadMigrationBlobInstall = { migration: OrcadMigrationRequestIdentity root: string terminalRestoreHints: OrcadTerminalRestoreHintStore + /** This host's `getHistoryRoot()`, where its panes' HISTFILEs live. */ + shellHistoryRoot: string + /** The shell binary a new pane on this host would actually run. */ + paneShellPath: () => string + /** + * The runtime's live-shell check. Without it (store load, before a runtime attached) shell + * history waits: a shell that outlived a restart would overwrite whatever lands under it. + */ + worktreeHasLiveShells?: (worktreeId: string) => boolean + /** Worktrees whose history install waits for their live shells to exit. */ + deferredShellHistoryWorktreeIds: Set /** The worktree that owns the pane on this host, or null when the pane is not here. */ paneWorktreeId: (paneKey: string) => string | null now: number @@ -71,10 +95,37 @@ export type OrcadMigrationBlobInstall = { /** * Idempotent: a blob already installed is only deleted, and the hint store remembers installed * migrations so a retry never revives a hint a pane already took. A write failure keeps the blob - * for the next retry: a commit retry, a catalog-state query, or the next store load. + * for the next retry: a commit retry, a catalog-state query, the runtime's post-attach pass, or + * (for restore hints) the next store load. */ export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobInstall): void { - installTerminalRestoreHints(install) + let firstError: unknown = null + // One kind's failure must not hold back the others; each keeps its own blobs for the retry. + for (const installKind of [installTerminalRestoreHints, installShellHistory]) { + try { + installKind(install) + } catch (error) { + firstError ??= error + } + } + if (firstError !== null) { + throw firstError + } +} + +function installShellHistory(install: OrcadMigrationBlobInstall): void { + if (!install.worktreeHasLiveShells) { + return + } + installOrcadMigrationShellHistory({ + migration: install.migration, + root: install.root, + historyRoot: install.shellHistoryRoot, + paneShellPath: install.paneShellPath, + worktreeHasLiveShells: install.worktreeHasLiveShells, + deferredWorktreeIds: install.deferredShellHistoryWorktreeIds, + now: install.now + }) } function installTerminalRestoreHints(install: OrcadMigrationBlobInstall): void { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts index 06e5be94783..f76dc4ea0a4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts @@ -67,6 +67,15 @@ export function getOrcadMigrationReceivedBlobPath( return RECEIVED_BLOB_DESTINATIONS[descriptor.blobKind](root, manifest, descriptor) } +/** Names in a committed migration's received directory for `blobKind`; the caller checks the key. */ +export function listOrcadMigrationReceivedBlobNames( + root: string, + manifest: OrcadMigrationRequestIdentity, + blobKind: OrcadMigrationBlobKind +): string[] { + return readDirectory(join(receivedBlobsDirectory(root, manifest), blobKind)) +} + export type OrcadMigrationBlobChunkStaging = { /** The manifest the importer holds staged for this migration, if any. */ stagedManifest: OrcadMigrationManifest | null diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-shell-history-format.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-shell-history-format.ts new file mode 100644 index 00000000000..cb8e17132ec --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-shell-history-format.ts @@ -0,0 +1,125 @@ +/** + * The `shell-history` migration blob: one worktree's history file for one shell, keyed + * `-`. The bytes are a one-line JSON header naming + * that worktree id, then the history file verbatim. The header is there because a key's hash + * cannot be reversed, the destination's meta.json needs the id (history GC prunes by it), and the + * catalog does not list every git worktree a terminal ever ran in. + */ +import { z } from 'zod' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../shared/orcad-migration-blob-transfer' +import { hashWorktreeId } from '../../terminal-history-id' + +/** fish keeps its history in the distro's own fish data dir, not Orca's store, so it never moves. */ +export const ORCAD_MIGRATION_SHELL_HISTORY_SHELLS = ['zsh', 'bash'] as const + +export type OrcadMigrationShellHistoryShell = (typeof ORCAD_MIGRATION_SHELL_HISTORY_SHELLS)[number] + +export const ORCAD_MIGRATION_SHELL_HISTORY_VERSION = 1 +/** Bounds the scan for the header's newline; a worktree id is a repo id plus one path. */ +export const ORCAD_MIGRATION_SHELL_HISTORY_HEADER_SCAN_BYTES = 16 * 1024 +const NEWLINE = 0x0a + +const HeaderSchema = z.object({ + version: z.literal(ORCAD_MIGRATION_SHELL_HISTORY_VERSION), + worktreeId: z.string().min(1), + loginShell: z.literal(true).optional() +}) + +export const MAX_ORCAD_MIGRATION_SHELL_HISTORY_BYTES = + ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['shell-history'].maxBytes + +export type OrcadMigrationShellHistoryKey = { + worktreeHash: string + shell: OrcadMigrationShellHistoryShell +} + +export type OrcadMigrationShellHistoryHeader = { + worktreeId: string + /** + * The desktop's WSL bridge names every pane's history `bash_history`, but wsl.exe runs the + * distro's login shell, so the file holds that shell's history; set only on such a bash file + * that has no zsh sibling. + */ + loginShell: boolean + /** Where the history bytes start. */ + byteLength: number +} + +export function orcadMigrationShellHistoryKey( + worktreeId: string, + shell: OrcadMigrationShellHistoryShell +): string { + return `${hashWorktreeId(worktreeId)}-${shell}` +} + +/** The history file a shell keeps in a worktree's history directory (see terminal-history.ts). */ +export function shellHistoryFileName(shell: OrcadMigrationShellHistoryShell): string { + return `${shell}_history` +} + +export function parseOrcadMigrationShellHistoryKey(key: string): OrcadMigrationShellHistoryKey { + if (!ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['shell-history'].key.test(key)) { + throw new Error('orcad_migration_shell_history_key_invalid') + } + const [worktreeHash, shell] = key.split('-') + const supported = ORCAD_MIGRATION_SHELL_HISTORY_SHELLS.find((entry) => entry === shell) + if (!supported) { + throw new Error('orcad_migration_shell_history_shell_unsupported') + } + return { worktreeHash, shell: supported } +} + +export function encodeOrcadMigrationShellHistoryHeader( + worktreeId: string, + options: { loginShell?: boolean } = {} +): Buffer { + const document = { + version: ORCAD_MIGRATION_SHELL_HISTORY_VERSION, + worktreeId, + ...(options.loginShell ? { loginShell: true } : {}) + } + const header = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8') + if (header.length > ORCAD_MIGRATION_SHELL_HISTORY_HEADER_SCAN_BYTES) { + throw new Error('orcad_migration_shell_history_worktree_id_too_long') + } + return header +} + +/** + * Reads only the header from the first bytes of a blob and checks it names the worktree the key + * hashes. `totalBytes` is the whole blob's size, so an empty history is refused without a read. + */ +export function parseOrcadMigrationShellHistoryHeader( + key: string, + head: Buffer, + totalBytes: number +): OrcadMigrationShellHistoryHeader { + const parsedKey = parseOrcadMigrationShellHistoryKey(key) + const end = head.subarray(0, ORCAD_MIGRATION_SHELL_HISTORY_HEADER_SCAN_BYTES).indexOf(NEWLINE) + if (end === -1) { + throw new Error('orcad_migration_shell_history_header_invalid') + } + const { worktreeId, loginShell } = parseHeaderLine(head.subarray(0, end)) + if (hashWorktreeId(worktreeId) !== parsedKey.worktreeHash) { + throw new Error('orcad_migration_shell_history_key_mismatch') + } + const byteLength = end + 1 + if (totalBytes <= byteLength) { + throw new Error('orcad_migration_shell_history_empty') + } + return { worktreeId, loginShell, byteLength } +} + +function parseHeaderLine(line: Buffer): { worktreeId: string; loginShell: boolean } { + try { + const parsed = HeaderSchema.safeParse( + JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line)) + ) + if (parsed.success) { + return { worktreeId: parsed.data.worktreeId, loginShell: parsed.data.loginShell === true } + } + } catch { + // Not UTF-8 JSON; refused below like any other malformed header. + } + throw new Error('orcad_migration_shell_history_header_invalid') +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-shell-history-installer.ts b/src/main/persistence/migrating-orcad-catalog/orcad-shell-history-installer.ts new file mode 100644 index 00000000000..f480d50de1e --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-shell-history-installer.ts @@ -0,0 +1,283 @@ +/** + * Destination half of the `shell-history` blob: a pre-commit check that each file names a worktree + * of the migrated catalog, and the post-commit install into this host's history root at the path + * a zsh/bash pane in that worktree gets as HISTFILE (`//_history`). + */ +import { createHash } from 'node:crypto' +import { + closeSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + readSync, + realpathSync, + rmSync, + statSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../shared/orcad-migration-blob-transfer' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-worktree' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { splitWorktreeId } from '../../../shared/worktree/id' +import { + durableWriteTempPath, + syncDirectoryDurablySync, + writeFileDurableSync +} from '../../durable-file-write' +import { resolveUnixPaneShellPath } from '../../daemon/shell-ready' +import { resolveUnixShellPath } from '../../providers/local-pty-utils' +import { readHistoryMeta, resolveShellKind } from '../../terminal-history' +import { + getOrcadMigrationReceivedBlobPath, + listOrcadMigrationReceivedBlobNames +} from './orcad-migration-blob-staging' +import { + ORCAD_MIGRATION_SHELL_HISTORY_HEADER_SCAN_BYTES, + parseOrcadMigrationShellHistoryHeader, + parseOrcadMigrationShellHistoryKey, + shellHistoryFileName, + type OrcadMigrationShellHistoryShell +} from './orcad-migration-shell-history-format' +import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-file' + +type ShellHistoryCatalog = Pick< + OrcadMigrationManifest['payload'], + 'repositories' | 'projectGroups' | 'folderWorkspaces' +> + +/** Throws unless the staged file is a well-formed history for a worktree this catalog moves. */ +export function assertOrcadMigrationShellHistoryValid( + key: string, + path: string, + catalog: ShellHistoryCatalog +): void { + const { worktreeId } = parseOrcadMigrationShellHistoryHeader( + key, + readHead(path, ORCAD_MIGRATION_SHELL_HISTORY_HEADER_SCAN_BYTES), + statSync(path).size + ) + // Refused, not dropped: only a sender bug names a worktree outside the catalog, and dropping + // the file would lose that history without anyone noticing. + if (!catalogOwnsWorktree(catalog, worktreeId)) { + throw new Error('orcad_migration_shell_history_worktree_not_migrated') + } +} + +export type OrcadShellHistoryInstall = { + migration: OrcadMigrationRequestIdentity + root: string + /** This host's `getHistoryRoot()`. */ + historyRoot: string + /** The shell binary a new pane on this host would actually run; see `resolveNewPaneShellPath`. */ + paneShellPath: () => string + /** True while a shell runs in the worktree. */ + worktreeHasLiveShells: (worktreeId: string) => boolean + /** Worktrees whose install waits for their live shells to exit. */ + deferredWorktreeIds: Set + now: number +} + +/** + * Installs every received history file, then deletes it. Already installed (meta.json records the + * blob) only deletes. A worktree with a live shell keeps its blob: that shell would overwrite the + * file from its own memory on exit. A write failure keeps the blob for the next retry and is + * rethrown once the others had their turn. + */ +export function installOrcadMigrationShellHistory(install: OrcadShellHistoryInstall): void { + let firstError: unknown = null + for (const key of listOrcadMigrationReceivedBlobNames( + install.root, + install.migration, + 'shell-history' + )) { + if (!ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['shell-history'].key.test(key)) { + continue + } + try { + installOne(install, key) + } catch (error) { + firstError ??= error + } + } + if (firstError !== null) { + throw firstError + } +} + +type ReceivedShellHistory = { + worktreeId: string + worktreeHash: string + shell: OrcadMigrationShellHistoryShell + sha256: string + history: Buffer +} + +function installOne(install: OrcadShellHistoryInstall, key: string): void { + const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { + blobKind: 'shell-history', + key + }) + const bytes = readFileSync(path) + let moved: ReceivedShellHistory + try { + const header = parseOrcadMigrationShellHistoryHeader(key, bytes, bytes.length) + const { worktreeHash, shell } = parseOrcadMigrationShellHistoryKey(key) + moved = { + worktreeId: header.worktreeId, + worktreeHash, + shell: header.loginShell ? (paneHistoryShell(install.paneShellPath) ?? shell) : shell, + sha256: createHash('sha256').update(bytes).digest('hex'), + history: withFinalNewline(bytes.subarray(header.byteLength)) + } + } catch (error) { + // The pre-commit check passed these bytes, so only a damaged file lands here. + console.error(`[orcad-migration] dropped unreadable shell history ${key}:`, error) + removeReceived(path) + return + } + const directory = join(install.historyRoot, moved.worktreeHash) + const meta = readHistoryMeta(directory) + // Keyed by sha alone, so a pane shell that changed between attempts cannot install it twice. + if (meta?.installedMigrationBlobs?.some((blob) => blob.sha256 === moved.sha256)) { + removeReceived(path) + return + } + if (install.worktreeHasLiveShells(moved.worktreeId)) { + install.deferredWorktreeIds.add(moved.worktreeId) + return + } + // Owner-only, like the history directories terminal-history.ts creates. + mkdirSync(directory, { recursive: true, mode: 0o700 }) + writeHistoryFile(join(directory, shellHistoryFileName(moved.shell)), moved.history) + writeMeta(directory, moved, install.now) + removeReceived(path) + install.deferredWorktreeIds.delete(moved.worktreeId) +} + +// A history without a final newline would run its last command into the host's next one. +function withFinalNewline(history: Buffer): Buffer { + return history.at(-1) === 0x0a ? history : Buffer.concat([history, Buffer.from('\n')]) +} + +/** + * The host normally has no history for a worktree that just arrived. If it has some, nothing is + * overwritten: the moved history goes first (it is older) and the host's own lines follow, so the + * newest commands stay at the end where shells read them back from. + */ +function writeHistoryFile(path: string, moved: Buffer): void { + const existing = readExisting(path) + // A crash between this write and the meta.json marker leaves the moved bytes already in front. + if (existing?.subarray(0, moved.length).equals(moved)) { + return + } + const content = existing ? Buffer.concat([moved, existing]) : moved + const target = writeTarget(path) + writeFileDurableSync(durableWriteTempPath(target), target, content, 0o600) +} + +// A symlinked HISTFILE is written through to its target so the link the user set up survives. +function writeTarget(path: string): string { + try { + return realpathSync(path) + } catch (error) { + if (!isMissing(error)) { + throw error + } + } + try { + return lstatSync(path).isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path + } catch (error) { + if (isMissing(error)) { + return path + } + throw error + } +} + +// GC prunes a history directory by its meta.json worktree id, so it must name the host's id. +function writeMeta(directory: string, moved: ReceivedShellHistory, now: number): void { + const existing = readHistoryMeta(directory) + const path = join(directory, 'meta.json') + const meta = { + ...existing, + worktreeId: moved.worktreeId, + createdAt: existing?.createdAt ?? new Date(now).toISOString(), + installedMigrationBlobs: [ + ...(existing?.installedMigrationBlobs ?? []), + { sha256: moved.sha256, shell: moved.shell } + ] + } + writeFileDurableSync(durableWriteTempPath(path), path, JSON.stringify(meta), 0o600) +} + +/** + * A login-shell history from the WSL bridge (see the header's `loginShell`) lands under the file a + * new pane on this host reads: `zsh_history` or `bash_history` when that pane runs zsh or bash, + * `bash_history` for any other shell. + */ +function paneHistoryShell(paneShellPath: () => string): OrcadMigrationShellHistoryShell | null { + const kind = resolveShellKind(paneShellPath()) + return kind === 'zsh' || kind === 'bash' ? kind : null +} + +/** + * The shell a new pane spawns, resolved as the spawn does: the host's default-shell setting, then + * SHELL, then the first executable fallback. With no executable shell at all, no pane runs here. + */ +export function resolveNewPaneShellPath(defaultShell: string | undefined): string { + try { + return resolveUnixShellPath(resolveUnixPaneShellPath({}, undefined, defaultShell)) + } catch { + return '' + } +} + +function readExisting(path: string): Buffer | null { + try { + return readFileSync(path) + } catch (error) { + if (isMissing(error)) { + return null + } + throw error + } +} + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function removeReceived(path: string): void { + rmSync(path, { force: true }) + syncDirectoryDurablySync(dirname(path)) +} + +function readHead(path: string, maxBytes: number): Buffer { + const fd = openSync(path, 'r') + try { + const buffer = Buffer.alloc(maxBytes) + const bytesRead = readSync(fd, buffer, 0, maxBytes, 0) + return buffer.subarray(0, bytesRead) + } finally { + closeSync(fd) + } +} + +function catalogOwnsWorktree(catalog: ShellHistoryCatalog, worktreeId: string): boolean { + if ( + catalog.folderWorkspaces.some((workspace) => folderWorkspaceKey(workspace.id) === worktreeId) + ) { + return true + } + const parsed = splitWorktreeId(worktreeId) + if (!parsed?.repoId || !parsed.worktreePath) { + return false + } + const projectGroupId = projectGroupIdFromRepoId(parsed.repoId) + return projectGroupId + ? catalog.projectGroups.some((group) => group.id === projectGroupId) + : catalog.repositories.some((repo) => repo.id === parsed.repoId) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.test.ts new file mode 100644 index 00000000000..7c2c325c6cc --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.test.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto' +import { + appendFileSync, + mkdirSync, + mkdtempSync, + rmSync, + truncateSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + parseOrcadMigrationBlobDescriptor, + type OrcadMigrationBlobDescriptor +} from '../../../shared/orcad-migration-blob-transfer' +import { rewriteOrcadMigrationWorktreeId } from '../../ssh/orcad-migration-path-rewrite' +import { hashWorktreeId } from '../../terminal-history-id' +import { parseOrcadMigrationShellHistoryHeader } from '../migrating-orcad-catalog/orcad-migration-shell-history-format' +import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter' +import { + buildWslMoveShellHistoryBlobs, + type WslMoveShellHistoryBlobs +} from './wsl-move-shell-history-blobs' + +const SOURCE_ID = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const DESTINATION_ID = rewriteOrcadMigrationWorktreeId( + SOURCE_ID, + createUncToLinuxPathRewriter('Ubuntu') +) +const SOURCE_FOLDER_ID = 'folder:workspace-1' + +let root: string + +function writeHistory(worktreeId: string, file: string, content: string | Buffer): string { + const directory = join(root, hashWorktreeId(worktreeId)) + mkdirSync(directory, { recursive: true }) + const path = join(directory, file) + writeFileSync(path, content) + return path +} + +function build( + worktrees = [{ sourceWorktreeId: SOURCE_ID, destinationWorktreeId: DESTINATION_ID }] +): Promise { + return buildWslMoveShellHistoryBlobs({ distro: 'Ubuntu', worktrees, historyRoot: root }) +} + +async function readAll( + blobs: WslMoveShellHistoryBlobs, + descriptor: OrcadMigrationBlobDescriptor, + chunk: number +): Promise { + const parts: Buffer[] = [] + for (let offset = 0; offset < descriptor.byteLength; offset += chunk) { + parts.push(await blobs.source.readChunk(descriptor, offset, chunk)) + } + return Buffer.concat(parts) +} + +describe('WSL move shell history blobs', () => { + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-wsl-move-history-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('keys each zsh/bash history by the host worktree id and carries that id in the header', async () => { + expect(DESTINATION_ID).toBe('repo-a::/home/dev/app') + writeHistory(SOURCE_ID, 'zsh_history', ': 1700000000:0;ls\n') + writeHistory(SOURCE_ID, 'bash_history', 'git status\n') + + const blobs = await build() + + const keys = blobs.descriptors.map((descriptor) => descriptor.key) + const hash = hashWorktreeId(DESTINATION_ID) + expect(keys).toEqual([`${hash}-zsh`, `${hash}-bash`]) + expect(keys.some((key) => key.startsWith(hashWorktreeId(SOURCE_ID)))).toBe(false) + for (const descriptor of blobs.descriptors) { + expect(parseOrcadMigrationBlobDescriptor(descriptor)).toEqual(descriptor) + // Read in pieces that straddle the header boundary. + const bytes = await readAll(blobs, descriptor, 7) + expect(bytes.length).toBe(descriptor.byteLength) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(descriptor.sha256) + const header = parseOrcadMigrationShellHistoryHeader(descriptor.key, bytes, bytes.length) + expect(header.worktreeId).toBe(DESTINATION_ID) + } + const zsh = await readAll(blobs, blobs.descriptors[0], 1024) + expect(zsh.toString('utf8')).toBe( + `{"version":1,"worktreeId":"repo-a::/home/dev/app"}\n: 1700000000:0;ls\n` + ) + }) + + it('marks a bash history as the login shell only when no zsh history came with it', async () => { + writeHistory(SOURCE_ID, 'zsh_history', ': 1:0;ls\n') + writeHistory(SOURCE_ID, 'bash_history', 'ls\n') + writeHistory(SOURCE_FOLDER_ID, 'bash_history', 'make\n') + + const blobs = await build([ + { sourceWorktreeId: SOURCE_ID, destinationWorktreeId: DESTINATION_ID }, + { sourceWorktreeId: SOURCE_FOLDER_ID, destinationWorktreeId: SOURCE_FOLDER_ID } + ]) + + const loginShell = await Promise.all( + blobs.descriptors.map(async (descriptor) => { + const bytes = await readAll(blobs, descriptor, descriptor.byteLength) + return parseOrcadMigrationShellHistoryHeader(descriptor.key, bytes, bytes.length).loginShell + }) + ) + expect(loginShell).toEqual([false, false, true]) + }) + + it('skips missing and empty files and never sends fish', async () => { + writeHistory(SOURCE_ID, 'zsh_history', '') + writeHistory(SOURCE_ID, 'fish_history', '- cmd: ls\n') + writeHistory(SOURCE_FOLDER_ID, 'bash_history', 'make\n') + + const blobs = await build([ + { sourceWorktreeId: SOURCE_ID, destinationWorktreeId: DESTINATION_ID }, + { sourceWorktreeId: SOURCE_FOLDER_ID, destinationWorktreeId: SOURCE_FOLDER_ID }, + { sourceWorktreeId: 'repo-a::/never-opened', destinationWorktreeId: 'repo-a::/never-opened' } + ]) + + expect(blobs.descriptors.map((descriptor) => descriptor.key)).toEqual([ + `${hashWorktreeId(SOURCE_FOLDER_ID)}-bash` + ]) + }) + + it('blocks the move on a history over the per-blob cap instead of truncating it', async () => { + const path = writeHistory(SOURCE_ID, 'bash_history', '') + truncateSync(path, 64 * 1024 * 1024) + + await expect(build()).rejects.toThrow( + `orcad_migration_shell_history_too_large:${hashWorktreeId(DESTINATION_ID)}-bash` + ) + }) + + it('merges the \\\\wsl$ and \\\\wsl.localhost histories of one worktree oldest first', async () => { + const legacyId = SOURCE_ID.replace('wsl.localhost', 'wsl$') + const legacy = writeHistory(legacyId, 'bash_history', 'old one\nold two') + const current = writeHistory(SOURCE_ID, 'bash_history', 'new one\n') + utimesSync(legacy, 1_000, 1_000) + utimesSync(current, 2_000, 2_000) + + // The newer id is listed first; the merge still puts its commands last. + const blobs = await build([ + { sourceWorktreeId: SOURCE_ID, destinationWorktreeId: DESTINATION_ID }, + { sourceWorktreeId: legacyId, destinationWorktreeId: DESTINATION_ID } + ]) + + expect(blobs.descriptors.map((descriptor) => descriptor.key)).toEqual([ + `${hashWorktreeId(DESTINATION_ID)}-bash` + ]) + const [descriptor] = blobs.descriptors + const bytes = await readAll(blobs, descriptor, 5) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(descriptor.sha256) + const header = parseOrcadMigrationShellHistoryHeader(descriptor.key, bytes, bytes.length) + expect(bytes.subarray(header.byteLength).toString('utf8')).toBe('old one\nold two\nnew one\n') + }) + + it('refuses a read once the file changed after its digest was taken', async () => { + const path = writeHistory(SOURCE_ID, 'bash_history', 'one\n') + const blobs = await build() + const [descriptor] = blobs.descriptors + appendFileSync(path, 'two\n') + + await expect(blobs.source.readChunk(descriptor, 0, 1024)).rejects.toThrow( + 'orcad_migration_source_blob_changed:shell-history' + ) + await expect( + blobs.source.readChunk({ ...descriptor, sha256: 'f'.repeat(64) }, 0, 1) + ).rejects.toThrow('orcad_migration_source_blob_changed:shell-history') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.ts b/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.ts new file mode 100644 index 00000000000..dd08768c5d9 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-shell-history-blobs.ts @@ -0,0 +1,251 @@ +/** + * The moved worktrees' zsh/bash history files (under the desktop's per-distro WSL history root) as + * `shell-history` migration blobs, keyed by the worktree id the destination host will use. + */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { + assertOrcadMigrationBlobSet, + orcadMigrationBlobIdentity, + sameOrcadMigrationBlob, + type OrcadMigrationBlobDescriptor +} from '../../../shared/orcad-migration-blob-transfer' +import type { OrcadMigrationBlobSource } from '../../ssh/orcad-migration-blob-coordinator' +import { getHistoryRootWsl } from '../../terminal-history-paths' +import { hashWorktreeId } from '../../terminal-history-id' +import { + MAX_ORCAD_MIGRATION_SHELL_HISTORY_BYTES, + ORCAD_MIGRATION_SHELL_HISTORY_SHELLS, + encodeOrcadMigrationShellHistoryHeader, + orcadMigrationShellHistoryKey, + shellHistoryFileName +} from '../migrating-orcad-catalog/orcad-migration-shell-history-format' + +export type WslMoveShellHistoryWorktree = { + /** The id the desktop keyed history by: the UNC worktree id. */ + sourceWorktreeId: string + /** The same worktree's id on the host, after the mover's path rewrite. */ + destinationWorktreeId: string +} + +export type WslMoveShellHistoryRequest = { + distro: string + worktrees: readonly WslMoveShellHistoryWorktree[] + /** Defaults to the desktop's history root for `distro`. */ + historyRoot?: string +} + +export type WslMoveShellHistoryBlobs = { + descriptors: OrcadMigrationBlobDescriptor[] + source: OrcadMigrationBlobSource +} + +type DesktopHistoryFile = { path: string; fileBytes: number } + +/** A blob's bytes in order: the header, then one or more desktop history files. */ +type ShellHistorySegment = { bytes: Buffer } | DesktopHistoryFile + +type ShellHistoryBlob = { + descriptor: OrcadMigrationBlobDescriptor + segments: ShellHistorySegment[] +} + +const NEWLINE = 0x0a + +/** + * Missing and empty files send nothing. A file over the per-blob cap blocks the move: history is + * never truncated. The source refuses a read once a file's size moved, so a shell that wrote after + * quiesce fails the transfer instead of sending bytes the digest does not cover. + */ +export async function buildWslMoveShellHistoryBlobs( + request: WslMoveShellHistoryRequest +): Promise { + const root = request.historyRoot ?? getHistoryRootWsl(request.distro) + const blobs: ShellHistoryBlob[] = [] + for (const [destinationWorktreeId, sourceWorktreeIds] of groupByDestination(request.worktrees)) { + let zshSent = false + for (const shell of ORCAD_MIGRATION_SHELL_HISTORY_SHELLS) { + const files = await desktopHistoryFiles(root, sourceWorktreeIds, shellHistoryFileName(shell)) + if (files.length === 0) { + continue + } + const key = orcadMigrationShellHistoryKey(destinationWorktreeId, shell) + // zsh is listed first, so a bash file without a zsh sibling is known here. + const header = encodeOrcadMigrationShellHistoryHeader(destinationWorktreeId, { + loginShell: shell === 'bash' && !zshSent + }) + zshSent ||= shell === 'zsh' + const segments = await joinHistories(header, files) + const byteLength = segments.reduce((total, segment) => total + segmentBytes(segment), 0) + // A move blocker rather than a truncation: dropping old commands would silently lose them. + if (byteLength > MAX_ORCAD_MIGRATION_SHELL_HISTORY_BYTES) { + throw new Error( + `orcad_migration_shell_history_too_large:${key}: the ${shell} history of ${destinationWorktreeId} is ${byteLength} bytes, over the ${MAX_ORCAD_MIGRATION_SHELL_HISTORY_BYTES}-byte limit` + ) + } + blobs.push({ + descriptor: { + blobKind: 'shell-history', + key, + sha256: await digestSegments(segments), + byteLength + }, + segments + }) + } + } + const descriptors = blobs.map((blob) => blob.descriptor) + assertOrcadMigrationBlobSet(descriptors) + const byIdentity = new Map( + blobs.map((blob) => [orcadMigrationBlobIdentity(blob.descriptor), blob]) + ) + return { + descriptors, + source: { + readChunk: async (requested, offset, maxBytes) => { + const blob = byIdentity.get(orcadMigrationBlobIdentity(requested)) + if (!blob || !sameOrcadMigrationBlob(requested, blob.descriptor)) { + throw new Error(`orcad_migration_source_blob_changed:${requested.blobKind}`) + } + return readSegments(blob, offset, maxBytes) + } + } + } +} + +// The `\\wsl$\` and `\\wsl.localhost\` ids of one worktree rewrite to the same host id. +function groupByDestination( + worktrees: readonly WslMoveShellHistoryWorktree[] +): Map { + const groups = new Map() + for (const worktree of worktrees) { + const sources = groups.get(worktree.destinationWorktreeId) ?? [] + if (!sources.includes(worktree.sourceWorktreeId)) { + sources.push(worktree.sourceWorktreeId) + } + groups.set(worktree.destinationWorktreeId, sources) + } + return groups +} + +// Oldest first, so the newest commands end up last, where shells read them back from. +async function desktopHistoryFiles( + root: string, + sourceWorktreeIds: readonly string[], + fileName: string +): Promise { + const found: (DesktopHistoryFile & { mtimeMs: number })[] = [] + for (const sourceWorktreeId of sourceWorktreeIds) { + const path = join(root, hashWorktreeId(sourceWorktreeId), fileName) + const info = await regularFile(path) + if (info && info.size > 0) { + found.push({ path, fileBytes: info.size, mtimeMs: info.mtimeMs }) + } + } + return found + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .map(({ path, fileBytes }) => ({ path, fileBytes })) +} + +// Without a newline between them, one file's last command would run into the next file's first. +async function joinHistories( + header: Buffer, + files: readonly DesktopHistoryFile[] +): Promise { + const segments: ShellHistorySegment[] = [{ bytes: header }] + for (const [index, file] of files.entries()) { + segments.push(file) + if (index < files.length - 1 && (await lastByte(file)) !== NEWLINE) { + segments.push({ bytes: Buffer.from('\n') }) + } + } + return segments +} + +async function lastByte(file: DesktopHistoryFile): Promise { + const [byte] = await readFileRange(file, file.fileBytes - 1, file.fileBytes) + return byte +} + +function segmentBytes(segment: ShellHistorySegment): number { + return 'bytes' in segment ? segment.bytes.length : segment.fileBytes +} + +// Null for a missing file or anything that is not a regular file. +async function regularFile(path: string): Promise<{ size: number; mtimeMs: number } | null> { + try { + const info = await stat(path) + return info.isFile() ? { size: info.size, mtimeMs: info.mtimeMs } : null + } catch { + return null + } +} + +async function digestSegments(segments: readonly ShellHistorySegment[]): Promise { + const hash = createHash('sha256') + for (const segment of segments) { + if ('bytes' in segment) { + hash.update(segment.bytes) + continue + } + let read = 0 + // `end` pins the digest to the size the descriptor declares even if the file grows meanwhile. + for await (const chunk of createReadStream(segment.path, { + start: 0, + end: segment.fileBytes - 1 + })) { + if (!Buffer.isBuffer(chunk)) { + throw new Error('orcad_migration_shell_history_read_failed') + } + hash.update(chunk) + read += chunk.length + } + if (read !== segment.fileBytes) { + throw new Error('orcad_migration_source_blob_changed:shell-history') + } + } + return hash.digest('hex') +} + +async function readSegments( + blob: ShellHistoryBlob, + offset: number, + maxBytes: number +): Promise { + const end = Math.min(offset + maxBytes, blob.descriptor.byteLength) + const parts: Buffer[] = [] + let segmentStart = 0 + for (const segment of blob.segments) { + const size = segmentBytes(segment) + const from = Math.max(offset - segmentStart, 0) + const to = Math.min(end - segmentStart, size) + if (to > from) { + parts.push( + 'bytes' in segment + ? segment.bytes.subarray(from, to) + : await readFileRange(segment, from, to) + ) + } + segmentStart += size + } + return Buffer.concat(parts) +} + +async function readFileRange(file: DesktopHistoryFile, from: number, to: number): Promise { + const handle = await open(file.path, 'r') + try { + if ((await handle.stat()).size !== file.fileBytes) { + throw new Error('orcad_migration_source_blob_changed:shell-history') + } + const buffer = Buffer.alloc(to - from) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, from) + if (bytesRead !== buffer.length) { + throw new Error('orcad_migration_source_blob_changed:shell-history') + } + return buffer + } finally { + await handle.close() + } +} diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index f82dd52e869..cb03f10ef75 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -2,6 +2,7 @@ import { win32 as pathWin32 } from 'node:path' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from '../git-bash' +import { resolveUnixPaneShellPath } from '../daemon/shell-ready' import { getDefaultWslDistro, parseWslPath } from '../wsl' import { getDefaultCwd, @@ -234,12 +235,11 @@ export function createLocalPtyLaunchPlan( if (process.platform === 'win32') { return createWindowsLocalPtyLaunchPlan(seed, getOptions) } - const shellPath = - args.shellOverride || - getOptions().getDefaultShell?.()?.trim() || - args.env?.SHELL || - process.env.SHELL || - '/bin/zsh' + const shellPath = resolveUnixPaneShellPath( + args.env ?? {}, + args.shellOverride, + getOptions().getDefaultShell?.() + ) return finalizeLocalPtyLaunchPlan(seed, { shellPath, // Why: shellOverride here is already resolved from the setting, so it cannot diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts index 2ad021f8ef4..a26fe70349f 100644 --- a/src/main/runtime/orca-runtime-migration-catalog.ts +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -55,6 +55,7 @@ export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaite manifest, signal: options.signal, expectedBlobs: options.expectedBlobs, + worktreeHasLiveShells: this.migrationWorktreeHasLiveShells, onDurableCommit: () => { this.invalidateResolvedWorktreeCache() this.notifyReposChanged() @@ -96,10 +97,55 @@ export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaite getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { return getOrcadMigrationCatalogState({ store: this.requireMigrationStore('getOrcadMigrationCatalogState'), - manifest + manifest, + worktreeHasLiveShells: this.migrationWorktreeHasLiveShells }) } + /** + * The moved shell history store load left waiting, installed once this runtime can see the + * host's shells. An unanswered inventory leaves it for the next catalog-state query. + */ + async installPendingOrcadMigrationShellHistory(): Promise { + const store = this.store + if (!store?.installPendingOrcadMigrationReceivedBlobs) { + return + } + // Daemon shells that outlived a restart count as live only once the inventory recorded them. + if ( + this.ptyController?.listProcesses && + !(await this.refreshPtyWorktreeRecordsWithControllerInventory([])) + ) { + console.warn('[orcad-migration] shell history waits: the terminal inventory did not answer') + return + } + store.installPendingOrcadMigrationReceivedBlobs({ + worktreeHasLiveShells: this.migrationWorktreeHasLiveShells + }) + } + + // The last shell of a worktree exiting releases a moved history that waited on it. + override onPtyExit( + ...args: Parameters + ): ReturnType { + const worktreeId = this.ptysById.get(args[0])?.worktreeId + const result = super.onPtyExit(...args) + // The store checks its deferred set first, so an exit in any other worktree costs one lookup. + if (worktreeId) { + try { + this.store?.installDeferredOrcadMigrationShellHistory?.(worktreeId, { + worktreeHasLiveShells: this.migrationWorktreeHasLiveShells + }) + } catch (error) { + console.error('[orcad-migration] installing deferred shell history failed:', error) + } + } + return result + } + + private readonly migrationWorktreeHasLiveShells = (worktreeId: string): boolean => + this.getLivePtyIdsForWorktree(worktreeId).size > 0 + // The capability check covers the captured Store, not a different virtual return. private requireMigrationStore(...methods: MigrationStoreKey[]): RuntimeStore { const store = this.store diff --git a/src/main/runtime/orca-runtime-migration-deferred-shell-history.test.ts b/src/main/runtime/orca-runtime-migration-deferred-shell-history.test.ts new file mode 100644 index 00000000000..a73d4907bda --- /dev/null +++ b/src/main/runtime/orca-runtime-migration-deferred-shell-history.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { store } from './orca-runtime-test-fixtures.spec' + +type RuntimeInternals = { + recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown +} + +describe('a moved shell history that waited on live shells', () => { + it('is retried when the last shell of its worktree exits, with the live-shell check', () => { + const installDeferred = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + installDeferredOrcadMigrationShellHistory: installDeferred + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is declared on OrcaRuntimeService's inheritance chain. + const internals = runtime as unknown as RuntimeInternals + internals.recordPtyWorktree('pty-1', 'repo-1::/srv/app', { connected: true }) + internals.recordPtyWorktree('pty-2', 'repo-1::/srv/app', { connected: true }) + + runtime.onPtyExit('pty-1', 0) + const [worktreeId, options] = installDeferred.mock.calls[0] + expect(worktreeId).toBe('repo-1::/srv/app') + // pty-2 still runs there, so the store keeps waiting. + expect(options.worktreeHasLiveShells('repo-1::/srv/app')).toBe(true) + + runtime.onPtyExit('pty-2', 0) + expect(installDeferred.mock.calls[1][1].worktreeHasLiveShells('repo-1::/srv/app')).toBe(false) + }) + + it('is installed once the runtime attached, counting daemon shells that outlived a restart', async () => { + const installPending = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + installPendingOrcadMigrationReceivedBlobs: installPending + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => [ + { id: 'daemon-pty', cwd: '/srv/app', title: 'zsh', worktreeId: 'repo-1::/srv/app' } + ]) + }) + + await runtime.installPendingOrcadMigrationShellHistory() + + const [options] = installPending.mock.calls[0] + expect(options.worktreeHasLiveShells('repo-1::/srv/app')).toBe(true) + expect(options.worktreeHasLiveShells('repo-1::/srv/other')).toBe(false) + }) + + it('waits when the terminal inventory does not answer', async () => { + const installPending = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + installPendingOrcadMigrationReceivedBlobs: installPending + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => { + throw new Error('daemon unreachable') + }) + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await runtime.installPendingOrcadMigrationShellHistory() + + expect(installPending).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/orcad-migration-catalog-import.ts b/src/main/runtime/orcad-migration-catalog-import.ts index e8bce17d8e9..efb34eb45d4 100644 --- a/src/main/runtime/orcad-migration-catalog-import.ts +++ b/src/main/runtime/orcad-migration-catalog-import.ts @@ -6,6 +6,9 @@ import type { import type { OrcadMigrationBlobDescriptor } from '../../shared/orcad-migration-blob-transfer' import type { Store } from '../persistence' +/** A moved history waits while a shell runs in its worktree: that shell would overwrite it. */ +type WorktreeHasLiveShells = (worktreeId: string) => boolean + type OrcadMigrationCatalogFlushStore = Partial> type OrcadMigrationCatalogStageStore = OrcadMigrationCatalogFlushStore & Partial> @@ -31,6 +34,7 @@ export async function commitStagedOrcadMigrationCatalogDurably(args: { signal?: AbortSignal expectedBlobs?: readonly OrcadMigrationBlobDescriptor[] onDurableCommit: () => void + worktreeHasLiveShells?: WorktreeHasLiveShells }): Promise { // SAFETY: Returned or mutable Store slots retain their original native missing-method failure. const result = args.store.commitStagedOrcadMigrationCatalog!( @@ -39,17 +43,18 @@ export async function commitStagedOrcadMigrationCatalogDurably(args: { ) await flushMigrationState(args.store, args.signal) args.onDurableCommit() - installReceivedBlobs(args.store, args.manifest) + installReceivedBlobs(args.store, args.manifest, args.worktreeHasLiveShells) return result } /** After the durable commit, on its retries and state queries; the store load retries too. */ function installReceivedBlobs( store: Partial>, - manifest: OrcadMigrationManifest + manifest: OrcadMigrationManifest, + worktreeHasLiveShells: WorktreeHasLiveShells | undefined ): void { try { - store.installOrcadMigrationReceivedBlobs?.(manifest) + store.installOrcadMigrationReceivedBlobs?.(manifest, { worktreeHasLiveShells }) } catch (error) { // The catalog is committed; a failed install keeps its blob for the next retry. console.error('[orcad-migration] installing received blobs failed:', error) @@ -76,11 +81,12 @@ export function getOrcadMigrationCatalogState(args: { Pick > manifest: OrcadMigrationManifest + worktreeHasLiveShells?: WorktreeHasLiveShells }): OrcadMigrationCatalogState { // SAFETY: Returned or mutable Store slots retain their original native missing-method failure. const result = args.store.getOrcadMigrationCatalogState!(args.manifest) if (result.state === 'committed') { - installReceivedBlobs(args.store, args.manifest) + installReceivedBlobs(args.store, args.manifest, args.worktreeHasLiveShells) } return result } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index c3294c3f308..9528b78bf88 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -62,6 +62,8 @@ export type RuntimeStore = { abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog'] getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState'] installOrcadMigrationReceivedBlobs?: Store['installOrcadMigrationReceivedBlobs'] + installPendingOrcadMigrationReceivedBlobs?: Store['installPendingOrcadMigrationReceivedBlobs'] + installDeferredOrcadMigrationShellHistory?: Store['installDeferredOrcadMigrationShellHistory'] peekOrcadMigrationTerminalRestoreHint?: Store['peekOrcadMigrationTerminalRestoreHint'] consumeOrcadMigrationTerminalRestoreHint?: Store['consumeOrcadMigrationTerminalRestoreHint'] persistPtyBinding?: Store['persistPtyBinding'] diff --git a/src/main/ssh/orcad-migration-path-rewrite.ts b/src/main/ssh/orcad-migration-path-rewrite.ts index 9ffe2b4fb52..e238b6de65b 100644 --- a/src/main/ssh/orcad-migration-path-rewrite.ts +++ b/src/main/ssh/orcad-migration-path-rewrite.ts @@ -122,7 +122,7 @@ function rewriteValue( ): string { switch (kind) { case 'worktree-id': - return rewriteWorktreeId(value, rewriter) + return rewriteOrcadMigrationWorktreeId(value, rewriter) case 'owner-key': return rewriteOwnerKey(value, rewriter) case 'pty-session-id': @@ -137,7 +137,11 @@ function rewriteValue( } } -function rewriteWorktreeId(value: string, rewriter: OrcadMigrationPathRewriter): string { +/** The worktree id the destination will hold for `value`, as the catalog rewrite computes it. */ +export function rewriteOrcadMigrationWorktreeId( + value: string, + rewriter: OrcadMigrationPathRewriter +): string { const parsed = splitWorktreeId(value) if (!parsed || !parsed.repoId || !parsed.worktreePath) { return value @@ -159,10 +163,10 @@ function rewriteOwnerKey(value: string, rewriter: OrcadMigrationPathRewriter): s return value } if (scope?.type === 'worktree') { - const mapped = rewriteWorktreeId(scope.worktreeId, rewriter) + const mapped = rewriteOrcadMigrationWorktreeId(scope.worktreeId, rewriter) return mapped === scope.worktreeId ? value : `worktree:${mapped}` } - return rewriteWorktreeId(value, rewriter) + return rewriteOrcadMigrationWorktreeId(value, rewriter) } function rewritePtySessionId(value: string, rewriter: OrcadMigrationPathRewriter): string { @@ -187,7 +191,7 @@ function rewriteEditorFileId(value: string, rewriter: OrcadMigrationPathRewriter return value } const [worktreeId, runtime, filePath] = decoded - const mappedWorktreeId = rewriteWorktreeId(worktreeId, rewriter) + const mappedWorktreeId = rewriteOrcadMigrationWorktreeId(worktreeId, rewriter) const mappedFilePath = rewriter.path(filePath, 'absolute') if (mappedWorktreeId === worktreeId && mappedFilePath === filePath) { return value diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 07ad8f12c93..27f8cfbac9e 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -107,7 +107,10 @@ function writeMetaFile( worktreeId, createdAt: existing?.createdAt ?? new Date().toISOString(), ...(fish ? { fishSession: fish.session } : {}), - ...(fish?.historyDir ? { fishHistoryDir: fish.historyDir } : {}) + ...(fish?.historyDir ? { fishHistoryDir: fish.historyDir } : {}), + ...(existing?.installedMigrationBlobs + ? { installedMigrationBlobs: existing.installedMigrationBlobs } + : {}) }), { mode: 0o600 } ) @@ -123,8 +126,12 @@ export type HistoryDirMeta = { fishSession?: string /** Directory that session's history file was written to, as the PTY saw it. */ fishHistoryDir?: string + /** Moved-history blobs (by sha256) already installed here, so a retry never prepends twice. */ + installedMigrationBlobs?: InstalledMigrationHistoryBlob[] } +export type InstalledMigrationHistoryBlob = { sha256: string; shell: string } + /** Read one history directory's meta.json, or null when it is absent or unparseable. */ export function readHistoryMeta(dir: string): HistoryDirMeta | null { try { @@ -174,13 +181,33 @@ function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), ...(typeof record.createdAt === 'string' ? { createdAt: record.createdAt } : {}), ...(fishSession ? { fishSession } : {}), - ...(fishHistoryDir ? { fishHistoryDir } : {}) + ...(fishHistoryDir ? { fishHistoryDir } : {}), + ...parseInstalledMigrationBlobs(record.installedMigrationBlobs) } } catch { return null } } +function parseInstalledMigrationBlobs( + value: unknown +): Pick { + if (!Array.isArray(value)) { + return {} + } + const blobs = value.flatMap((entry: unknown) => + entry && + typeof entry === 'object' && + 'sha256' in entry && + typeof entry.sha256 === 'string' && + 'shell' in entry && + typeof entry.shell === 'string' + ? [{ sha256: entry.sha256, shell: entry.shell }] + : [] + ) + return blobs.length > 0 ? { installedMigrationBlobs: blobs } : {} +} + // ─── Environment Injection ───────────────────────────────────────── export type HistoryInjectionResult = { diff --git a/src/shared/orcad-migration-blob-transfer.test.ts b/src/shared/orcad-migration-blob-transfer.test.ts index ccc50a650c1..00e9c10285a 100644 --- a/src/shared/orcad-migration-blob-transfer.test.ts +++ b/src/shared/orcad-migration-blob-transfer.test.ts @@ -124,7 +124,8 @@ describe('orcad migration blob contract', () => { it('advertises the channel and only the kinds that have an installer', () => { expect(advertisedOrcadMigrationBlobCapabilities()).toEqual([ ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, - 'orcad.migration-blob.terminal-restore-hints.v1' + 'orcad.migration-blob.terminal-restore-hints.v1', + 'orcad.migration-blob.shell-history.v1' ]) }) diff --git a/src/shared/orcad-migration-blob-transfer.ts b/src/shared/orcad-migration-blob-transfer.ts index 8cd69866f03..ef9d46b1b2d 100644 --- a/src/shared/orcad-migration-blob-transfer.ts +++ b/src/shared/orcad-migration-blob-transfer.ts @@ -258,7 +258,8 @@ export function parseOrcadMigrationBlobChunkResult( /** Kinds this build's destination installs after commit; each is added with its installer. */ export const ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS: readonly OrcadMigrationBlobKind[] = [ - 'terminal-restore-hints' + 'terminal-restore-hints', + 'shell-history' ] export function advertisedOrcadMigrationBlobCapabilities(): string[] {