mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+30
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user