Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
900 lines
33 KiB
TypeScript
900 lines
33 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { readFileSync, rmSync, mkdtempSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
|
|
import { getDefaultWorkspaceSession } from '../shared/constants'
|
|
import { isTerminalLeafId } from '../shared/stable-pane-id'
|
|
import {
|
|
testState,
|
|
createStore,
|
|
dataFile,
|
|
writeDataFile,
|
|
readDataFile,
|
|
makeRepo,
|
|
makeTerminalTab,
|
|
makeWorktreeLineage,
|
|
makeWorkspaceLineage
|
|
} from './persistence-test-harness'
|
|
import { worktreeWorkspaceKey } from '../shared/workspace-scope'
|
|
import { TEST_LEAF_1 } from './persistence-session-fixtures'
|
|
|
|
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
|
|
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
|
|
loadUserSshConfigMock: vi.fn(),
|
|
sshConfigHostsToTargetsMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./ssh/ssh-config-parser', () => ({
|
|
loadUserSshConfig: loadUserSshConfigMock,
|
|
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
|
|
}))
|
|
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
|
|
trackMock: vi.fn(),
|
|
getCohortAtEmitMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
getPath: () => testState.dir
|
|
},
|
|
safeStorage: {
|
|
isEncryptionAvailable: () => true,
|
|
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
|
decryptString: (ciphertext: Buffer) => {
|
|
const decoded = ciphertext.toString('utf-8')
|
|
if (!decoded.startsWith('encrypted:')) {
|
|
throw new Error('invalid ciphertext')
|
|
}
|
|
return decoded.slice('encrypted:'.length)
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('./telemetry/client', () => ({
|
|
track: trackMock
|
|
}))
|
|
|
|
vi.mock('./telemetry/cohort-classifier', () => ({
|
|
getCohortAtEmit: getCohortAtEmitMock
|
|
}))
|
|
|
|
describe('Store host-partitioned workspace sessions', () => {
|
|
beforeEach(() => {
|
|
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(testState.dir, { recursive: true, force: true })
|
|
})
|
|
|
|
const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({
|
|
...getDefaultWorkspaceSession(),
|
|
activeRepoId
|
|
})
|
|
|
|
const makeLegacyPaneHostSession = (repoId: string, ptyId: string): WorkspaceSessionState => {
|
|
const worktreeId = `${repoId}::/worktree`
|
|
return {
|
|
...getDefaultWorkspaceSession(),
|
|
activeRepoId: repoId,
|
|
activeWorktreeId: worktreeId,
|
|
activeTabId: 'tab-shared',
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'tab-shared', worktreeId, ptyId })]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
'tab-shared': {
|
|
root: { type: 'leaf', leafId: 'pane:1' },
|
|
activeLeafId: 'pane:1',
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: { 'pane:1': ptyId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({
|
|
...getDefaultWorkspaceSession(),
|
|
activeRepoId: 'repo-1',
|
|
activeWorktreeId: 'repo-1::/worktree',
|
|
activeTabId: 'tab-1',
|
|
tabsByWorktree: {
|
|
'repo-1::/worktree': [
|
|
{
|
|
id: 'tab-1',
|
|
worktreeId: 'repo-1::/worktree',
|
|
title: 'Terminal',
|
|
customTitle: null,
|
|
color: null,
|
|
sortOrder: 0,
|
|
createdAt: 1,
|
|
ptyId
|
|
}
|
|
]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
'tab-1': {
|
|
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
|
activeLeafId: TEST_LEAF_1,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('migrates a legacy workspaceSession blob into the local partition', async () => {
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('legacy-repo')
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
// The legacy blob is the 'local' partition; an explicit/default hostId reads it.
|
|
expect(store.getWorkspaceSession().activeRepoId).toBe('legacy-repo')
|
|
expect(store.getWorkspaceSession('local').activeRepoId).toBe('legacy-repo')
|
|
// No data was moved, so a downgrade still finds the legacy field intact.
|
|
store.flush()
|
|
const persisted = readDataFile() as { workspaceSession?: { activeRepoId?: string } }
|
|
expect(persisted.workspaceSession?.activeRepoId).toBe('legacy-repo')
|
|
})
|
|
|
|
it('is idempotent: re-loading already-partitioned state preserves all hosts', async () => {
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('local-repo'),
|
|
workspaceSessionsByHostId: {
|
|
'runtime:env-a': makeHostSession('runtime-repo'),
|
|
'ssh:host-b': makeHostSession('ssh-repo')
|
|
}
|
|
})
|
|
|
|
const readSessionPartitions = (): unknown => {
|
|
const data = readDataFile() as {
|
|
workspaceSession?: unknown
|
|
workspaceSessionsByHostId?: unknown
|
|
}
|
|
return {
|
|
workspaceSession: data.workspaceSession,
|
|
workspaceSessionsByHostId: data.workspaceSessionsByHostId
|
|
}
|
|
}
|
|
|
|
const first = await createStore()
|
|
first.flush()
|
|
const afterFirst = readSessionPartitions()
|
|
|
|
const second = await createStore()
|
|
second.flush()
|
|
const afterSecond = readSessionPartitions()
|
|
|
|
// Re-running the partition migration must not move or reshape any host.
|
|
expect(afterSecond).toEqual(afterFirst)
|
|
expect(second.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo')
|
|
expect(second.getWorkspaceSession('ssh:host-b').activeRepoId).toBe('ssh-repo')
|
|
expect(second.getWorkspaceSession('local').activeRepoId).toBe('local-repo')
|
|
})
|
|
|
|
it('drops a stray "local" key in workspaceSessionsByHostId in favor of the legacy blob', async () => {
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('canonical-local'),
|
|
workspaceSessionsByHostId: {
|
|
local: makeHostSession('shadow-local')
|
|
}
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local')
|
|
})
|
|
|
|
it('rewrites legacy pane ids inside a host partition and remaps its leases', async () => {
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('local-repo'),
|
|
workspaceSessionsByHostId: {
|
|
'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty')
|
|
},
|
|
sshRemotePtyLeases: [
|
|
{
|
|
targetId: 'ssh-1',
|
|
ptyId: 'remote-pty',
|
|
worktreeId: 'repo-ssh::/worktree',
|
|
tabId: 'tab-shared',
|
|
leafId: 'pane:1',
|
|
state: 'detached',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
const root = store.getWorkspaceSession('ssh:ssh-1').terminalLayoutsByTabId['tab-shared']?.root
|
|
const leafId = root?.type === 'leaf' ? root.leafId : null
|
|
expect(leafId && isTerminalLeafId(leafId)).toBe(true)
|
|
// The lease follows the partition's rewritten leaf, not the legacy `pane:1`.
|
|
expect(store.getSshRemotePtyLeases('ssh-1')[0]?.leafId).toBe(leafId)
|
|
})
|
|
|
|
it('remaps legacy SSH leases within their execution-host partition', async () => {
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('local-repo'),
|
|
workspaceSessionsByHostId: {
|
|
'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'),
|
|
'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b')
|
|
},
|
|
sshRemotePtyLeases: [
|
|
{
|
|
targetId: 'host-a',
|
|
ptyId: 'pty-a',
|
|
worktreeId: 'repo-a::/worktree',
|
|
tabId: 'tab-shared',
|
|
leafId: 'pane:1',
|
|
state: 'detached',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
},
|
|
{
|
|
targetId: 'host-b',
|
|
ptyId: 'pty-b',
|
|
worktreeId: 'repo-b::/worktree',
|
|
tabId: 'tab-shared',
|
|
leafId: 'pane:1',
|
|
state: 'detached',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
})
|
|
|
|
const store = await createStore()
|
|
const rootA = store.getWorkspaceSession('ssh:host-a').terminalLayoutsByTabId['tab-shared']?.root
|
|
const rootB = store.getWorkspaceSession('ssh:host-b').terminalLayoutsByTabId['tab-shared']?.root
|
|
const leafA = rootA?.type === 'leaf' ? rootA.leafId : null
|
|
const leafB = rootB?.type === 'leaf' ? rootB.leafId : null
|
|
|
|
expect(leafA && isTerminalLeafId(leafA)).toBe(true)
|
|
expect(leafB && isTerminalLeafId(leafB)).toBe(true)
|
|
expect(leafA).not.toBe(leafB)
|
|
expect(store.getSshRemotePtyLeases('host-a')[0]?.leafId).toBe(leafA)
|
|
expect(store.getSshRemotePtyLeases('host-b')[0]?.leafId).toBe(leafB)
|
|
})
|
|
|
|
it('repairs a stable SSH lease leaf copied from another host partition', async () => {
|
|
const leafA = '11111111-1111-4111-8111-111111111111'
|
|
const leafB = '22222222-2222-4222-8222-222222222222'
|
|
const makeStableHostSession = (
|
|
repoId: string,
|
|
ptyId: string,
|
|
leafId: string
|
|
): WorkspaceSessionState => {
|
|
const worktreeId = `${repoId}::/worktree`
|
|
return {
|
|
...getDefaultWorkspaceSession(),
|
|
activeRepoId: repoId,
|
|
activeWorktreeId: worktreeId,
|
|
activeTabId: 'tab-shared',
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'tab-shared', worktreeId, ptyId })]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
'tab-shared': {
|
|
root: { type: 'leaf', leafId },
|
|
activeLeafId: leafId,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: { [leafId]: ptyId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: makeHostSession('local-repo'),
|
|
workspaceSessionsByHostId: {
|
|
'ssh:host-a': makeStableHostSession('repo-a', 'pty-a', leafA),
|
|
'ssh:host-b': makeStableHostSession('repo-b', 'pty-b', leafB)
|
|
},
|
|
sshRemotePtyLeases: [
|
|
{
|
|
targetId: 'host-b',
|
|
ptyId: 'pty-b',
|
|
worktreeId: 'repo-b::/worktree',
|
|
tabId: 'tab-shared',
|
|
leafId: leafA,
|
|
state: 'detached',
|
|
createdAt: 1,
|
|
updatedAt: 1
|
|
}
|
|
]
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getSshRemotePtyLeases('host-b')[0]?.leafId).toBe(leafB)
|
|
})
|
|
|
|
it('isolates writes: setting host A does not mutate host B or local', async () => {
|
|
const store = await createStore()
|
|
|
|
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
|
|
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
|
store.setWorkspaceSession(makeHostSession('repo-b'), 'runtime:env-b')
|
|
|
|
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
|
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
|
expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b')
|
|
|
|
// Overwriting host A leaves host B and local untouched.
|
|
store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a')
|
|
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a2')
|
|
expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b')
|
|
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
|
})
|
|
|
|
it('patches a single host partition without touching the others', async () => {
|
|
const store = await createStore()
|
|
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
|
|
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
|
|
|
store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a')
|
|
|
|
expect(store.getWorkspaceSession('runtime:env-a').activeTabId).toBe('tab-a')
|
|
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
|
// Local was never given that tab id.
|
|
expect(store.getWorkspaceSession('local').activeTabId).toBeNull()
|
|
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
|
|
})
|
|
|
|
it('preserves and enforces equal repo-id topology authority independently per host', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'duplicate::/worktree'
|
|
const staleTabs = {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId, ptyId: 'stale-pty' })]
|
|
}
|
|
store.setWorkspaceSession(
|
|
{
|
|
...getDefaultWorkspaceSession(),
|
|
terminalTopologyRevisionByRepoId: { duplicate: 2 }
|
|
},
|
|
'runtime:env-a'
|
|
)
|
|
store.setWorkspaceSession(
|
|
{
|
|
...getDefaultWorkspaceSession(),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId, ptyId: 'live-pty' })]
|
|
},
|
|
terminalTopologyRevisionByRepoId: { duplicate: 7 }
|
|
},
|
|
'runtime:env-b'
|
|
)
|
|
|
|
store.setWorkspaceSession(
|
|
{ ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs },
|
|
'runtime:env-a'
|
|
)
|
|
store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a')
|
|
|
|
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([])
|
|
expect(
|
|
store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate
|
|
).toBe(2)
|
|
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe(
|
|
'live-tab'
|
|
)
|
|
expect(
|
|
store.getWorkspaceSession('runtime:env-b').terminalTopologyRevisionByRepoId?.duplicate
|
|
).toBe(7)
|
|
})
|
|
|
|
it('persists an SSH PTY binding only in the SSH host partition', async () => {
|
|
const store = await createStore()
|
|
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
|
|
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
|
|
|
|
store.persistPtyBinding(
|
|
{
|
|
worktreeId: 'repo-1::/worktree',
|
|
tabId: 'tab-1',
|
|
leafId: TEST_LEAF_1,
|
|
ptyId: 'ssh:ssh-1@@remote-pty'
|
|
},
|
|
'ssh:ssh-1'
|
|
)
|
|
|
|
expect(
|
|
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
|
).toBe('ssh:ssh-1@@remote-pty')
|
|
expect(
|
|
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
|
).toBeNull()
|
|
})
|
|
|
|
it('rolls back a failed SSH PTY binding flush in the SSH host partition', async () => {
|
|
const store = await createStore()
|
|
store.setWorkspaceSession(makeBoundHostSession(null), 'local')
|
|
store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1')
|
|
const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
|
|
throw new Error('disk unavailable')
|
|
})
|
|
|
|
expect(() =>
|
|
store.persistPtyBinding(
|
|
{
|
|
worktreeId: 'repo-1::/worktree',
|
|
tabId: 'tab-1',
|
|
leafId: TEST_LEAF_1,
|
|
ptyId: 'ssh:ssh-1@@remote-pty'
|
|
},
|
|
'ssh:ssh-1'
|
|
)
|
|
).toThrow('disk unavailable')
|
|
flush.mockRestore()
|
|
|
|
expect(
|
|
store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
|
).toBeNull()
|
|
expect(
|
|
store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId
|
|
).toBeNull()
|
|
})
|
|
|
|
it('clears expired SSH PTY bindings from the SSH partition and legacy local copy', async () => {
|
|
const store = await createStore()
|
|
const ptyId = 'ssh:ssh-1@@remote-pty'
|
|
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local')
|
|
store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1')
|
|
store.upsertSshRemotePtyLease({
|
|
targetId: 'ssh-1',
|
|
ptyId: 'remote-pty',
|
|
worktreeId: 'repo-1::/worktree',
|
|
tabId: 'tab-1',
|
|
leafId: TEST_LEAF_1,
|
|
state: 'attached'
|
|
})
|
|
|
|
store.markSshRemotePtyLease('ssh-1', ptyId, 'expired')
|
|
|
|
for (const hostId of ['local', 'ssh:ssh-1']) {
|
|
const session = store.getWorkspaceSession(hostId)
|
|
expect(session.tabsByWorktree['repo-1::/worktree'][0]?.ptyId).toBeNull()
|
|
expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({})
|
|
}
|
|
})
|
|
|
|
it('defaults an omitted hostId to the local partition', async () => {
|
|
const store = await createStore()
|
|
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
|
|
|
// No hostId → local, which is still empty/default and unaffected by host A.
|
|
store.setWorkspaceSession(makeHostSession('repo-local'))
|
|
expect(store.getWorkspaceSession().activeRepoId).toBe('repo-local')
|
|
expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
|
})
|
|
|
|
it('round-trips host partitions through disk', async () => {
|
|
const store = await createStore()
|
|
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
|
|
store.flush()
|
|
|
|
const reloaded = await createStore()
|
|
expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a')
|
|
})
|
|
|
|
it('removes one orphaned worktree with a host-scoped topology fence', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-gone::/workspace/stale'
|
|
const session = {
|
|
...makeHostSession('repo-gone'),
|
|
activeWorktreeId: worktreeId,
|
|
activeWorktreeIdsOnShutdown: [worktreeId],
|
|
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
},
|
|
terminalTopologyRevisionByRepoId: { 'repo-gone': 3 }
|
|
}
|
|
store.setWorkspaceSession(session, 'local')
|
|
store.setWorkspaceSession(session, 'runtime:env-a')
|
|
store.setWorkspaceSession(session, 'runtime:env-b')
|
|
|
|
store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a')
|
|
store.setWorkspaceSession(session, 'runtime:env-a')
|
|
store.flush()
|
|
|
|
const reloaded = await createStore()
|
|
expect(reloaded.getWorkspaceSession('runtime:env-a')).toMatchObject({
|
|
tabsByWorktree: {},
|
|
terminalTopologyRevisionByRepoId: { 'repo-gone': 4 }
|
|
})
|
|
expect(reloaded.getWorkspaceSession('runtime:env-b')).toMatchObject({
|
|
activeWorktreeId: worktreeId,
|
|
activeWorktreeIdsOnShutdown: [worktreeId],
|
|
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
|
|
terminalTopologyRevisionByRepoId: { 'repo-gone': 3 }
|
|
})
|
|
// The local blob is a co-owner surface for every remote host, since the renderer parks state
|
|
// there whenever worktree ownership is unresolved; leaving it behind leaks the removed worktree.
|
|
expect(reloaded.getWorkspaceSession('local')).toMatchObject({
|
|
tabsByWorktree: {},
|
|
activeWorktreeId: null,
|
|
activeWorktreeIdsOnShutdown: [],
|
|
lastVisitedAtByWorktreeId: {},
|
|
terminalTopologyRevisionByRepoId: { 'repo-gone': 4 }
|
|
})
|
|
})
|
|
|
|
it('uses persisted worktree ownership when removing metadata', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-gone::/workspace/stale'
|
|
const session = {
|
|
...makeHostSession('repo-gone'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
}
|
|
store.setWorkspaceSession(session, 'local')
|
|
store.setWorkspaceSession(session, 'runtime:env-a')
|
|
store.setWorkspaceSession(session, 'runtime:env-b')
|
|
store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' })
|
|
|
|
store.removeWorktreeMeta(worktreeId)
|
|
|
|
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
// Only the owning host's partition and the local blob it may spill into are cleaned; a same-id
|
|
// worktree on another host is a different workspace and must survive.
|
|
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1)
|
|
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
})
|
|
|
|
it('cleans both surfaces that hold an ssh-owned worktree', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-ssh::/srv/stale'
|
|
const makeSession = (): ReturnType<typeof makeHostSession> => ({
|
|
...makeHostSession('repo-ssh'),
|
|
activeWorktreeId: worktreeId,
|
|
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
})
|
|
// The renderer persists SSH workspaces in the local blob; the main process writes their
|
|
// terminal state to the host's own `ssh:*` partition. Removal must clear both.
|
|
store.setWorkspaceSession(makeSession(), 'local')
|
|
store.setWorkspaceSession(makeSession(), 'ssh:conn-1')
|
|
store.setWorktreeMeta(worktreeId, { hostId: 'ssh:conn-1' })
|
|
|
|
store.removeWorktreeMeta(worktreeId, 'ssh:conn-1')
|
|
|
|
for (const hostId of ['local', 'ssh:conn-1'] as const) {
|
|
expect(store.getWorkspaceSession(hostId)).toMatchObject({
|
|
tabsByWorktree: {},
|
|
activeWorktreeId: null,
|
|
lastVisitedAtByWorktreeId: {},
|
|
terminalTopologyRevisionByRepoId: { 'repo-ssh': 1 }
|
|
})
|
|
}
|
|
})
|
|
|
|
it('does not bump the topology fence in a partition that never held the worktree', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-split::/workspace/stale'
|
|
const otherWorktreeId = 'repo-split::/workspace/live'
|
|
// Why this matters: the fence is keyed by repo, so a bump here would make every later write
|
|
// for repo-split rebase onto main's copy and silently drop the live worktree's unsaved tabs.
|
|
store.setWorkspaceSession(
|
|
{
|
|
...makeHostSession('repo-split'),
|
|
tabsByWorktree: {
|
|
[otherWorktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId: otherWorktreeId })]
|
|
}
|
|
},
|
|
'local'
|
|
)
|
|
store.setWorkspaceSession(
|
|
{
|
|
...makeHostSession('repo-split'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
},
|
|
'runtime:env-a'
|
|
)
|
|
store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' })
|
|
|
|
store.removeWorktreeMeta(worktreeId, 'local')
|
|
|
|
expect(
|
|
store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split']
|
|
).toBeUndefined()
|
|
expect(
|
|
store.getWorkspaceSession('local').terminalTopologyRevisionByRepoId?.['repo-split']
|
|
).toBeUndefined()
|
|
expect(store.getWorkspaceSession('local').tabsByWorktree[otherWorktreeId]).toHaveLength(1)
|
|
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('runtime:env-a')
|
|
})
|
|
|
|
it('preserves a same-id persisted owner when another qualified host is removed', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-split::/workspace/stale'
|
|
const session = {
|
|
...makeHostSession('repo-split'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
}
|
|
store.setWorkspaceSession(session, 'local')
|
|
store.setWorkspaceSession(session, 'runtime:env-b')
|
|
store.setWorktreeMeta(worktreeId, { hostId: 'local' })
|
|
const worktreeLineage = makeWorktreeLineage({ worktreeId })
|
|
const workspaceLineage = makeWorkspaceLineage({
|
|
childWorkspaceKey: worktreeWorkspaceKey(worktreeId)
|
|
})
|
|
store.setWorktreeLineage(worktreeId, worktreeLineage)
|
|
store.setWorkspaceLineage(workspaceLineage)
|
|
|
|
// The confirmed removal target is env-b. Bare metadata belongs to the same-id
|
|
// local owner and must not redirect the post-delete purge back to local.
|
|
store.removeWorktreeMeta(worktreeId, 'runtime:env-b')
|
|
|
|
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('local')
|
|
expect(store.getWorktreeLineage(worktreeId)).toEqual(worktreeLineage)
|
|
expect(store.getWorkspaceLineage(workspaceLineage.childWorkspaceKey)).toEqual(workspaceLineage)
|
|
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toHaveLength(1)
|
|
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
})
|
|
|
|
it('preserves a surviving local session when removed-host metadata owns the shared id', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-split::/workspace/stale'
|
|
store.addRepo(makeRepo({ id: 'repo-split', path: '/local/repo' }))
|
|
store.addRepo(
|
|
makeRepo({
|
|
id: 'repo-split',
|
|
path: '/remote/repo',
|
|
connectionId: 'ssh-b',
|
|
executionHostId: 'ssh:ssh-b'
|
|
})
|
|
)
|
|
const localSession = {
|
|
...makeHostSession('repo-split'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'same-id-local-tab', worktreeId })]
|
|
}
|
|
}
|
|
const remoteSession = {
|
|
...makeHostSession('repo-split'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'removed-remote-tab', worktreeId })]
|
|
}
|
|
}
|
|
store.setWorkspaceSession(localSession, 'local')
|
|
store.setWorkspaceSession(remoteSession, 'ssh:ssh-b')
|
|
store.setWorktreeMeta(worktreeId, { hostId: 'ssh:ssh-b' })
|
|
|
|
store.removeWorktreeMeta(worktreeId, 'ssh:ssh-b')
|
|
|
|
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toEqual([
|
|
expect.objectContaining({ id: 'same-id-local-tab' })
|
|
])
|
|
expect(store.getWorkspaceSession('ssh:ssh-b').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
expect(store.getWorktreeMeta(worktreeId)).toBeUndefined()
|
|
})
|
|
|
|
it('falls back to the caller hostId only when no ownership was recorded', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-gone::/workspace/stale'
|
|
const session = {
|
|
...makeHostSession('repo-gone'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
}
|
|
store.setWorkspaceSession(session, 'runtime:env-a')
|
|
store.setWorkspaceSession(session, 'local')
|
|
|
|
store.removeWorktreeMeta(worktreeId, 'runtime:env-a')
|
|
|
|
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toBeUndefined()
|
|
})
|
|
|
|
// Trade-off: the fence is repo-wide, so claiming authority over a partition main never wrote would
|
|
// rebase every sibling worktree of that repo onto an empty copy. A delayed write wins here instead.
|
|
it('does not fence a delayed session write when its host partition was never persisted', async () => {
|
|
const store = await createStore()
|
|
const worktreeId = 'repo-gone::/workspace/stale'
|
|
const delayedSession = {
|
|
...makeHostSession('repo-gone'),
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })]
|
|
}
|
|
}
|
|
|
|
store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a')
|
|
store.setWorkspaceSession(delayedSession, 'runtime:env-a')
|
|
|
|
const session = store.getWorkspaceSession('runtime:env-a')
|
|
expect(session.tabsByWorktree[worktreeId]?.[0]?.id).toBe('stale-tab')
|
|
expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined()
|
|
})
|
|
|
|
it('resets only the corrupt required field of a host partition, not the partition', async () => {
|
|
const worktreeId = 'repo-1::/worktree'
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSessionsByHostId: {
|
|
'runtime:good': makeHostSession('good-repo'),
|
|
// activeRepoId must be string|null; a number fails the zod parse.
|
|
'runtime:bad': {
|
|
...makeHostSession('x'),
|
|
activeRepoId: 123,
|
|
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] }
|
|
}
|
|
}
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo')
|
|
// The unsalvageable field falls back to its default; the partition's tabs survive.
|
|
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
|
|
expect(
|
|
store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id)
|
|
).toEqual(['bad-host-tab'])
|
|
})
|
|
|
|
it('keeps every other worktree when the local session has a corrupt required field', async () => {
|
|
const worktreeId = 'repo-1::/worktree'
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
workspaceSession: {
|
|
...makeHostSession('local-repo'),
|
|
// A projected/truncated write can leave a top-level field the wrong type;
|
|
// that must not cost every worktree's tabs the way a full reset did.
|
|
activeTabId: 42,
|
|
tabsByWorktree: {
|
|
[worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })]
|
|
}
|
|
}
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
const session = store.getWorkspaceSession('local')
|
|
expect(session.activeTabId).toBeNull()
|
|
expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep'])
|
|
})
|
|
|
|
type PersistedSessionsFile = {
|
|
workspaceSession?: {
|
|
tabsByWorktree?: Record<string, { id: string }[]>
|
|
sleepingAgentSessionsByPaneKey?: Record<string, unknown>
|
|
}
|
|
workspaceSessionsByHostId?: Record<
|
|
string,
|
|
{ tabsByWorktree?: Record<string, { id: string }[]> }
|
|
>
|
|
}
|
|
|
|
// Why: flush() writes whatever the state hash says is dirty, so it passes even
|
|
// when nothing scheduled a save — it cannot see the repair write at all. Loading
|
|
// once first canonicalizes the profile (a second load of a canonical file
|
|
// schedules nothing), so a later rewrite proves the salvage scheduled it.
|
|
async function loadAndAwaitScheduledSave(): Promise<void> {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const store = await createStore()
|
|
vi.advanceTimersByTime(10_000)
|
|
await store.waitForPendingWrite()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
}
|
|
|
|
async function canonicalize(fixture: Record<string, unknown>): Promise<PersistedSessionsFile> {
|
|
writeDataFile(fixture)
|
|
await loadAndAwaitScheduledSave()
|
|
const canonical = readFileSync(dataFile(), 'utf-8')
|
|
// Why: the save assertions below are only meaningful if a clean load schedules
|
|
// nothing. Prove that here rather than assume it — a future migration that
|
|
// dirtied every load would otherwise leave those tests silently vacuous.
|
|
await loadAndAwaitScheduledSave()
|
|
expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical)
|
|
return JSON.parse(canonical) as PersistedSessionsFile
|
|
}
|
|
|
|
it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => {
|
|
const worktreeId = 'repo-1::/worktree'
|
|
const profile = await canonicalize({
|
|
schemaVersion: 1,
|
|
workspaceSession: {
|
|
...makeHostSession('local-repo'),
|
|
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
|
|
}
|
|
})
|
|
const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId]
|
|
expect(tabs).toBeDefined()
|
|
tabs!.push({ id: 'tab-corrupt' })
|
|
writeDataFile(profile)
|
|
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await loadAndAwaitScheduledSave()
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[persistence] Salvaged workspace session; dropped corrupt entries:',
|
|
{ count: 1, fields: ['tabsByWorktree'], detailsTruncated: false }
|
|
)
|
|
expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId)
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
|
|
const persisted = readDataFile() as PersistedSessionsFile
|
|
expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([
|
|
'tab-keep'
|
|
])
|
|
})
|
|
|
|
it('schedules a save for salvaged host partitions', async () => {
|
|
const worktreeId = 'repo-1::/worktree'
|
|
const profile = await canonicalize({
|
|
schemaVersion: 1,
|
|
workspaceSessionsByHostId: {
|
|
'runtime:env-a': {
|
|
...makeHostSession('runtime-repo'),
|
|
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] }
|
|
},
|
|
'ssh:target-b': {
|
|
...makeHostSession('ssh-repo'),
|
|
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] }
|
|
}
|
|
}
|
|
})
|
|
const partitions = profile.workspaceSessionsByHostId
|
|
const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]
|
|
const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]
|
|
expect(runtimeTabs).toBeDefined()
|
|
expect(sshTabs).toBeDefined()
|
|
runtimeTabs!.push({ id: 'runtime-corrupt' })
|
|
sshTabs!.push({ id: 'ssh-corrupt' })
|
|
const mutablePartitions = partitions as Record<string, unknown>
|
|
mutablePartitions['runtime:broken'] = 'not a session'
|
|
writeDataFile(profile)
|
|
await loadAndAwaitScheduledSave()
|
|
|
|
const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId
|
|
expect(
|
|
persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)
|
|
).toEqual(['runtime-keep'])
|
|
expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual(
|
|
['ssh-keep']
|
|
)
|
|
expect(persisted).not.toHaveProperty('runtime:broken')
|
|
})
|
|
|
|
it('writes back sleeping-agent records dropped during salvage', async () => {
|
|
const profile = await canonicalize({
|
|
schemaVersion: 1,
|
|
workspaceSession: {
|
|
...makeHostSession('local-repo'),
|
|
sleepingAgentSessionsByPaneKey: {}
|
|
}
|
|
})
|
|
profile.workspaceSession!.sleepingAgentSessionsByPaneKey = {
|
|
'tab-bad:leaf': { paneKey: 'different:leaf' }
|
|
}
|
|
writeDataFile(profile)
|
|
|
|
await loadAndAwaitScheduledSave()
|
|
|
|
const persisted = readDataFile() as PersistedSessionsFile
|
|
expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined()
|
|
})
|
|
})
|