diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..0e507090c1f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 + sleepingAgentSessionsByPaneKey?: Record + } + workspaceSessionsByHostId?: Record< + string, + { tabsByWorktree?: Record } + > + } + + // 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 { + vi.useFakeTimers() + try { + const store = await createStore() + vi.advanceTimersByTime(10_000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + } + + async function canonicalize(fixture: Record): Promise { + 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 + 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() }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 12856638d29..c2e36d3ce9d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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> { +): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return {} + return { partitions: {}, repaired: raw !== undefined } } + let repaired = false const partitions: Partial> = {} for (const [key, value] of Object.entries(raw as Record)) { 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( diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 787fd320920..78a7a31b358 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1651,21 +1651,25 @@ export const createBrowserSlice: StateCreator = } 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 } = diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 3d9a759e1d2..06cfa17bfc8 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -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' diff --git a/src/renderer/src/store/slices/tab-group-reference-repair.ts b/src/renderer/src/store/slices/tab-group-reference-repair.ts new file mode 100644 index 00000000000..42a742cc724 --- /dev/null +++ b/src/renderer/src/store/slices/tab-group-reference-repair.ts @@ -0,0 +1,78 @@ +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import { createBrowserUuid } from '@/lib/browser-uuid' + +function collectLayoutGroupIds(node: TabGroupLayoutNode, groupIds: Set): 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() + collectLayoutGroupIds(first, laidOutGroupIds) + return groups + .filter((group) => !laidOutGroupIds.has(group.id)) + .reduce( + (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, + groupsByWorktree: Record, + activeGroupIdByWorktree: Record, + layoutByWorktree: Record +): void { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const groups = groupsByWorktree[worktreeId] ?? [] + const owningGroupIdByTabId = new Map() + 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 } + } + } +} diff --git a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts index f927db73b8d..dca253f4733 100644 --- a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts @@ -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']) + }) +}) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index e6b1ae2474e..2fb85ab3808 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -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, diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts new file mode 100644 index 00000000000..19aaeb0ae82 --- /dev/null +++ b/src/shared/workspace-session-browser-schema.ts @@ -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 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 = 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) +) diff --git a/src/shared/workspace-session-salvage.test.ts b/src/shared/workspace-session-salvage.test.ts new file mode 100644 index 00000000000..876f8bb7305 --- /dev/null +++ b/src/shared/workspace-session-salvage.test.ts @@ -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 = {}): Record { + 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 = {}): Record { + 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 = {} + 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 = {} + 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 = {} + 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 = { 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({}) + } + }) +}) diff --git a/src/shared/workspace-session-salvage.ts b/src/shared/workspace-session-salvage.ts new file mode 100644 index 00000000000..66b6a6374e9 --- /dev/null +++ b/src/shared/workspace-session-salvage.ts @@ -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 } +} diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 72c7f40d2a5..9a1291a9d67 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -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, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 7a24ad57505..446e19270da 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -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 = 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 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 = 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 = z.object({ - activeRepoId: z.string().nullable(), - activeWorkspaceKey: workspaceKeySchema.nullable().optional(), - activeWorkspaceExecutionHostId: z - .custom( - (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 = {} - for (const [k, v] of Object.entries(raw as Record)) { - 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( + (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('.') || '' + return `${path}: ${firstIssue?.message ?? 'invalid session'}` +} + +export const WORKSPACE_SESSION_UNVALIDATABLE = ': 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 | 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('.') || '' - return { ok: false, error: `${path}: ${firstIssue?.message ?? 'invalid session'}` } + return { ok: false, error: describeWorkspaceSessionError(result.error) } } diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index eb48de31e4b..4ee620efa82 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -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> = Object.create( - null - ) - for (const [paneKey, value] of Object.entries(raw as Record)) { - 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)) diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 367ee953f78..5878b70a1b9 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -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([]) + } }) }) diff --git a/src/shared/zod-salvage.ts b/src/shared/zod-salvage.ts new file mode 100644 index 00000000000..64860eab10a --- /dev/null +++ b/src/shared/zod-salvage.ts @@ -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(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(segment: string | number, parse: () => T): T { + dropPath.push(segment) + try { + return parse() + } finally { + dropPath.pop() + } +} + +function parseEntry( + schema: T, + raw: unknown +): { success: true; data: z.output } | { success: false } { + try { + const parsed = schema.safeParse(raw) + return parsed.success ? { success: true, data: parsed.data as z.output } : { success: false } + } catch { + return { success: false } + } +} + +/** Array that drops the elements it cannot parse instead of failing. */ +export function salvagingArray(item: T): z.ZodType[], 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, V extends z.ZodType>( + key: K, + value: V, + accepts?: (key: string, value: z.output) => boolean +): z.ZodType>, 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> = 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( + name: string, + schema: T, + fallback: () => z.output +): z.ZodType, unknown> { + return salvaged(name, schema, fallback) as z.ZodType, unknown> +} + +/** Drop an invalid optional field without reporting legitimate absence. */ +export function salvagedOptional( + name: string, + schema: T +): z.ZodType | undefined, unknown> { + return salvaged(name, schema, () => undefined).optional() as z.ZodType< + z.output | undefined, + unknown + > +} diff --git a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts new file mode 100644 index 00000000000..50c3d153101 --- /dev/null +++ b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts @@ -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[]> + unifiedTabs?: Record[]> + } +} + +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 { + 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() + } +})