feat(wsl-mover): shell history moves with the project to the WSL host (D1b-6c-1) (#28902)

* feat(wsl-mover): shell history moves with the project to the WSL host (D1b-6c-1)

* fix(wsl-mover): moved WSL history lands under the host's login shell (D1b-6c-1)

* fix(wsl-mover): history follows the shell a pane would actually run, fallback included (D1b-6c-1)

* fix(wsl-mover): history installs follow the real pane shell, wait for live shells, and mark what they installed (D1b-6c-1)

* fix(wsl-mover): history installs wait until live shells can be checked (D1b-6c-1)
This commit is contained in:
Neil
2026-10-14 17:56:14 -07:00
committed by GitHub
parent 88accb2e4a
commit 67a3aa492d
27 changed files with 1797 additions and 39 deletions
+12
View File
@@ -103,6 +103,18 @@ export function resolvePtyShellPath(env: Record<string, string>): 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<string, string>,
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
+4
View File
@@ -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({
@@ -130,6 +130,7 @@ vi.mock('../runtime/orca-runtime', () => ({
restoreHostAccountLaunchEnv() {}
async refreshRestoredOrchestrationAuthority() {}
async reconcileLegacyWorkerTerminals() {}
async installPendingOrcadMigrationShellHistory() {}
async stopLegacyWorkerTerminalRecovery() {}
async closeNotificationDismissals() {}
syncWindowGraph() {}
@@ -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(
@@ -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<typeof createStore>
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<EncodedBlob[]> {
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<string> {
const result = await commitStagedOrcadMigrationCatalogDurably({
store,
manifest: input,
worktreeHasLiveShells: noLiveShells,
onDurableCommit: () => {}
})
return result.state
}
@@ -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<typeof createStore> {
return createStore({ paneShellPath: () => '/usr/bin/zsh' })
}
async function stageZsh(
store: ReturnType<typeof createStore>,
content: string
): Promise<{ input: ReturnType<typeof manifest>; 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`)
}
)
})
@@ -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<string>(),
presentShells: new Set<string>()
}))
// 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<typeof NodeFs>()
return {
...actual,
existsSync: (path: Parameters<typeof actual.existsSync>[0]) =>
presentShells.has(String(path)) ||
(!missingShells.has(String(path)) && actual.existsSync(path)),
accessSync: (...args: Parameters<typeof actual.accessSync>) => {
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<void> {
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<string, string> = {}
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)
})
})
@@ -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<typeof createStore> {
return createStore({ paneShellPath: () => '/bin/bash' })
}
function encodedHistories(
files: { worktreeId: string; file: string; content: string }[]
): Promise<EncodedBlob[]> {
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<string, string> = {}
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)
})
})
+2 -2
View File
@@ -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<StoreRuntimeOptions, 'paneShellPath'> = {}): 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. */
@@ -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<T> = {
@@ -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<string>()
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
+2 -1
View File
@@ -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(
@@ -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)
})
@@ -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<Record<OrcadMigrationBlobKind, OrcadMigrationBlobContentCheck>> =
{
'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<string>
/** 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 {
@@ -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
@@ -0,0 +1,125 @@
/**
* The `shell-history` migration blob: one worktree's history file for one shell, keyed
* `<hashWorktreeId(destination worktree id)>-<shell>`. 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')
}
@@ -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 (`<root>/<hash>/<shell>_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<string>
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)
}
@@ -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<WslMoveShellHistoryBlobs> {
return buildWslMoveShellHistoryBlobs({ distro: 'Ubuntu', worktrees, historyRoot: root })
}
async function readAll(
blobs: WslMoveShellHistoryBlobs,
descriptor: OrcadMigrationBlobDescriptor,
chunk: number
): Promise<Buffer> {
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')
})
})
@@ -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<WslMoveShellHistoryBlobs> {
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<string, string[]> {
const groups = new Map<string, string[]>()
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<DesktopHistoryFile[]> {
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<ShellHistorySegment[]> {
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<number | undefined> {
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<string> {
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<Buffer> {
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<Buffer> {
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()
}
}
+6 -6
View File
@@ -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
@@ -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<void> {
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<OrcaRuntimeWithResolveWaiter['onPtyExit']>
): ReturnType<OrcaRuntimeWithResolveWaiter['onPtyExit']> {
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
@@ -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()
})
})
@@ -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<Pick<Store, 'flushPendingOrThrowAsync'>>
type OrcadMigrationCatalogStageStore = OrcadMigrationCatalogFlushStore &
Partial<Pick<Store, 'stageOrcadMigrationCatalog'>>
@@ -31,6 +34,7 @@ export async function commitStagedOrcadMigrationCatalogDurably(args: {
signal?: AbortSignal
expectedBlobs?: readonly OrcadMigrationBlobDescriptor[]
onDurableCommit: () => void
worktreeHasLiveShells?: WorktreeHasLiveShells
}): Promise<OrcadMigrationCatalogState> {
// 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<Pick<Store, 'installOrcadMigrationReceivedBlobs'>>,
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<Store, 'getOrcadMigrationCatalogState' | 'installOrcadMigrationReceivedBlobs'>
>
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
}
@@ -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']
+9 -5
View File
@@ -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
+29 -2
View File
@@ -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<HistoryDirMeta, 'installedMigrationBlobs'> {
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 = {
@@ -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'
])
})
+2 -1
View File
@@ -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[] {