fix(persistence): save editor-only host sessions during quit (#26848)

Normalize the incoming host slice and the saved partition before retirement and
membership rebasing read their terminal maps.

Fixes #25924

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
Neil
2026-10-10 23:52:32 -07:00
committed by GitHub
co-authored by m4air
parent c37ce7add2
commit 58d1d8d101
2 changed files with 114 additions and 1 deletions
@@ -194,8 +194,11 @@ export function setHostWorkspaceSession(
hostId: ExecutionHostId,
session: WorkspaceSessionState
): void {
const prior =
const persisted =
owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId]
const prior = persisted ? withRequiredWorkspaceSessionMaps(persisted) : undefined
// Host slices omit empty maps; retirement and rebasing need them before inspecting membership.
session = withRequiredWorkspaceSessionMaps(session)
// Why here and not at the callers: the before-unload stage path writes the renderer's payload
// straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows.
session = dropClosedTerminalTabs(preserveRuntimeAuthoredWorkspaceSessionFields(session, prior))
@@ -0,0 +1,110 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
closeTestStores,
createSqliteTestStore,
makeTerminalTab
} from '../../persistence-test-harness'
import { Store } from './store'
const HOST_ID = 'ssh:target-1'
const WORKTREE = 'repo-1::/srv/app'
const FOLDER_WORKTREE = 'folder:folder-1'
const TAB_ID = 'host-tab'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const directories: string[] = []
afterEach(async () => {
await closeTestStores()
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function openStore(): Store {
const directory = mkdtempSync(join(tmpdir(), 'orca-host-before-unload-'))
directories.push(directory)
return createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') })
}
/** The renderer's host split leaves out terminal maps for a host with only editor tabs. */
function editorOnlySlice(worktreeId = WORKTREE): WorkspaceSessionState {
const slice: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
openFilesByWorktree: {
[worktreeId]: [
{ worktreeId, filePath: '/srv/app/a.ts', relativePath: 'a.ts', language: 'typescript' }
]
}
}
Reflect.deleteProperty(slice, 'tabsByWorktree')
Reflect.deleteProperty(slice, 'terminalLayoutsByTabId')
return slice
}
describe('before-unload staging of an editor-only host slice', () => {
it.each([
[{}, WORKTREE],
[{ 'repo-1': 1 }, WORKTREE],
[{ 'repo-1': 1 }, FOLDER_WORKTREE]
])('saves the slice when the partition has topology revisions %j (%s)', (revisions, worktree) => {
const store = openStore()
store.setWorkspaceSession(
{ ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: revisions },
HOST_ID
)
store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(worktree), HOST_ID)
const saved = store.getWorkspaceSession(HOST_ID)
expect(saved.openFilesByWorktree?.[worktree]).toHaveLength(1)
expect(saved.tabsByWorktree).toEqual({})
expect(saved.terminalLayoutsByTabId).toEqual({})
})
it('accepts an older saved partition that is missing the terminal maps', () => {
const store = openStore()
store.setWorkspaceSession(
{ ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: { 'repo-1': 1 } },
HOST_ID
)
const older = store.getWorkspaceSession(HOST_ID)
Reflect.deleteProperty(older, 'tabsByWorktree')
Reflect.deleteProperty(older, 'terminalLayoutsByTabId')
store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(), HOST_ID)
expect(store.getWorkspaceSession(HOST_ID).tabsByWorktree).toEqual({})
})
it('keeps fenced live terminal tabs the stale slice leaves out', () => {
const store = openStore()
const tab = makeTerminalTab({ id: TAB_ID, worktreeId: WORKTREE, ptyId: 'live-pty' })
const layout = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'live-pty' }
} as const
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: { [WORKTREE]: [tab] },
terminalLayoutsByTabId: { [TAB_ID]: layout },
terminalTopologyRevisionByRepoId: { 'repo-1': 1 }
},
HOST_ID
)
store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(), HOST_ID)
expect(store.getWorkspaceSession(HOST_ID)).toMatchObject({
tabsByWorktree: { [WORKTREE]: [tab] },
terminalLayoutsByTabId: { [TAB_ID]: layout }
})
})
})