mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(persistence): salvage corrupt workspace session entries (#13431)
This commit is contained in:
@@ -12142,21 +12142,177 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a corrupt host partition to defaults without failing the others', async () => {
|
||||
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 }
|
||||
'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')
|
||||
// Bad partition collapses to defaults rather than poisoning the map.
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+45
-10
@@ -134,7 +134,7 @@ import {
|
||||
ONBOARDING_FLOW_VERSION,
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage'
|
||||
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
|
||||
import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode'
|
||||
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
|
||||
@@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc
|
||||
)
|
||||
}
|
||||
|
||||
function workspaceSessionSalvageLogDetails(result: {
|
||||
droppedCount: number
|
||||
droppedPaths: string[]
|
||||
}): { count: number; fields: string[]; detailsTruncated: boolean } {
|
||||
return {
|
||||
count: result.droppedCount,
|
||||
fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))],
|
||||
detailsTruncated: result.droppedCount > result.droppedPaths.length
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
|
||||
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
|
||||
function parseWorkspaceSessionsByHostId(
|
||||
raw: unknown,
|
||||
defaults: WorkspaceSessionState
|
||||
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
|
||||
): { partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {}
|
||||
return { partitions: {}, repaired: raw !== undefined }
|
||||
}
|
||||
let repaired = false
|
||||
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const hostId = normalizeExecutionHostId(key)
|
||||
@@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId(
|
||||
if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
continue
|
||||
}
|
||||
const result = parseWorkspaceSession(value)
|
||||
const result = parseWorkspaceSessionSalvaging(value)
|
||||
if (!result.ok) {
|
||||
repaired = true
|
||||
console.error(
|
||||
`[persistence] Corrupt workspace session for host ${hostId}, using defaults:`,
|
||||
result.error
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (result.droppedCount > 0) {
|
||||
console.warn(
|
||||
`[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`,
|
||||
workspaceSessionSalvageLogDetails(result)
|
||||
)
|
||||
repaired = true
|
||||
}
|
||||
partitions[hostId] = { ...defaults, ...result.value }
|
||||
}
|
||||
return partitions
|
||||
return { partitions, repaired }
|
||||
}
|
||||
|
||||
function backupPath(dataFile: string, index: number): string {
|
||||
@@ -3651,7 +3671,7 @@ export class Store {
|
||||
if (parsed.workspaceSession === undefined) {
|
||||
return defaults.workspaceSession
|
||||
}
|
||||
const result = parseWorkspaceSession(parsed.workspaceSession)
|
||||
const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession)
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
'[persistence] Corrupt workspace session, using defaults:',
|
||||
@@ -3659,13 +3679,28 @@ export class Store {
|
||||
)
|
||||
return defaults.workspaceSession
|
||||
}
|
||||
if (result.droppedCount > 0) {
|
||||
console.warn(
|
||||
'[persistence] Salvaged workspace session; dropped corrupt entries:',
|
||||
workspaceSessionSalvageLogDetails(result)
|
||||
)
|
||||
// Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch.
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return { ...defaults.workspaceSession, ...result.value }
|
||||
})(),
|
||||
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
|
||||
workspaceSessionsByHostId: parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
),
|
||||
workspaceSessionsByHostId: (() => {
|
||||
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
)
|
||||
if (repaired) {
|
||||
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return partitions
|
||||
})(),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
|
||||
? parsed.deletedSshConfigAliases.filter(
|
||||
|
||||
@@ -1651,21 +1651,25 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
}
|
||||
const hydratedTabs: BrowserWorkspace[] = []
|
||||
for (const tab of tabs) {
|
||||
const persistedPages = persistedPagesByWorkspace[tab.id] ?? [
|
||||
{
|
||||
id: createBrowserUuid(),
|
||||
workspaceId: tab.id,
|
||||
worktreeId,
|
||||
url: normalizeUrl(tab.url),
|
||||
title: tab.title,
|
||||
loading: false,
|
||||
faviconUrl: tab.faviconUrl ?? null,
|
||||
canGoBack: tab.canGoBack,
|
||||
canGoForward: tab.canGoForward,
|
||||
loadError: tab.loadError ?? null,
|
||||
createdAt: tab.createdAt
|
||||
} satisfies BrowserPage
|
||||
]
|
||||
// Salvage can leave an empty page array; hydrate it like a missing array.
|
||||
const storedPages = persistedPagesByWorkspace[tab.id]
|
||||
const persistedPages = storedPages?.length
|
||||
? storedPages
|
||||
: [
|
||||
{
|
||||
id: createBrowserUuid(),
|
||||
workspaceId: tab.id,
|
||||
worktreeId,
|
||||
url: normalizeUrl(tab.url),
|
||||
title: tab.title,
|
||||
loading: false,
|
||||
faviconUrl: tab.faviconUrl ?? null,
|
||||
canGoBack: tab.canGoBack,
|
||||
canGoForward: tab.canGoForward,
|
||||
loadError: tab.loadError ?? null,
|
||||
createdAt: tab.createdAt
|
||||
} satisfies BrowserPage
|
||||
]
|
||||
const nextPages = persistedPages.map((page) => {
|
||||
// Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping.
|
||||
const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } =
|
||||
|
||||
@@ -724,6 +724,45 @@ describe('hydrateBrowserSession', () => {
|
||||
expect(s.activeBrowserTabId).toBe('browser-1')
|
||||
})
|
||||
|
||||
it('synthesizes a page for a browser workspace whose persisted page list is empty', () => {
|
||||
// Why: session salvage drops a corrupt page by rebuilding the array, so the
|
||||
// key survives holding []. Treating that as "has pages" restores a workspace
|
||||
// with no page at all — a dead about:blank tab nothing prunes or reloads.
|
||||
const store = createTestStore()
|
||||
const validWt = 'repo1::/path/wt1'
|
||||
|
||||
store.setState({
|
||||
repos: [
|
||||
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
activeWorktreeId: validWt
|
||||
})
|
||||
|
||||
store.getState().hydrateBrowserSession({
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: validWt,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
browserTabsByWorktree: {
|
||||
[validWt]: [
|
||||
makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' })
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: { 'browser-1': [] }
|
||||
})
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.browserPagesByWorkspace['browser-1']).toHaveLength(1)
|
||||
expect(s.browserPagesByWorkspace['browser-1'][0].url).toBe('https://example.com')
|
||||
expect(s.browserTabsByWorktree[validWt][0].activePageId).toBe(
|
||||
s.browserPagesByWorkspace['browser-1'][0].id
|
||||
)
|
||||
})
|
||||
|
||||
it('drops legacy window close bypass state during hydration', () => {
|
||||
const store = createTestStore()
|
||||
const validWt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
|
||||
function collectLayoutGroupIds(node: TabGroupLayoutNode, groupIds: Set<string>): void {
|
||||
if (node.type === 'leaf') {
|
||||
groupIds.add(node.groupId)
|
||||
return
|
||||
}
|
||||
collectLayoutGroupIds(node.first, groupIds)
|
||||
collectLayoutGroupIds(node.second, groupIds)
|
||||
}
|
||||
|
||||
export function layoutSpanningGroups(
|
||||
groups: readonly TabGroup[],
|
||||
existing?: TabGroupLayoutNode | null
|
||||
): TabGroupLayoutNode {
|
||||
const first = existing ?? { type: 'leaf', groupId: groups[0].id }
|
||||
const laidOutGroupIds = new Set<string>()
|
||||
collectLayoutGroupIds(first, laidOutGroupIds)
|
||||
return groups
|
||||
.filter((group) => !laidOutGroupIds.has(group.id))
|
||||
.reduce<TabGroupLayoutNode>(
|
||||
(first, group) => ({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first,
|
||||
second: { type: 'leaf', groupId: group.id }
|
||||
}),
|
||||
first
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-home tabs stranded by a dropped group so hydration cannot render a blank workspace. */
|
||||
export function adoptGrouplessTabs(
|
||||
tabsByWorktree: Record<string, Tab[]>,
|
||||
groupsByWorktree: Record<string, TabGroup[]>,
|
||||
activeGroupIdByWorktree: Record<string, string>,
|
||||
layoutByWorktree: Record<string, TabGroupLayoutNode>
|
||||
): void {
|
||||
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
|
||||
const groups = groupsByWorktree[worktreeId] ?? []
|
||||
const owningGroupIdByTabId = new Map<string, string>()
|
||||
for (const group of groups) {
|
||||
for (const tabId of group.tabOrder) {
|
||||
owningGroupIdByTabId.set(tabId, owningGroupIdByTabId.get(tabId) ?? group.id)
|
||||
}
|
||||
}
|
||||
const orphanIds = tabs.filter((tab) => !owningGroupIdByTabId.has(tab.id)).map((tab) => tab.id)
|
||||
const host: TabGroup = groups[0] ?? {
|
||||
id: createBrowserUuid(),
|
||||
worktreeId,
|
||||
activeTabId: null,
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}
|
||||
const adopted = orphanIds.length
|
||||
? {
|
||||
...host,
|
||||
tabOrder: [...host.tabOrder, ...orphanIds],
|
||||
activeTabId: host.activeTabId ?? orphanIds[0]
|
||||
}
|
||||
: host
|
||||
for (const tabId of orphanIds) {
|
||||
owningGroupIdByTabId.set(tabId, adopted.id)
|
||||
}
|
||||
tabsByWorktree[worktreeId] = tabs.map((tab) => {
|
||||
const owningGroupId = owningGroupIdByTabId.get(tab.id)
|
||||
return owningGroupId && tab.groupId !== owningGroupId
|
||||
? { ...tab, groupId: owningGroupId }
|
||||
: tab
|
||||
})
|
||||
if (orphanIds.length) {
|
||||
groupsByWorktree[worktreeId] = [adopted, ...groups.slice(1)]
|
||||
activeGroupIdByWorktree[worktreeId] ??= adopted.id
|
||||
layoutByWorktree[worktreeId] ??= { type: 'leaf', groupId: adopted.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,3 +96,121 @@ describe('buildHydratedTabState group validation', () => {
|
||||
expect(group.tabOrder).toEqual(['t1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHydratedTabState referential repair', () => {
|
||||
const tab = (id: string, groupId: string, sortOrder: number) => ({
|
||||
id,
|
||||
entityId: id,
|
||||
groupId,
|
||||
worktreeId: 'w1',
|
||||
contentType: 'terminal' as const,
|
||||
label: id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt: 1
|
||||
})
|
||||
|
||||
function leafGroupIds(node: unknown): string[] {
|
||||
const layout = node as
|
||||
| { type: 'leaf'; groupId: string }
|
||||
| { type: 'split'; first: unknown; second: unknown }
|
||||
return layout.type === 'leaf'
|
||||
? [layout.groupId]
|
||||
: [...leafGroupIds(layout.first), ...leafGroupIds(layout.second)]
|
||||
}
|
||||
|
||||
it('keeps tabs reachable when a worktree lost every tab group', () => {
|
||||
// Why: session salvage drops a corrupt group record and leaves its tabs
|
||||
// behind. Groupless tabs render nowhere yet still count as renderable, so the
|
||||
// auto-create rescue never fires and the worktree body comes back blank.
|
||||
const result = buildHydratedTabState(
|
||||
{
|
||||
...makeBaseSession(),
|
||||
unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g1', 1)] },
|
||||
tabGroups: { w1: [] }
|
||||
},
|
||||
new Set(['w1'])
|
||||
)
|
||||
|
||||
const groups = result.groupsByWorktree.w1
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].tabOrder).toEqual(['t1', 't2'])
|
||||
expect(groups[0].activeTabId).toBe('t1')
|
||||
expect(result.activeGroupIdByWorktree.w1).toBe(groups[0].id)
|
||||
expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual([groups[0].id])
|
||||
expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual([
|
||||
groups[0].id,
|
||||
groups[0].id
|
||||
])
|
||||
})
|
||||
|
||||
it('adopts tabs stranded by one dropped group into a surviving group', () => {
|
||||
const result = buildHydratedTabState(
|
||||
{
|
||||
...makeBaseSession(),
|
||||
unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] },
|
||||
tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] }
|
||||
},
|
||||
new Set(['w1'])
|
||||
)
|
||||
|
||||
expect(result.groupsByWorktree.w1.map((group) => group.id)).toEqual(['g1'])
|
||||
expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1', 't2'])
|
||||
expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual(['g1', 'g1'])
|
||||
})
|
||||
|
||||
it('repairs a tab groupId when a surviving group already owns the tab', () => {
|
||||
const result = buildHydratedTabState(
|
||||
{
|
||||
...makeBaseSession(),
|
||||
unifiedTabs: { w1: [tab('t1', 'g-dropped', 0)] },
|
||||
tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] }
|
||||
},
|
||||
new Set(['w1'])
|
||||
)
|
||||
|
||||
expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1'])
|
||||
expect(result.unifiedTabsByWorktree.w1[0].groupId).toBe('g1')
|
||||
})
|
||||
|
||||
it('spans every surviving group when the persisted layout is gone', () => {
|
||||
// Why: salvage can drop a corrupt tabGroupLayouts entry while both groups
|
||||
// survive; a fallback leaf naming only the first hides the second group and
|
||||
// every tab in it, with nothing downstream to notice.
|
||||
const result = buildHydratedTabState(
|
||||
{
|
||||
...makeBaseSession(),
|
||||
unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] },
|
||||
tabGroups: {
|
||||
w1: [
|
||||
{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] },
|
||||
{ id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] }
|
||||
]
|
||||
}
|
||||
},
|
||||
new Set(['w1'])
|
||||
)
|
||||
|
||||
expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2'])
|
||||
})
|
||||
|
||||
it('adds surviving groups omitted by a partial persisted layout', () => {
|
||||
const result = buildHydratedTabState(
|
||||
{
|
||||
...makeBaseSession(),
|
||||
unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] },
|
||||
tabGroups: {
|
||||
w1: [
|
||||
{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] },
|
||||
{ id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] }
|
||||
]
|
||||
},
|
||||
tabGroupLayouts: { w1: { type: 'leaf', groupId: 'g1' } }
|
||||
},
|
||||
new Set(['w1'])
|
||||
)
|
||||
|
||||
expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../../../../shared/types'
|
||||
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { adoptGrouplessTabs, layoutSpanningGroups } from './tab-group-reference-repair'
|
||||
import {
|
||||
dedupeTabOrder,
|
||||
getPersistedEditFileIdsByWorktree,
|
||||
@@ -176,15 +177,12 @@ function hydrateUnifiedFormat(
|
||||
const hydratedLayout = session.tabGroupLayouts?.[worktreeId]
|
||||
? pruneTabGroupLayoutForGroups(session.tabGroupLayouts[worktreeId], hydratedGroupIds)
|
||||
: null
|
||||
layoutByWorktree[worktreeId] = hydratedLayout ?? {
|
||||
type: 'leaf',
|
||||
// Why: if transient-only groups were removed during hydration, the
|
||||
// persisted split tree can collapse to a single surviving group. The
|
||||
// fallback leaf keeps restore aligned with the remaining real tabs.
|
||||
groupId: hydratedGroups[0].id
|
||||
}
|
||||
// A partial layout must still render every surviving group.
|
||||
layoutByWorktree[worktreeId] = layoutSpanningGroups(hydratedGroups, hydratedLayout)
|
||||
}
|
||||
|
||||
adoptGrouplessTabs(tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree, layoutByWorktree)
|
||||
|
||||
return {
|
||||
unifiedTabsByWorktree: tabsByWorktree,
|
||||
groupsByWorktree,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Why: the browser slice of the persisted workspace session. Split out of
|
||||
* workspace-session-schema.ts to keep that file inside its line budget; the
|
||||
* schemas themselves are unchanged apart from the per-entry tolerance the
|
||||
* session schema now declares everywhere. */
|
||||
import { z } from 'zod'
|
||||
import type { BrowserWorkspace } from './types'
|
||||
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
|
||||
import { salvagingArray } from './zod-salvage'
|
||||
|
||||
const browserLoadErrorSchema = z.object({
|
||||
code: z.number(),
|
||||
description: z.string(),
|
||||
validatedUrl: z.string()
|
||||
})
|
||||
|
||||
const browserViewportPresetIdSchema = z.enum([
|
||||
'mobile-s',
|
||||
'mobile-m',
|
||||
'mobile-l',
|
||||
'tablet',
|
||||
'laptop',
|
||||
'laptop-l',
|
||||
'desktop'
|
||||
])
|
||||
|
||||
// Why: the z.ZodType<BrowserWorkspace> cast only aligns the static type — it
|
||||
// does NOT let new fields survive parsing. z.object strips unknown keys, so
|
||||
// every additive field must be listed below (optional+nullable) or it is
|
||||
// dropped on restore.
|
||||
export const browserWorkspaceSchema: z.ZodType<BrowserWorkspace> = z.object({
|
||||
id: z.string(),
|
||||
worktreeId: z.string(),
|
||||
label: z.string().optional(),
|
||||
sessionProfileId: z.string().nullable().optional(),
|
||||
// Why: optional+nullable so pre-field sessions still validate; without this
|
||||
// zod strips the persisted partition on restore, and an isolated tab whose
|
||||
// profile mirror is stale at startup would silently fall back to the shared
|
||||
// default partition — reopening the storage leak (#6923) across restarts.
|
||||
sessionPartition: z.string().nullable().optional(),
|
||||
activePageId: z.string().nullable().optional(),
|
||||
pageIds: z.array(z.string()).optional(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
loading: z.boolean(),
|
||||
faviconUrl: z.string().nullable(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loadError: browserLoadErrorSchema.nullable(),
|
||||
createdAt: z.number()
|
||||
})
|
||||
|
||||
export const browserPageSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
worktreeId: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
loading: z.boolean(),
|
||||
faviconUrl: z.string().nullable(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loadError: browserLoadErrorSchema.nullable(),
|
||||
createdAt: z.number(),
|
||||
// Why: explicit null marks a browser page as client-local even when its
|
||||
// worktree is remote-owned; older sessions omit it and keep inferred runtime.
|
||||
browserRuntimeEnvironmentId: z.string().nullable().optional(),
|
||||
// Why: optional+nullable so sessions persisted before viewport presets were
|
||||
// added still validate; without this, zod would strip the field during
|
||||
// restore and reset the user's chosen preset on every app restart.
|
||||
viewportPresetId: browserViewportPresetIdSchema.nullable().optional()
|
||||
})
|
||||
|
||||
const browserHistoryEntrySchema = z.object({
|
||||
url: z.string(),
|
||||
normalizedUrl: z.string(),
|
||||
title: z.string(),
|
||||
lastVisitedAt: z.number(),
|
||||
visitCount: z.number()
|
||||
})
|
||||
|
||||
export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform(
|
||||
(entries) => normalizeBrowserHistoryEntries(entries)
|
||||
)
|
||||
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { parseWorkspaceSessionSalvaging } from './workspace-session-salvage'
|
||||
import { collectSalvageDrops, salvagingArray } from './zod-salvage'
|
||||
|
||||
const WT = 'repo-1::/home/user/project'
|
||||
|
||||
function terminalTab(id: string, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: WT,
|
||||
title: 'Terminal',
|
||||
defaultTitle: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1_700_000_000_000,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function baseSession(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseWorkspaceSessionSalvaging', () => {
|
||||
it('restores outer diagnostics after a nested collection', () => {
|
||||
const schema = salvagingArray(z.string())
|
||||
const outer = collectSalvageDrops(() => {
|
||||
schema.parse([1])
|
||||
const inner = collectSalvageDrops(() => schema.parse([2]))
|
||||
schema.parse([3])
|
||||
return inner
|
||||
})
|
||||
|
||||
expect(outer.droppedCount).toBe(2)
|
||||
expect(outer.value.droppedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a valid session unchanged with nothing dropped', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({ tabsByWorktree: { [WT]: [terminalTab('tab-1')] } })
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual([])
|
||||
expect(result.value.tabsByWorktree[WT]).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a tab record missing required fields and keeps the rest of the session', () => {
|
||||
const truncated = {
|
||||
id: 'tab-bad',
|
||||
ptyId: null,
|
||||
worktreeId: WT,
|
||||
title: 'Terminal',
|
||||
sortOrder: 0,
|
||||
generation: 3,
|
||||
startupCwd: '/home/user/project'
|
||||
}
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: { [WT]: [terminalTab('tab-1'), terminalTab('tab-2'), truncated] },
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual([`tabsByWorktree.${WT}.2`])
|
||||
expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2'])
|
||||
}
|
||||
})
|
||||
|
||||
it('reports sleeping-agent records removed during normalization', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-bad:leaf': { paneKey: 'different:leaf' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['sleepingAgentSessionsByPaneKey.tab-bad:leaf'])
|
||||
expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops only the corrupt leaf pty mapping and keeps the rest of the tab layout', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: { [WT]: [terminalTab('tab-1')] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-1': 'pty-1', 'leaf-2': 42 }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-1.ptyIdsByLeafId.leaf-2'])
|
||||
const layout = result.value.terminalLayoutsByTabId['tab-1']
|
||||
expect(layout?.ptyIdsByLeafId).toEqual({ 'leaf-1': 'pty-1' })
|
||||
expect(layout?.root).toEqual({ type: 'leaf', leafId: 'leaf-1' })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the containing entry when the corruption sits in one of its required fields', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-bad': {
|
||||
root: { type: 'leaf', leafId: 42 },
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null
|
||||
},
|
||||
'tab-good': {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
// Why: the entry is the smallest self-contained unit, so dropped counts
|
||||
// reflect distinct corrupt records rather than symptoms.
|
||||
expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-bad'])
|
||||
expect(result.value.terminalLayoutsByTabId['tab-bad']).toBeUndefined()
|
||||
expect(result.value.terminalLayoutsByTabId['tab-good']?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: 'leaf-1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('salvages systemic single-field corruption without inflating the dropped count', () => {
|
||||
const layouts: Record<string, unknown> = {}
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
layouts[`tab-${i}`] = {
|
||||
root: { type: 'leaf', leafId: i },
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
const result = parseWorkspaceSessionSalvaging(baseSession({ terminalLayoutsByTabId: layouts }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.terminalLayoutsByTabId).toEqual({})
|
||||
expect(result.droppedPaths).toHaveLength(20)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports one drop when a record raises both a bad-field and a missing-field issue', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({ terminalSurfaceTombstonesByPaneKey: { 'tab-1:leaf-1': { worktreeId: 42 } } })
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1'])
|
||||
expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports one drop when two fields of the same record are corrupt', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
terminalSurfaceTombstonesByPaneKey: {
|
||||
'tab-1:leaf-1': {
|
||||
worktreeId: WT,
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: '',
|
||||
retiredAt: -5
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1'])
|
||||
expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a dotted map key and its same-named nested path independently', () => {
|
||||
// Why: map keys are user data, so 'a.root' and the nested path a → root are
|
||||
// different entries that read alike once joined.
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
terminalLayoutsByTabId: {
|
||||
'a.root': 'not-an-object',
|
||||
a: { root: 42, activeLeafId: null, expandedLeafId: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths.toSorted()).toEqual([
|
||||
'terminalLayoutsByTabId.a',
|
||||
'terminalLayoutsByTabId.a.root'
|
||||
])
|
||||
expect(result.value.terminalLayoutsByTabId).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('drops an invalid unified tab entry without touching sibling worktrees', () => {
|
||||
const goodUnified = {
|
||||
id: 'tab-1',
|
||||
entityId: 'tab-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1_700_000_000_000
|
||||
}
|
||||
const missingCustomLabel = { ...goodUnified, id: 'tab-2', entityId: 'tab-2' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
delete missingCustomLabel.customLabel
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: { [WT]: [terminalTab('tab-1')] },
|
||||
unifiedTabs: { [WT]: [goodUnified, missingCustomLabel], 'repo-2::/other': [] }
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual([`unifiedTabs.${WT}.1`])
|
||||
expect(result.value.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['tab-1'])
|
||||
expect(result.value.unifiedTabs?.['repo-2::/other']).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a corrupt map value by its key', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1', 'tab-2:leaf-2': 123 }
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['terminalPtyIncarnationsByPaneKey.tab-2:leaf-2'])
|
||||
expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' })
|
||||
}
|
||||
})
|
||||
|
||||
it('salvages multiple corrupt entries across different maps in one load', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: { [WT]: [terminalTab('tab-1'), { id: 'tab-bad' }] },
|
||||
terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 42 }
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toHaveLength(2)
|
||||
expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1'])
|
||||
}
|
||||
})
|
||||
|
||||
it('salvages rather than throwing on a payload large enough to overflow the validator', () => {
|
||||
// Why: this parse runs in the Store constructor, so an escaping RangeError is
|
||||
// an unrecoverable launch failure. Per-entry validation never accumulates the
|
||||
// issue list that used to overflow.
|
||||
const worktreeId = 'repo-1::/huge'
|
||||
const tabs = Array.from({ length: 200_000 }, (_, i) => ({ id: `bad-${i}` }))
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({ tabsByWorktree: { [worktreeId]: tabs } })
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedCount).toBe(200_000)
|
||||
expect(result.droppedPaths).toHaveLength(100)
|
||||
expect(result.value.tabsByWorktree[worktreeId]).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('fails for a payload that is not an object', () => {
|
||||
expect(parseWorkspaceSessionSalvaging('not a session').ok).toBe(false)
|
||||
expect(parseWorkspaceSessionSalvaging(null).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('drops an optional top-level field whose value is the wrong type', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({ terminalTopologyRevisionByRepoId: 'nope' })
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual(['terminalTopologyRevisionByRepoId'])
|
||||
expect(result.value.terminalTopologyRevisionByRepoId).toBeUndefined()
|
||||
// Why: an explicit undefined key would shadow the caller's default in the
|
||||
// `{ ...defaults, ...value }` spread both call sites do.
|
||||
expect(Object.hasOwn(result.value, 'terminalTopologyRevisionByRepoId')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('salvages systemic corruption far larger than any single-entry budget', () => {
|
||||
// Why: the reported failure was one bad record, but a bad writer projects the
|
||||
// same wrong shape across every entry it touches. The dropped count is
|
||||
// unbounded so that case stays a salvage instead of a full-session reset.
|
||||
const incarnations: Record<string, unknown> = {}
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
incarnations[`tab-${i}:leaf-${i}`] = i % 2 === 0 ? i : `inc-${i}`
|
||||
}
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({ terminalPtyIncarnationsByPaneKey: incarnations })
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedCount).toBe(200)
|
||||
expect(result.droppedPaths).toHaveLength(100)
|
||||
expect(Object.keys(result.value.terminalPtyIncarnationsByPaneKey ?? {})).toHaveLength(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops many corrupt tab records across worktrees in a single session load', () => {
|
||||
const tabsByWorktree: Record<string, unknown> = {}
|
||||
for (let w = 0; w < 20; w += 1) {
|
||||
const worktreeId = `repo-1::/w${w}`
|
||||
tabsByWorktree[worktreeId] = [
|
||||
terminalTab(`good-${w}`, { worktreeId }),
|
||||
{ id: `bad-a-${w}`, worktreeId },
|
||||
terminalTab(`good2-${w}`, { worktreeId }),
|
||||
{ id: `bad-b-${w}`, worktreeId }
|
||||
]
|
||||
}
|
||||
const result = parseWorkspaceSessionSalvaging(baseSession({ tabsByWorktree }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toHaveLength(40)
|
||||
expect(result.value.tabsByWorktree['repo-1::/w7']?.map((tab) => tab.id)).toEqual([
|
||||
'good-7',
|
||||
'good2-7'
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the rest of the session when a required top-level field is unsalvageable', () => {
|
||||
// Why: without a fallback, one bad legacy `tabsByWorktree` would still cost
|
||||
// every worktree's unified tabs, groups and layouts.
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: 'nope',
|
||||
activeRepoId: 42,
|
||||
terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1' }
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths.toSorted()).toEqual(['activeRepoId', 'tabsByWorktree'])
|
||||
expect(result.value.tabsByWorktree).toEqual({})
|
||||
expect(result.value.activeRepoId).toBeNull()
|
||||
expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' })
|
||||
}
|
||||
})
|
||||
|
||||
it('still rejects a foreign object payload rather than posing as a repaired session', () => {
|
||||
// Why: a fallback repairs a field we could not read, it must never manufacture
|
||||
// a session out of an unrelated JSON blob that simply lacks every field.
|
||||
expect(parseWorkspaceSessionSalvaging({ unrelated: 'payload', count: 3 }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a whole layout entry when the corruption sits inside a recursive union', () => {
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabGroupLayouts: {
|
||||
[WT]: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'split', direction: 'column', first: { type: 'leaf', groupId: 42 } }
|
||||
},
|
||||
'repo-2::/other': { type: 'leaf', groupId: 'group-ok' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`])
|
||||
expect(result.value.tabGroupLayouts?.[WT]).toBeUndefined()
|
||||
expect(result.value.tabGroupLayouts?.['repo-2::/other']).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a recursive entry whose validator overflows instead of resetting the session', () => {
|
||||
let layout: Record<string, unknown> = { type: 'leaf', groupId: 'group-deep' }
|
||||
for (let depth = 0; depth < 3_000; depth += 1) {
|
||||
layout = {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: layout,
|
||||
second: { type: 'leaf', groupId: `group-${depth}` }
|
||||
}
|
||||
}
|
||||
const result = parseWorkspaceSessionSalvaging(
|
||||
baseSession({
|
||||
tabsByWorktree: { [WT]: [terminalTab('tab-keep')] },
|
||||
tabGroupLayouts: { [WT]: layout }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`])
|
||||
expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-keep'])
|
||||
expect(result.value.tabGroupLayouts).toEqual({})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { WorkspaceSessionState } from './types'
|
||||
import {
|
||||
describeWorkspaceSessionError,
|
||||
safeParseWorkspaceSession,
|
||||
WORKSPACE_SESSION_UNVALIDATABLE
|
||||
} from './workspace-session-schema'
|
||||
import { collectSalvageDrops } from './zod-salvage'
|
||||
|
||||
export type SalvagedWorkspaceSession =
|
||||
| { ok: true; value: WorkspaceSessionState; droppedPaths: string[]; droppedCount: number }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** Remove undefined keys that would shadow caller defaults during object spread. */
|
||||
function withoutSalvagedAwayFields(session: WorkspaceSessionState): WorkspaceSessionState {
|
||||
return Object.fromEntries(
|
||||
Object.entries(session).filter(([, value]) => value !== undefined)
|
||||
) as WorkspaceSessionState
|
||||
}
|
||||
|
||||
/** Validate a session, preserving valid entries and reporting bounded repair diagnostics. */
|
||||
export function parseWorkspaceSessionSalvaging(raw: unknown): SalvagedWorkspaceSession {
|
||||
const {
|
||||
value: result,
|
||||
droppedPaths,
|
||||
droppedCount
|
||||
} = collectSalvageDrops(() => safeParseWorkspaceSession(raw))
|
||||
if (!result) {
|
||||
return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE }
|
||||
}
|
||||
if (!result.success) {
|
||||
return { ok: false, error: describeWorkspaceSessionError(result.error) }
|
||||
}
|
||||
return { ok: true, value: withoutSalvagedAwayFields(result.data), droppedPaths, droppedCount }
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('parseWorkspaceSession', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects blank external SSH file ownership', () => {
|
||||
it('drops an open file with blank external SSH ownership, keeping the session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'wt',
|
||||
@@ -60,7 +60,10 @@ describe('parseWorkspaceSession', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.openFilesByWorktree?.wt).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a fully populated session with optional fields', () => {
|
||||
@@ -194,7 +197,7 @@ describe('parseWorkspaceSession', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a session where ptyId is a number (schema drift)', () => {
|
||||
it('drops a tab where ptyId is a number (schema drift) without failing the session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
@@ -215,9 +218,9 @@ describe('parseWorkspaceSession', () => {
|
||||
},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('ptyId')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.tabsByWorktree.wt).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -406,6 +409,47 @@ describe('parseWorkspaceSession', () => {
|
||||
expect(parseWorkspaceSession(42).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('drops one truncated tab without discarding other persisted worktrees', () => {
|
||||
const validTab = {
|
||||
id: 'tab-good',
|
||||
ptyId: null,
|
||||
worktreeId: 'worktree-good',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1_700_000_000_000
|
||||
}
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'worktree-good',
|
||||
activeTabId: 'tab-good',
|
||||
tabsByWorktree: {
|
||||
'worktree-good': [validTab],
|
||||
'worktree-corrupt': [
|
||||
{
|
||||
id: 'tab-truncated',
|
||||
ptyId: null,
|
||||
worktreeId: 'worktree-corrupt',
|
||||
title: 'Terminal',
|
||||
sortOrder: 0,
|
||||
generation: 3,
|
||||
startupCwd: '/workspace'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.tabsByWorktree).toEqual({
|
||||
'worktree-good': [validTab],
|
||||
'worktree-corrupt': []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('drops bad lastVisitedAtByWorktreeId entries rather than failing the session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* "reject and fall back to defaults" point so garbage never reaches React.
|
||||
*
|
||||
* Policy: be tolerant of extra fields (future builds may add more) but strict
|
||||
* about the types of fields we actually read. Unknown enum values, wrong types,
|
||||
* and wrong shapes all collapse to "use defaults" — never throw into main.
|
||||
* about the types of fields we actually read. Where a field holds a collection
|
||||
* of independent records, tolerance is declared on the field itself (see
|
||||
* ./zod-salvage): a corrupt entry is dropped and the rest of the session
|
||||
* survives, because one bad tab record must not cost every worktree its state.
|
||||
* Only a payload that is not a session at all falls back to defaults.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
BrowserWorkspace,
|
||||
TabGroupLayoutNode,
|
||||
TerminalPaneLayoutNode,
|
||||
TuiAgent,
|
||||
@@ -20,9 +22,14 @@ import type {
|
||||
import { isValidTerminalTabId } from './terminal-tab-id'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from './execution-host'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
|
||||
import { isWorkspaceKey } from './workspace-scope'
|
||||
import {
|
||||
browserHistoryEntriesSchema,
|
||||
browserPageSchema,
|
||||
browserWorkspaceSchema
|
||||
} from './workspace-session-browser-schema'
|
||||
import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents'
|
||||
import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage'
|
||||
|
||||
// ─── Terminal pane layout (recursive) ───────────────────────────────
|
||||
|
||||
@@ -53,14 +60,16 @@ const terminalPaneLayoutNodeSchema: z.ZodType<TerminalPaneLayoutNode> = z.lazy((
|
||||
])
|
||||
)
|
||||
|
||||
const leafStringsSchema = salvagingRecord(z.string(), z.string())
|
||||
|
||||
const terminalLayoutSnapshotSchema = z.object({
|
||||
root: terminalPaneLayoutNodeSchema.nullable(),
|
||||
activeLeafId: z.string().nullable(),
|
||||
expandedLeafId: z.string().nullable(),
|
||||
ptyIdsByLeafId: z.record(z.string(), z.string()).optional(),
|
||||
buffersByLeafId: z.record(z.string(), z.string()).optional(),
|
||||
scrollbackRefsByLeafId: z.record(z.string(), z.string()).optional(),
|
||||
titlesByLeafId: z.record(z.string(), z.string()).optional()
|
||||
ptyIdsByLeafId: salvagedOptional('ptyIdsByLeafId', leafStringsSchema),
|
||||
buffersByLeafId: salvagedOptional('buffersByLeafId', leafStringsSchema),
|
||||
scrollbackRefsByLeafId: salvagedOptional('scrollbackRefsByLeafId', leafStringsSchema),
|
||||
titlesByLeafId: salvagedOptional('titlesByLeafId', leafStringsSchema)
|
||||
})
|
||||
|
||||
// ─── Terminal tab (legacy) ──────────────────────────────────────────
|
||||
@@ -187,172 +196,170 @@ const persistedOpenFileSchema = z.object({
|
||||
liveTail: z.boolean().optional()
|
||||
})
|
||||
|
||||
// ─── Browser ────────────────────────────────────────────────────────
|
||||
|
||||
const browserLoadErrorSchema = z.object({
|
||||
code: z.number(),
|
||||
description: z.string(),
|
||||
validatedUrl: z.string()
|
||||
})
|
||||
|
||||
const browserViewportPresetIdSchema = z.enum([
|
||||
'mobile-s',
|
||||
'mobile-m',
|
||||
'mobile-l',
|
||||
'tablet',
|
||||
'laptop',
|
||||
'laptop-l',
|
||||
'desktop'
|
||||
])
|
||||
|
||||
// Why: the z.ZodType<BrowserWorkspace> cast only aligns the static type — it
|
||||
// does NOT let new fields survive parsing. z.object strips unknown keys, so
|
||||
// every additive field must be listed below (optional+nullable) or it is
|
||||
// dropped on restore.
|
||||
const browserWorkspaceSchema: z.ZodType<BrowserWorkspace> = z.object({
|
||||
id: z.string(),
|
||||
worktreeId: z.string(),
|
||||
label: z.string().optional(),
|
||||
sessionProfileId: z.string().nullable().optional(),
|
||||
// Why: optional+nullable so pre-field sessions still validate; without this
|
||||
// zod strips the persisted partition on restore, and an isolated tab whose
|
||||
// profile mirror is stale at startup would silently fall back to the shared
|
||||
// default partition — reopening the storage leak (#6923) across restarts.
|
||||
sessionPartition: z.string().nullable().optional(),
|
||||
activePageId: z.string().nullable().optional(),
|
||||
pageIds: z.array(z.string()).optional(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
loading: z.boolean(),
|
||||
faviconUrl: z.string().nullable(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loadError: browserLoadErrorSchema.nullable(),
|
||||
createdAt: z.number()
|
||||
})
|
||||
|
||||
const browserPageSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
worktreeId: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
loading: z.boolean(),
|
||||
faviconUrl: z.string().nullable(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loadError: browserLoadErrorSchema.nullable(),
|
||||
createdAt: z.number(),
|
||||
// Why: explicit null marks a browser page as client-local even when its
|
||||
// worktree is remote-owned; older sessions omit it and keep inferred runtime.
|
||||
browserRuntimeEnvironmentId: z.string().nullable().optional(),
|
||||
// Why: optional+nullable so sessions persisted before viewport presets were
|
||||
// added still validate; without this, zod would strip the field during
|
||||
// restore and reset the user's chosen preset on every app restart.
|
||||
viewportPresetId: browserViewportPresetIdSchema.nullable().optional()
|
||||
})
|
||||
|
||||
const browserHistoryEntrySchema = z.object({
|
||||
url: z.string(),
|
||||
normalizedUrl: z.string(),
|
||||
title: z.string(),
|
||||
lastVisitedAt: z.number(),
|
||||
visitCount: z.number()
|
||||
})
|
||||
|
||||
const browserHistoryEntriesSchema = z
|
||||
.array(browserHistoryEntrySchema)
|
||||
.transform((entries) => normalizeBrowserHistoryEntries(entries))
|
||||
|
||||
// ─── Workspace session ──────────────────────────────────────────────
|
||||
|
||||
const terminalSurfaceTombstoneSchema = z.object({
|
||||
worktreeId: z.string(),
|
||||
parentTabId: terminalTabIdSchema,
|
||||
leafId: z.string(),
|
||||
ptyId: z.string(),
|
||||
incarnationId: z.string().min(1).max(128),
|
||||
retiredAt: z.number().finite().nonnegative()
|
||||
})
|
||||
|
||||
const worktreeIdSchema = z.string()
|
||||
|
||||
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
|
||||
activeRepoId: z.string().nullable(),
|
||||
activeWorkspaceKey: workspaceKeySchema.nullable().optional(),
|
||||
activeWorkspaceExecutionHostId: z
|
||||
.custom<ExecutionHostId>(
|
||||
(value) => typeof value === 'string' && Boolean(parseExecutionHostId(value))
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
activeWorktreeId: z.string().nullable(),
|
||||
activeTabId: z.string().nullable(),
|
||||
tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)),
|
||||
terminalLayoutsByTabId: z.record(terminalTabIdSchema, terminalLayoutSnapshotSchema),
|
||||
activeWorktreeIdsOnShutdown: z.array(z.string()).optional(),
|
||||
openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(),
|
||||
activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
markdownFrontmatterVisible: z.record(z.string(), z.boolean()).optional(),
|
||||
browserTabsByWorktree: z.record(z.string(), z.array(browserWorkspaceSchema)).optional(),
|
||||
browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(),
|
||||
activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(),
|
||||
browserUrlHistory: browserHistoryEntriesSchema.optional(),
|
||||
activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(),
|
||||
tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(),
|
||||
tabGroupLayouts: z.record(z.string(), tabGroupLayoutNodeSchema).optional(),
|
||||
activeGroupIdByWorktree: z.record(z.string(), z.string()).optional(),
|
||||
activeConnectionIdsAtShutdown: z.array(z.string()).optional(),
|
||||
remoteSessionIdsByTabId: z.record(terminalTabIdSchema, z.string()).optional(),
|
||||
// Why: the sort comparator in order-empty-query-worktrees.ts would produce
|
||||
// NaN (undefined sort order) if a corrupted session file carried NaN or
|
||||
// Infinity here. Parse leniently: drop individual bad entries rather than
|
||||
// failing the entire session. A strict record() rejection here would cause
|
||||
// parseWorkspaceSession to fall back to defaults for the ENTIRE session
|
||||
// (terminals, editors, browsers, layouts) on a single corrupted timestamp
|
||||
// — a blast radius far larger than "Cmd+J falls back to activity recency",
|
||||
// which is all this field gates.
|
||||
lastVisitedAtByWorktreeId: z
|
||||
.preprocess(
|
||||
(raw) => {
|
||||
if (raw == null || typeof raw !== 'object') {
|
||||
return raw
|
||||
}
|
||||
const cleaned: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) {
|
||||
cleaned[k] = v
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
},
|
||||
z.record(z.string(), z.number().finite().nonnegative())
|
||||
)
|
||||
.optional(),
|
||||
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(),
|
||||
sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema,
|
||||
terminalPtyIncarnationsByPaneKey: z.record(z.string(), z.string().min(1).max(128)).optional(),
|
||||
terminalTopologyRevisionByRepoId: z.record(z.string(), z.number().int().nonnegative()).optional(),
|
||||
terminalSurfaceTombstonesByPaneKey: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
worktreeId: z.string(),
|
||||
parentTabId: terminalTabIdSchema,
|
||||
leafId: z.string(),
|
||||
ptyId: z.string(),
|
||||
incarnationId: z.string().min(1).max(128),
|
||||
retiredAt: z.number().finite().nonnegative()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
activeRepoId: salvagedField('activeRepoId', z.string().nullable(), () => null),
|
||||
activeWorkspaceKey: salvagedOptional('activeWorkspaceKey', workspaceKeySchema.nullable()),
|
||||
activeWorkspaceExecutionHostId: salvagedOptional(
|
||||
'activeWorkspaceExecutionHostId',
|
||||
z
|
||||
.custom<ExecutionHostId>(
|
||||
(value) => typeof value === 'string' && Boolean(parseExecutionHostId(value))
|
||||
)
|
||||
.nullable()
|
||||
),
|
||||
activeWorktreeId: salvagedField('activeWorktreeId', z.string().nullable(), () => null),
|
||||
activeTabId: salvagedField('activeTabId', z.string().nullable(), () => null),
|
||||
tabsByWorktree: salvagedField(
|
||||
'tabsByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, salvagingArray(terminalTabSchema)),
|
||||
() => ({})
|
||||
),
|
||||
terminalLayoutsByTabId: salvagedField(
|
||||
'terminalLayoutsByTabId',
|
||||
salvagingRecord(terminalTabIdSchema, terminalLayoutSnapshotSchema),
|
||||
() => ({})
|
||||
),
|
||||
activeWorktreeIdsOnShutdown: salvagedOptional(
|
||||
'activeWorktreeIdsOnShutdown',
|
||||
salvagingArray(worktreeIdSchema)
|
||||
),
|
||||
openFilesByWorktree: salvagedOptional(
|
||||
'openFilesByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, salvagingArray(persistedOpenFileSchema))
|
||||
),
|
||||
activeFileIdByWorktree: salvagedOptional(
|
||||
'activeFileIdByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, z.string().nullable())
|
||||
),
|
||||
markdownFrontmatterVisible: salvagedOptional(
|
||||
'markdownFrontmatterVisible',
|
||||
salvagingRecord(z.string(), z.boolean())
|
||||
),
|
||||
browserTabsByWorktree: salvagedOptional(
|
||||
'browserTabsByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, salvagingArray(browserWorkspaceSchema))
|
||||
),
|
||||
browserPagesByWorkspace: salvagedOptional(
|
||||
'browserPagesByWorkspace',
|
||||
salvagingRecord(z.string(), salvagingArray(browserPageSchema))
|
||||
),
|
||||
activeBrowserTabIdByWorktree: salvagedOptional(
|
||||
'activeBrowserTabIdByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, z.string().nullable())
|
||||
),
|
||||
activeTabTypeByWorktree: salvagedOptional(
|
||||
'activeTabTypeByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema)
|
||||
),
|
||||
browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema),
|
||||
activeTabIdByWorktree: salvagedOptional(
|
||||
'activeTabIdByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, z.string().nullable())
|
||||
),
|
||||
unifiedTabs: salvagedOptional(
|
||||
'unifiedTabs',
|
||||
salvagingRecord(worktreeIdSchema, salvagingArray(tabSchema))
|
||||
),
|
||||
tabGroups: salvagedOptional(
|
||||
'tabGroups',
|
||||
salvagingRecord(worktreeIdSchema, salvagingArray(tabGroupSchema))
|
||||
),
|
||||
tabGroupLayouts: salvagedOptional(
|
||||
'tabGroupLayouts',
|
||||
salvagingRecord(worktreeIdSchema, tabGroupLayoutNodeSchema)
|
||||
),
|
||||
activeGroupIdByWorktree: salvagedOptional(
|
||||
'activeGroupIdByWorktree',
|
||||
salvagingRecord(worktreeIdSchema, z.string())
|
||||
),
|
||||
activeConnectionIdsAtShutdown: salvagedOptional(
|
||||
'activeConnectionIdsAtShutdown',
|
||||
salvagingArray(z.string())
|
||||
),
|
||||
remoteSessionIdsByTabId: salvagedOptional(
|
||||
'remoteSessionIdsByTabId',
|
||||
salvagingRecord(terminalTabIdSchema, z.string())
|
||||
),
|
||||
// Why: the sort comparator in order-empty-query-worktrees.ts would produce NaN
|
||||
// (undefined sort order) from a NaN or Infinity persisted here.
|
||||
lastVisitedAtByWorktreeId: salvagedOptional(
|
||||
'lastVisitedAtByWorktreeId',
|
||||
salvagingRecord(worktreeIdSchema, z.number().finite().nonnegative())
|
||||
),
|
||||
defaultTerminalTabsAppliedByWorktreeId: salvagedOptional(
|
||||
'defaultTerminalTabsAppliedByWorktreeId',
|
||||
salvagingRecord(worktreeIdSchema, z.literal(true))
|
||||
),
|
||||
sleepingAgentSessionsByPaneKey: salvagedOptional(
|
||||
'sleepingAgentSessionsByPaneKey',
|
||||
sleepingAgentSessionsByPaneKeySchema
|
||||
),
|
||||
terminalPtyIncarnationsByPaneKey: salvagedOptional(
|
||||
'terminalPtyIncarnationsByPaneKey',
|
||||
salvagingRecord(z.string(), z.string().min(1).max(128))
|
||||
),
|
||||
terminalTopologyRevisionByRepoId: salvagedOptional(
|
||||
'terminalTopologyRevisionByRepoId',
|
||||
salvagingRecord(z.string(), z.number().int().nonnegative())
|
||||
),
|
||||
terminalSurfaceTombstonesByPaneKey: salvagedOptional(
|
||||
'terminalSurfaceTombstonesByPaneKey',
|
||||
salvagingRecord(z.string(), terminalSurfaceTombstoneSchema)
|
||||
)
|
||||
})
|
||||
|
||||
export type ParsedWorkspaceSession =
|
||||
| { ok: true; value: WorkspaceSessionState }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** Why: keep the error compact — a zod issue dump is noisy and most of the time
|
||||
* only the first divergent field is actionable for debugging. */
|
||||
export function describeWorkspaceSessionError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0]
|
||||
const path = firstIssue?.path.join('.') || '<root>'
|
||||
return `${path}: ${firstIssue?.message ?? 'invalid session'}`
|
||||
}
|
||||
|
||||
export const WORKSPACE_SESSION_UNVALIDATABLE = '<root>: session could not be validated'
|
||||
|
||||
/** safeParse, or null when the validator itself could not run.
|
||||
* Why: safeParse is documented not to throw, but a payload holding hundreds of
|
||||
* thousands of bad records overflows the stack while zod materializes an issue
|
||||
* per field. This parse runs in the Store constructor, so an escaping RangeError
|
||||
* is a launch failure the user cannot recover from without deleting their
|
||||
* profile — exactly the "never throw into main" contract at the top of this file. */
|
||||
export function safeParseWorkspaceSession(
|
||||
raw: unknown
|
||||
): ReturnType<typeof workspaceSessionStateSchema.safeParse> | null {
|
||||
try {
|
||||
return workspaceSessionStateSchema.safeParse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate raw JSON as a WorkspaceSessionState. Returns a discriminated union
|
||||
* so callers can fall back to defaults on failure without a try/catch. */
|
||||
export function parseWorkspaceSession(raw: unknown): ParsedWorkspaceSession {
|
||||
const result = workspaceSessionStateSchema.safeParse(raw)
|
||||
const result = safeParseWorkspaceSession(raw)
|
||||
if (!result) {
|
||||
return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE }
|
||||
}
|
||||
if (result.success) {
|
||||
return { ok: true, value: result.data }
|
||||
}
|
||||
// Why: keep the error compact — a zod issue dump is noisy and most of the
|
||||
// time only the first divergent field is actionable for debugging.
|
||||
const firstIssue = result.error.issues[0]
|
||||
const path = firstIssue?.path.join('.') || '<root>'
|
||||
return { ok: false, error: `${path}: ${firstIssue?.message ?? 'invalid session'}` }
|
||||
return { ok: false, error: describeWorkspaceSessionError(result.error) }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RESUMABLE_TUI_AGENTS
|
||||
} from './agent-session-resume'
|
||||
import { isValidTerminalTabId } from './terminal-tab-id'
|
||||
import { salvagingRecord } from './zod-salvage'
|
||||
|
||||
const terminalTabIdSchema = z
|
||||
.string()
|
||||
@@ -108,23 +109,8 @@ const sleepingAgentSessionRecordSchema = z
|
||||
{ message: 'provider session is not resumable for this agent', path: ['providerSession'] }
|
||||
)
|
||||
|
||||
export const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {
|
||||
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleaned: Record<string, z.infer<typeof sleepingAgentSessionRecordSchema>> = Object.create(
|
||||
null
|
||||
)
|
||||
for (const [paneKey, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (isUnsafeObjectKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const parsed = sleepingAgentSessionRecordSchema.safeParse(value)
|
||||
if (parsed.success && parsed.data.paneKey === paneKey) {
|
||||
cleaned[paneKey] = parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(cleaned).length > 0 ? { ...cleaned } : undefined
|
||||
}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional())
|
||||
export const sleepingAgentSessionsByPaneKeySchema = salvagingRecord(
|
||||
z.string().refine((paneKey) => !isUnsafeObjectKey(paneKey)),
|
||||
sleepingAgentSessionRecordSchema,
|
||||
(paneKey, record) => record.paneKey === paneKey
|
||||
).transform((records) => (Object.keys(records).length > 0 ? records : undefined))
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('parseWorkspaceSession terminal fields', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty terminal startup cwd values', () => {
|
||||
it('drops a tab with an empty startup cwd instead of failing the session', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'wt',
|
||||
@@ -67,6 +67,9 @@ describe('parseWorkspaceSession terminal fields', () => {
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.tabsByWorktree.wt).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Zod transforms lack paths, so salvage combinators track bounded diagnostics during parsing.
|
||||
import { z } from 'zod'
|
||||
|
||||
const MAX_REPORTED_SALVAGE_PATHS = 100
|
||||
|
||||
type DropCollector = { paths: string[]; count: number }
|
||||
|
||||
let dropCollector: DropCollector | null = null
|
||||
const dropPath: (string | number)[] = []
|
||||
|
||||
/** Run a synchronous parse while collecting its salvage count and example paths. */
|
||||
export function collectSalvageDrops<T>(parse: () => T): {
|
||||
value: T
|
||||
droppedPaths: string[]
|
||||
droppedCount: number
|
||||
} {
|
||||
const previousCollector = dropCollector
|
||||
const previousPath = [...dropPath]
|
||||
const collector: DropCollector = { paths: [], count: 0 }
|
||||
dropCollector = collector
|
||||
dropPath.length = 0
|
||||
try {
|
||||
const value = parse()
|
||||
return { value, droppedPaths: collector.paths, droppedCount: collector.count }
|
||||
} finally {
|
||||
dropCollector = previousCollector
|
||||
dropPath.splice(0, dropPath.length, ...previousPath)
|
||||
}
|
||||
}
|
||||
|
||||
function reportDrop(segment: string | number): void {
|
||||
if (!dropCollector) {
|
||||
return
|
||||
}
|
||||
dropCollector.count += 1
|
||||
if (dropCollector.paths.length < MAX_REPORTED_SALVAGE_PATHS) {
|
||||
dropCollector.paths.push([...dropPath, segment].join('.'))
|
||||
}
|
||||
}
|
||||
|
||||
function inEntry<T>(segment: string | number, parse: () => T): T {
|
||||
dropPath.push(segment)
|
||||
try {
|
||||
return parse()
|
||||
} finally {
|
||||
dropPath.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function parseEntry<T extends z.ZodType>(
|
||||
schema: T,
|
||||
raw: unknown
|
||||
): { success: true; data: z.output<T> } | { success: false } {
|
||||
try {
|
||||
const parsed = schema.safeParse(raw)
|
||||
return parsed.success ? { success: true, data: parsed.data as z.output<T> } : { success: false }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Array that drops the elements it cannot parse instead of failing. */
|
||||
export function salvagingArray<T extends z.ZodType>(item: T): z.ZodType<z.output<T>[], unknown> {
|
||||
return z.array(z.unknown()).transform((values) =>
|
||||
values.flatMap((value, index) => {
|
||||
const parsed = inEntry(index, () => parseEntry(item, value))
|
||||
if (parsed.success) {
|
||||
return [parsed.data]
|
||||
}
|
||||
reportDrop(index)
|
||||
return []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Record that drops entries with invalid keys or values instead of failing. */
|
||||
export function salvagingRecord<K extends z.ZodType<string>, V extends z.ZodType>(
|
||||
key: K,
|
||||
value: V,
|
||||
accepts?: (key: string, value: z.output<V>) => boolean
|
||||
): z.ZodType<Record<string, z.output<V>>, unknown> {
|
||||
return z.record(z.string(), z.unknown()).transform((entries) => {
|
||||
// Why: null prototype so a persisted '__proto__' key cannot poison the result.
|
||||
const kept: Record<string, z.output<V>> = Object.create(null)
|
||||
for (const [entryKey, entryValue] of Object.entries(entries)) {
|
||||
const parsed = parseEntry(key, entryKey).success
|
||||
? inEntry(entryKey, () => parseEntry(value, entryValue))
|
||||
: null
|
||||
if (parsed?.success && (!accepts || accepts(entryKey, parsed.data))) {
|
||||
kept[entryKey] = parsed.data
|
||||
continue
|
||||
}
|
||||
reportDrop(entryKey)
|
||||
}
|
||||
return { ...kept }
|
||||
})
|
||||
}
|
||||
|
||||
function salvaged(name: string, schema: z.ZodType, fallback: () => unknown): z.ZodType {
|
||||
return z.unknown().transform((raw, ctx) => {
|
||||
if (raw === undefined) {
|
||||
ctx.addIssue({ code: 'custom', message: 'required', input: raw })
|
||||
return z.NEVER
|
||||
}
|
||||
const parsed = inEntry(name, () => parseEntry(schema, raw))
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
}
|
||||
reportDrop(name)
|
||||
return fallback()
|
||||
})
|
||||
}
|
||||
|
||||
/** Fall back for an invalid required field; absence stays fatal for foreign payloads. */
|
||||
export function salvagedField<T extends z.ZodType>(
|
||||
name: string,
|
||||
schema: T,
|
||||
fallback: () => z.output<T>
|
||||
): z.ZodType<z.output<T>, unknown> {
|
||||
return salvaged(name, schema, fallback) as z.ZodType<z.output<T>, unknown>
|
||||
}
|
||||
|
||||
/** Drop an invalid optional field without reporting legitimate absence. */
|
||||
export function salvagedOptional<T extends z.ZodType>(
|
||||
name: string,
|
||||
schema: T
|
||||
): z.ZodType<z.output<T> | undefined, unknown> {
|
||||
return salvaged(name, schema, () => undefined).optional() as z.ZodType<
|
||||
z.output<T> | undefined,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { ensureTerminalVisible, getActiveWorktreeId, waitForSessionReady } from './helpers/store'
|
||||
import { TEST_REPO_PATH_FILE } from './global-setup'
|
||||
|
||||
const FIRST_SURVIVOR_TITLE = 'STA-3604 survivor one'
|
||||
const SECOND_SURVIVOR_TITLE = 'STA-3604 survivor two'
|
||||
const CORRUPT_TAB_ID = 'sta-3604-corrupt-tab'
|
||||
|
||||
type PersistedData = {
|
||||
workspaceSession?: {
|
||||
tabsByWorktree?: Record<string, Record<string, unknown>[]>
|
||||
unifiedTabs?: Record<string, Record<string, unknown>[]>
|
||||
}
|
||||
}
|
||||
|
||||
function persistedDataPath(userDataDir: string): string {
|
||||
return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json')
|
||||
}
|
||||
|
||||
function injectTruncatedTab(userDataDir: string, worktreeId: string, startupCwd: string): void {
|
||||
const dataPath = persistedDataPath(userDataDir)
|
||||
const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData
|
||||
const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId]
|
||||
if (!tabs) {
|
||||
throw new Error('Persisted terminal tabs were unavailable for corruption seeding')
|
||||
}
|
||||
tabs.push({
|
||||
id: CORRUPT_TAB_ID,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Truncated terminal',
|
||||
sortOrder: 999,
|
||||
generation: 3,
|
||||
startupCwd
|
||||
})
|
||||
writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function persistedSessionEvidence(
|
||||
userDataDir: string,
|
||||
worktreeId: string
|
||||
): { corruptLegacyTabPresent: boolean; unifiedTabIds: string[] } {
|
||||
const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData
|
||||
const legacyTabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] ?? []
|
||||
const unifiedTabs = data.workspaceSession?.unifiedTabs?.[worktreeId] ?? []
|
||||
return {
|
||||
corruptLegacyTabPresent: legacyTabs.some((tab) => tab.id === CORRUPT_TAB_ID),
|
||||
unifiedTabIds: unifiedTabs
|
||||
.map((tab) => tab.id)
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSurvivingTabsVisible(page: Page): Promise<void> {
|
||||
for (const title of [FIRST_SURVIVOR_TITLE, SECOND_SURVIVOR_TITLE]) {
|
||||
await expect(
|
||||
page.locator('[data-testid="sortable-tab"]').filter({ hasText: title })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps valid terminal tabs visible after a corrupt sibling record on restart', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
const repoPath = existsSync(TEST_REPO_PATH_FILE)
|
||||
? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim()
|
||||
: ''
|
||||
test.skip(!repoPath || !existsSync(repoPath), 'Seeded E2E repository is unavailable')
|
||||
|
||||
const session = createRestartSession(testInfo)
|
||||
let firstApp: ElectronApplication | null = null
|
||||
let secondApp: ElectronApplication | null = null
|
||||
|
||||
try {
|
||||
const first = await session.launch()
|
||||
firstApp = first.app
|
||||
const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
|
||||
await waitForSessionReady(first.page)
|
||||
await ensureTerminalVisible(first.page)
|
||||
|
||||
const survivorIds = await first.page.evaluate(
|
||||
({ worktreeId, firstTitle, secondTitle }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const firstTab = state.tabsByWorktree[worktreeId]?.[0]
|
||||
if (!firstTab) {
|
||||
throw new Error('Initial terminal tab unavailable')
|
||||
}
|
||||
state.setTabCustomTitle(firstTab.id, firstTitle)
|
||||
const secondTab = state.createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
state.setTabCustomTitle(secondTab.id, secondTitle)
|
||||
return [firstTab.id, secondTab.id]
|
||||
},
|
||||
{ worktreeId, firstTitle: FIRST_SURVIVOR_TITLE, secondTitle: SECOND_SURVIVOR_TITLE }
|
||||
)
|
||||
await expectSurvivingTabsVisible(first.page)
|
||||
|
||||
await session.close(firstApp)
|
||||
firstApp = null
|
||||
injectTruncatedTab(session.userDataDir, worktreeId, repoPath)
|
||||
|
||||
const second = await session.launch()
|
||||
secondApp = second.app
|
||||
await waitForSessionReady(second.page)
|
||||
await expect.poll(() => getActiveWorktreeId(second.page), { timeout: 15_000 }).toBe(worktreeId)
|
||||
await ensureTerminalVisible(second.page)
|
||||
|
||||
await expectSurvivingTabsVisible(second.page)
|
||||
await expect(second.page.locator('.xterm').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect
|
||||
.poll(() => persistedSessionEvidence(session.userDataDir, worktreeId), {
|
||||
timeout: 30_000
|
||||
})
|
||||
.toEqual({ corruptLegacyTabPresent: false, unifiedTabIds: survivorIds })
|
||||
|
||||
await testInfo.attach('sta-3604-valid-tabs-after-corrupt-session-restart.png', {
|
||||
body: await second.page.screenshot(),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
await expectSurvivingTabsVisible(second.page)
|
||||
} finally {
|
||||
for (const app of [secondApp, firstApp]) {
|
||||
if (app) {
|
||||
await session.close(app).catch(() => {})
|
||||
}
|
||||
}
|
||||
await session.dispose()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user