fix(mobile): bound deletion overflow evidence

This commit is contained in:
Brennan Benson
2026-07-31 19:31:04 -07:00
parent e9e7dfbf3f
commit 4d62514ea5
4 changed files with 213 additions and 84 deletions
+15 -14
View File
@@ -6,7 +6,6 @@ import type {
} from '../shared/types'
const MAX_TOMBSTONES = 512
export const MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 24 * 60 * 60 * 1000
export const MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const MAX_HOST_IDS = 32
const MAX_TAB_OWNERS = 256
@@ -47,10 +46,10 @@ export function boundDeletedFolderTombstoneEvidence(
}
}
export function getDeletedFolderTombstoneEvictionKeys(
export function getDeletedFolderTombstoneEviction(
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
now: number
): WorkspaceKey[] {
): { workspaceKeys: WorkspaceKey[]; overflowExpiresAt: number | undefined } {
const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) =>
tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : []
)
@@ -60,21 +59,23 @@ export function getDeletedFolderTombstoneEvictionKeys(
const expiredKeys = new Set(expired.map(({ workspaceKey }) => workspaceKey))
const retained = entries.filter(({ workspaceKey }) => !expiredKeys.has(workspaceKey))
const excess = Math.max(0, retained.length - MAX_TOMBSTONES)
if (excess === 0) {
return expired.map(({ workspaceKey }) => workspaceKey)
}
const softEvictions = retained
.filter(
({ tombstone }) => now - tombstone.deletedAt >= MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
)
const capEvictions = retained
.sort((left, right) =>
left.tombstone.deletedAt === right.tombstone.deletedAt
? left.index - right.index
: left.tombstone.deletedAt - right.tombstone.deletedAt
)
.slice(0, excess)
return [
...expired.map(({ workspaceKey }) => workspaceKey),
...softEvictions.map(({ workspaceKey }) => workspaceKey)
]
const overflowExpiresAt = capEvictions.reduce<number | undefined>(
(latest, { tombstone }) =>
Math.max(latest ?? 0, tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS),
undefined
)
return {
workspaceKeys: [
...expired.map(({ workspaceKey }) => workspaceKey),
...capEvictions.map(({ workspaceKey }) => workspaceKey)
],
overflowExpiresAt
}
}
+52 -4
View File
@@ -5216,9 +5216,12 @@ describe('Store', () => {
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined()
expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined()
expect(
(readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
).toBe(deletedAt + 1 + 30 * 24 * 60 * 60 * 1000)
})
it('retains recent deletion fences before applying the tombstone soft cap', async () => {
it('hard-bounds recent deletion fences with conservative overflow evidence', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Tombstone churn',
@@ -5239,8 +5242,11 @@ describe('Store', () => {
}
store.flush()
let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
expect(Object.keys(tombstones ?? {})).toHaveLength(514)
expect(tombstones?.[workspaceKeys[0]!]).toBeDefined()
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
expect(
(readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
).toBeGreaterThan(Date.now())
vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z'))
const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' })
@@ -5269,6 +5275,7 @@ describe('Store', () => {
tabConnectionIdsByHostId: {}
}
}
state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Date.now() - 1
writeDataFile(state)
vi.useFakeTimers()
@@ -5280,7 +5287,9 @@ describe('Store', () => {
vi.useRealTimers()
}
expect((readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones).toEqual({})
const persisted = readDataFile() as PersistedState
expect(persisted.deletedFolderWorkspaceSessionTombstones).toEqual({})
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeUndefined()
})
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
@@ -6850,6 +6859,7 @@ describe('Store', () => {
const staleSession = {
...getDefaultWorkspaceSession(),
activeWorkspaceKey: workspaceKey,
activeConnectionIdsAtShutdown: [connectionId],
tabsByWorktree: {
[workspaceKey]: [makeTerminalTab({ id: 'rapid-churn-stale-tab', worktreeId: workspaceKey })]
}
@@ -6863,6 +6873,11 @@ describe('Store', () => {
})
expect(store.removeFolderWorkspace(churned.id)).toBe(true)
}
const persisted = (store as unknown as { state: PersistedState }).state
expect(persisted.deletedFolderWorkspaceSessionTombstones?.[workspaceKey]).toBeUndefined()
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeGreaterThan(
Date.now()
)
store.setWorkspaceSession(staleSession, hostId)
store.upsertSshRemotePtyLease({
@@ -6875,9 +6890,42 @@ describe('Store', () => {
})
expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toBeUndefined()
expect(store.getWorkspaceSession(hostId).activeConnectionIdsAtShutdown).toBeUndefined()
expect(store.getSshRemotePtyLeases()).toEqual([])
})
it('batches deletion evidence commits for folders with many tabs', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Many tabs',
parentPath: '/workspace/many-tabs',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id })
const workspaceKey = folderWorkspaceKey(workspace.id)
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[workspaceKey]: Array.from({ length: 256 }, (_, index) =>
makeTerminalTab({ id: `many-tabs-${index}`, worktreeId: workspaceKey })
)
}
})
const tombstoneSetter = vi.spyOn(
store as unknown as {
setDeletedFolderWorkspaceSessionTombstone: (
workspaceKey: string,
tombstone: unknown
) => void
},
'setDeletedFolderWorkspaceSessionTombstone'
)
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
expect(tombstoneSetter).toHaveBeenCalledTimes(2)
})
it('bounds detailed tab-owner evidence for one deleted folder', async () => {
const store = await createStore()
const hostId = toRuntimeExecutionHostId('tab-evidence-runtime')
+145 -66
View File
@@ -90,9 +90,8 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
import {
boundDeletedFolderTombstoneEvidence,
getDeletedFolderTombstoneEvictionKeys,
MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS,
MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
getDeletedFolderTombstoneEviction,
MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
} from './deleted-folder-session-tombstones'
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
@@ -2865,17 +2864,28 @@ function backfillFolderScopeConnectionIds(state: PersistedState): {
function normalizeDeletedFolderWorkspaceSessionTombstones(
value: unknown,
liveFolderIds: ReadonlySet<string>
liveFolderIds: ReadonlySet<string>,
rawOverflowExpiresAt: unknown
): {
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>
overflowExpiresAt: number | undefined
changed: boolean
} {
const now = Date.now()
const tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']> = {}
let changed = value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))
let overflowExpiresAt =
typeof rawOverflowExpiresAt === 'number' &&
Number.isFinite(rawOverflowExpiresAt) &&
rawOverflowExpiresAt > now
? Math.min(rawOverflowExpiresAt, now + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS)
: undefined
if (rawOverflowExpiresAt !== undefined && overflowExpiresAt !== rawOverflowExpiresAt) {
changed = true
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { tombstones, changed }
return { tombstones, overflowExpiresAt, changed }
}
const now = Date.now()
for (const [workspaceKey, rawTombstone] of Object.entries(value)) {
const scope = parseWorkspaceKey(workspaceKey)
if (
@@ -2896,7 +2906,7 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
raw.deletedAt >= 0 &&
raw.deletedAt <= now
? raw.deletedAt
: now - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
: now
if (now - deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) {
changed = true
continue
@@ -2945,11 +2955,13 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
changed = true
}
}
for (const workspaceKey of getDeletedFolderTombstoneEvictionKeys(tombstones, now)) {
const eviction = getDeletedFolderTombstoneEviction(tombstones, now)
for (const workspaceKey of eviction.workspaceKeys) {
delete tombstones[workspaceKey]
changed = true
}
return { tombstones, changed }
overflowExpiresAt = Math.max(overflowExpiresAt ?? 0, eviction.overflowExpiresAt ?? 0) || undefined
return { tombstones, overflowExpiresAt, changed }
}
function deleteRemovedTerminalScrollbackSnapshots(
@@ -3482,7 +3494,8 @@ export class Store {
)
const normalizedDeletedFolderTombstones = normalizeDeletedFolderWorkspaceSessionTombstones(
parsed.deletedFolderWorkspaceSessionTombstones,
new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id))
new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id)),
parsed.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
)
if (normalizedDeletedFolderTombstones.changed) {
this.loadNeedsSave = true
@@ -3537,6 +3550,8 @@ export class Store {
projectGroups: normalizedProjectGroups,
folderWorkspaces: normalizedFolderWorkspaces,
deletedFolderWorkspaceSessionTombstones: normalizedDeletedFolderTombstones.tombstones,
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt:
normalizedDeletedFolderTombstones.overflowExpiresAt,
worktreeLineageById: parsed.worktreeLineageById ?? {},
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
parsed.mobileClientTabSelectionsByDeviceId
@@ -4588,6 +4603,12 @@ export class Store {
hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])],
tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {}
})
const tabOwners: {
hostId: ExecutionHostId
tabId: string
workspaceKey: string
connectionId: string | null
}[] = []
for (const { hostId, session } of partitions) {
const tabIds = new Set<string>()
const tabPtyIdByTabId = new Map<string, string>()
@@ -4642,16 +4663,18 @@ export class Store {
tabPtyIdByTabId.get(tabId) ??
layoutPtyId ??
''
this.rememberDeletedFolderTabOwner(
tabOwners.push({
hostId,
tabId,
workspaceKey,
this.resolveWorkspaceSessionPtyConnectionId(hostId, workspaceKey, ptyId) ??
connectionId:
this.resolveWorkspaceSessionPtyConnectionId(hostId, workspaceKey, ptyId) ??
tabConnectionIdByTabId.get(tabId) ??
connectionId
)
})
}
}
this.rememberDeletedFolderTabOwners(tabOwners)
}
private workspaceSessionReferencesOwner(
@@ -4706,38 +4729,55 @@ export class Store {
)
}
private rememberDeletedFolderTabOwner(
hostId: ExecutionHostId,
tabId: string,
workspaceKey: string,
connectionId: string | null
private rememberDeletedFolderTabOwners(
records: readonly {
hostId: ExecutionHostId
tabId: string
workspaceKey: string
connectionId: string | null
}[]
): void {
const ownersByTabId = this.deletedFolderOwnersByHostAndTabId.get(hostId) ?? new Map()
const owners = ownersByTabId.get(tabId) ?? new Map<string, string | null>()
owners.set(workspaceKey, connectionId ?? owners.get(workspaceKey) ?? null)
ownersByTabId.set(tabId, owners)
this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId)
const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId }
const tabConnectionIds = { ...tabConnectionIdsByHostId[hostId] }
const retainedConnectionId = connectionId ?? tabConnectionIds[tabId] ?? null
delete tabConnectionIds[tabId]
tabConnectionIds[tabId] = retainedConnectionId
delete tabConnectionIdsByHostId[hostId]
tabConnectionIdsByHostId[hostId] = tabConnectionIds
const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null
const retainedHostIds = (prior?.hostIds ?? []).filter(
(retainedHostId) => retainedHostId !== hostId && retainedHostId !== connectionHostId
)
this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, {
connectionId: prior?.connectionId ?? connectionId,
deletedAt: prior?.deletedAt ?? Date.now(),
evidenceTruncated: prior?.evidenceTruncated ?? false,
hostIds: [
...new Set([...retainedHostIds, hostId, ...(connectionHostId ? [connectionHostId] : [])])
],
tabConnectionIdsByHostId
})
const pendingByWorkspaceKey = new Map<string, DeletedFolderWorkspaceSessionTombstone>()
for (const { hostId, tabId, workspaceKey, connectionId } of records) {
let pending = pendingByWorkspaceKey.get(workspaceKey)
if (!pending) {
const prior =
this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
pending = {
connectionId: prior?.connectionId ?? connectionId,
deletedAt: prior?.deletedAt ?? Date.now(),
evidenceTruncated: prior?.evidenceTruncated ?? false,
hostIds: [...(prior?.hostIds ?? [])],
tabConnectionIdsByHostId: Object.fromEntries(
Object.entries(prior?.tabConnectionIdsByHostId ?? {}).map(([priorHostId, tabs]) => [
priorHostId,
{ ...tabs }
])
)
}
pendingByWorkspaceKey.set(workspaceKey, pending)
}
pending.connectionId ??= connectionId
const tabConnectionIds = pending.tabConnectionIdsByHostId[hostId] ?? {}
const retainedConnectionId = connectionId ?? tabConnectionIds[tabId] ?? null
delete tabConnectionIds[tabId]
tabConnectionIds[tabId] = retainedConnectionId
delete pending.tabConnectionIdsByHostId[hostId]
pending.tabConnectionIdsByHostId[hostId] = tabConnectionIds
const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null
pending.hostIds = [
...new Set([
...pending.hostIds.filter(
(retainedHostId) => retainedHostId !== hostId && retainedHostId !== connectionHostId
),
hostId,
...(connectionHostId ? [connectionHostId] : [])
])
]
}
for (const [workspaceKey, tombstone] of pendingByWorkspaceKey) {
this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
}
}
private setDeletedFolderWorkspaceSessionTombstone(
@@ -4750,31 +4790,51 @@ export class Store {
tombstones[workspaceKey as WorkspaceKey] = tombstone
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
for (const evictedWorkspaceKey of getDeletedFolderTombstoneEvictionKeys(
tombstones,
Date.now()
)) {
const eviction = getDeletedFolderTombstoneEviction(tombstones, Date.now())
for (const evictedWorkspaceKey of eviction.workspaceKeys) {
delete tombstones[evictedWorkspaceKey]
this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey)
}
if (eviction.overflowExpiresAt) {
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Math.max(
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0,
eviction.overflowExpiresAt
)
}
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
}
private pruneDeletedFolderWorkspaceSessionTombstones(): void {
const now = Date.now()
const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
const evictedWorkspaceKeys = getDeletedFolderTombstoneEvictionKeys(current, Date.now())
if (evictedWorkspaceKeys.length === 0) {
const eviction = getDeletedFolderTombstoneEviction(current, now)
const overflowExpiresAt = this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
const overflowExpired = overflowExpiresAt !== undefined && overflowExpiresAt <= now
const overflowExtended =
eviction.overflowExpiresAt !== undefined &&
eviction.overflowExpiresAt > (overflowExpiresAt ?? 0)
if (eviction.workspaceKeys.length === 0 && !overflowExpired && !overflowExtended) {
return
}
const tombstones = { ...current }
for (const workspaceKey of evictedWorkspaceKeys) {
for (const workspaceKey of eviction.workspaceKeys) {
delete tombstones[workspaceKey]
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
}
if (overflowExtended) {
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt =
eviction.overflowExpiresAt
} else if (overflowExpired) {
delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
}
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
this.scheduleSave()
}
private hasDeletedFolderWorkspaceSessionTombstoneOverflow(): boolean {
return (this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0) > Date.now()
}
private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void {
this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey)
for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) {
@@ -4913,7 +4973,8 @@ export class Store {
!(this.state.folderWorkspaces ?? []).some(
(workspace) => workspace.id === scope.folderWorkspaceId
) &&
this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey]
(this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey] ||
this.hasDeletedFolderWorkspaceSessionTombstoneOverflow())
)
}
@@ -4953,7 +5014,13 @@ export class Store {
removedOwnerKeys: ReadonlySet<string>
): WorkspaceSessionState {
const originalConnectionIds = session.activeConnectionIdsAtShutdown
if (!originalConnectionIds || removedConnectionIds.size === 0) {
if (!originalConnectionIds) {
return session
}
const removalCandidates = this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()
? new Set(originalConnectionIds)
: removedConnectionIds
if (removalCandidates.size === 0) {
return session
}
const retainedPartitions = [
@@ -4970,7 +5037,7 @@ export class Store {
}
const activeConnectionIdsAtShutdown = originalConnectionIds.filter(
(connectionId) =>
!removedConnectionIds.has(connectionId) || retainedConnectionIds.has(connectionId)
!removalCandidates.has(connectionId) || retainedConnectionIds.has(connectionId)
)
if (
activeConnectionIdsAtShutdown.length === originalConnectionIds.length &&
@@ -5111,9 +5178,11 @@ export class Store {
(this.state.folderWorkspaces ?? []).map((workspace) => workspace.id)
)
const deletedFolderTombstones = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
const hasTruncatedDeletedFolderEvidence = Object.values(deletedFolderTombstones).some(
(tombstone) => tombstone?.evidenceTruncated
)
const hasDeletedFolderTombstoneOverflow =
this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()
const hasTruncatedDeletedFolderEvidence =
hasDeletedFolderTombstoneOverflow ||
Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated)
const ownerKeys = new Set<string>()
const ownerKeysByTabId = new Map<string, Set<string>>()
const directPtyIdsByOwnerAndTabId = new Map<string, Map<string, string>>()
@@ -5150,7 +5219,9 @@ export class Store {
if (
scope?.type === 'folder' &&
!liveFolderIds.has(scope.folderWorkspaceId) &&
(hostId === LOCAL_EXECUTION_HOST_ID || tombstone !== undefined)
(hostId === LOCAL_EXECUTION_HOST_ID ||
tombstone !== undefined ||
hasDeletedFolderTombstoneOverflow)
) {
ownerKeys.add(worktreeId)
}
@@ -5263,6 +5334,12 @@ export class Store {
ownerKeys
)
}
const deletedTabOwners: {
hostId: ExecutionHostId
tabId: string
workspaceKey: string
connectionId: string | null
}[] = []
for (const [tabId, tabOwners] of ownerKeysByTabId) {
for (const ownerKey of tabOwners) {
if (!ownerKeys.has(ownerKey)) {
@@ -5274,20 +5351,22 @@ export class Store {
? (session.remoteSessionIdsByTabId?.[tabId] ??
Object.values(session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})[0])
: undefined
this.rememberDeletedFolderTabOwner(
deletedTabOwners.push({
hostId,
tabId,
ownerKey,
this.resolveWorkspaceSessionPtyConnectionId(
hostId,
ownerKey,
directPtyId ?? sharedPtyId ?? ''
) ??
workspaceKey: ownerKey,
connectionId:
this.resolveWorkspaceSessionPtyConnectionId(
hostId,
ownerKey,
directPtyId ?? sharedPtyId ?? ''
) ??
directConnectionIdsByOwnerAndTabId.get(ownerKey)?.get(tabId) ??
null
)
})
}
}
this.rememberDeletedFolderTabOwners(deletedTabOwners)
const removedConnectionIds = this.collectWorkspaceSessionRemoteConnectionIds(
[{ hostId, session }],
(worktreeId) => ownerKeys.has(worktreeId)
+1
View File
@@ -3583,6 +3583,7 @@ export type PersistedState = {
deletedFolderWorkspaceSessionTombstones?: Partial<
Record<WorkspaceKey, DeletedFolderWorkspaceSessionTombstone>
>
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number
/** Sparse-checkout presets keyed by repoId. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */