fix(persistence): salvage corrupt workspace session entries (#13431)

This commit is contained in:
Jinwoo Hong
2026-08-09 21:39:24 -07:00
committed by GitHub
parent 4cca31f36f
commit d9e84d88ef
16 changed files with 1505 additions and 224 deletions
+159 -3
View File
@@ -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
View File
@@ -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(
+19 -15
View File
@@ -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({})
}
})
})
+34
View File
@@ -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 }
}
+50 -6
View File
@@ -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,
+168 -161
View File
@@ -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([])
}
})
})
+132
View File
@@ -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()
}
})