mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
fix: key folder tombstone overflow evidence
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
DeletedFolderWorkspaceSessionTombstone,
|
||||
PersistedState,
|
||||
WorkspaceKey
|
||||
} from '../shared/types'
|
||||
import {
|
||||
addDeletedFolderTombstoneOverflowEntries,
|
||||
getDeletedFolderTombstoneEviction,
|
||||
hasDeletedFolderConnectionOverflowEvidence,
|
||||
hasDeletedFolderTabOwnerOverflowEvidence,
|
||||
hasDeletedFolderWorkspaceKeyOverflowEvidence,
|
||||
pruneDeletedFolderTombstoneOverflowBuckets
|
||||
} from './deleted-folder-session-tombstones'
|
||||
|
||||
const NOW = Date.parse('2026-01-15T12:00:00Z')
|
||||
|
||||
function tombstone(
|
||||
deletedAt = NOW,
|
||||
connectionId: string | null = null
|
||||
): DeletedFolderWorkspaceSessionTombstone {
|
||||
return {
|
||||
connectionId,
|
||||
deletedAt,
|
||||
evidenceTruncated: false,
|
||||
hostIds: ['local'],
|
||||
tabConnectionIdsByHostId: {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('deleted folder session tombstones', () => {
|
||||
it('does not sort an already-capped tombstone set', () => {
|
||||
const tombstones = Object.fromEntries(
|
||||
Array.from({ length: 512 }, (_, index) => [`folder:deleted-${index}`, tombstone(NOW - index)])
|
||||
) as NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>
|
||||
const sortSpy = vi.spyOn(Array.prototype, 'sort')
|
||||
|
||||
const eviction = getDeletedFolderTombstoneEviction(tombstones, NOW)
|
||||
|
||||
expect(eviction).toEqual({ workspaceKeys: [], overflowEntries: [] })
|
||||
expect(sortSpy).not.toHaveBeenCalled()
|
||||
sortSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps overflow evidence keyed to deleted workspace, tab, and connection identities', () => {
|
||||
const workspaceKey = 'folder:deleted' as WorkspaceKey
|
||||
const deleted = tombstone(NOW, 'deleted-connection')
|
||||
deleted.hostIds = ['runtime:deleted-host']
|
||||
deleted.tabConnectionIdsByHostId = {
|
||||
'runtime:deleted-host': { 'deleted-tab': 'deleted-connection' }
|
||||
}
|
||||
|
||||
const buckets = addDeletedFolderTombstoneOverflowEntries(
|
||||
undefined,
|
||||
[{ workspaceKey, tombstone: deleted }],
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(hasDeletedFolderWorkspaceKeyOverflowEvidence(buckets, workspaceKey, NOW)).toBe(true)
|
||||
expect(
|
||||
hasDeletedFolderTabOwnerOverflowEvidence(buckets, 'runtime:deleted-host', 'deleted-tab', NOW)
|
||||
).toBe(true)
|
||||
expect(hasDeletedFolderConnectionOverflowEvidence(buckets, 'deleted-connection', NOW)).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasDeletedFolderWorkspaceKeyOverflowEvidence(buckets, 'folder:unrelated', NOW)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
hasDeletedFolderTabOwnerOverflowEvidence(
|
||||
buckets,
|
||||
'runtime:unrelated-host',
|
||||
'unrelated-tab',
|
||||
NOW
|
||||
)
|
||||
).toBe(false)
|
||||
expect(hasDeletedFolderConnectionOverflowEvidence(buckets, 'unrelated-connection', NOW)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('expires overflow buckets without cloning active evidence', () => {
|
||||
const buckets = addDeletedFolderTombstoneOverflowEntries(
|
||||
undefined,
|
||||
[{ workspaceKey: 'folder:deleted' as WorkspaceKey, tombstone: tombstone() }],
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(pruneDeletedFolderTombstoneOverflowBuckets(buckets, NOW)).toBe(buckets)
|
||||
expect(
|
||||
pruneDeletedFolderTombstoneOverflowBuckets(buckets, NOW + 30 * 24 * 60 * 60 * 1000)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,33 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
import type {
|
||||
DeletedFolderWorkspaceSessionTombstone,
|
||||
DeletedFolderWorkspaceSessionTombstoneOverflowBucket,
|
||||
PersistedState,
|
||||
WorkspaceKey
|
||||
} from '../shared/types'
|
||||
|
||||
const MAX_TOMBSTONES = 512
|
||||
export const MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
// Why: daily keyed filters bound churn without turning unrelated remote folder identities into deletions.
|
||||
export const DELETED_FOLDER_OVERFLOW_BUCKET_MS = 24 * 60 * 60 * 1000
|
||||
export const MAX_DELETED_FOLDER_OVERFLOW_BUCKETS = 31
|
||||
export const DELETED_FOLDER_OVERFLOW_FILTER_BYTES = 8 * 1024
|
||||
const OVERFLOW_BUCKET_MS = DELETED_FOLDER_OVERFLOW_BUCKET_MS
|
||||
const MAX_OVERFLOW_BUCKETS = MAX_DELETED_FOLDER_OVERFLOW_BUCKETS
|
||||
const OVERFLOW_FILTER_BYTES = DELETED_FOLDER_OVERFLOW_FILTER_BYTES
|
||||
const OVERFLOW_FILTER_HASHES = 4
|
||||
const MAX_HOST_IDS = 32
|
||||
const MAX_TAB_OWNERS = 256
|
||||
const overflowFilterCache = new WeakMap<
|
||||
DeletedFolderWorkspaceSessionTombstoneOverflowBucket,
|
||||
Partial<Record<'workspaceKeyBits' | 'tabOwnerBits' | 'connectionIdBits', Buffer | null>>
|
||||
>()
|
||||
|
||||
export type DeletedFolderTombstoneOverflowEntry = {
|
||||
workspaceKey: WorkspaceKey
|
||||
tombstone: DeletedFolderWorkspaceSessionTombstone
|
||||
}
|
||||
|
||||
export function boundDeletedFolderTombstoneEvidence(
|
||||
tombstone: DeletedFolderWorkspaceSessionTombstone
|
||||
@@ -49,7 +68,7 @@ export function boundDeletedFolderTombstoneEvidence(
|
||||
export function getDeletedFolderTombstoneEviction(
|
||||
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
|
||||
now: number
|
||||
): { workspaceKeys: WorkspaceKey[]; overflowExpiresAt: number | undefined } {
|
||||
): { workspaceKeys: WorkspaceKey[]; overflowEntries: DeletedFolderTombstoneOverflowEntry[] } {
|
||||
const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) =>
|
||||
tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : []
|
||||
)
|
||||
@@ -59,23 +78,190 @@ export function getDeletedFolderTombstoneEviction(
|
||||
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)
|
||||
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)
|
||||
const overflowExpiresAt = capEvictions.reduce<number | undefined>(
|
||||
(latest, { tombstone }) =>
|
||||
Math.max(latest ?? 0, tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS),
|
||||
undefined
|
||||
)
|
||||
const capEvictions =
|
||||
excess === 0
|
||||
? []
|
||||
: retained
|
||||
.sort((left, right) =>
|
||||
left.tombstone.deletedAt === right.tombstone.deletedAt
|
||||
? left.index - right.index
|
||||
: left.tombstone.deletedAt - right.tombstone.deletedAt
|
||||
)
|
||||
.slice(0, excess)
|
||||
return {
|
||||
workspaceKeys: [
|
||||
...expired.map(({ workspaceKey }) => workspaceKey),
|
||||
...capEvictions.map(({ workspaceKey }) => workspaceKey)
|
||||
],
|
||||
overflowExpiresAt
|
||||
overflowEntries: capEvictions.map(({ workspaceKey, tombstone }) => ({
|
||||
workspaceKey,
|
||||
tombstone
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function getOverflowFilterOffsets(value: string): number[] {
|
||||
const digest = createHash('sha256').update(value).digest()
|
||||
return Array.from(
|
||||
{ length: OVERFLOW_FILTER_HASHES },
|
||||
(_, index) => digest.readUInt32BE(index * 4) % (OVERFLOW_FILTER_BYTES * 8)
|
||||
)
|
||||
}
|
||||
|
||||
function addOverflowFilterValue(filter: Buffer, value: string): void {
|
||||
for (const offset of getOverflowFilterOffsets(value)) {
|
||||
filter[offset >> 3] |= 1 << (offset & 7)
|
||||
}
|
||||
}
|
||||
|
||||
function overflowBucketFilterHasValue(
|
||||
bucket: DeletedFolderWorkspaceSessionTombstoneOverflowBucket,
|
||||
field: 'workspaceKeyBits' | 'tabOwnerBits' | 'connectionIdBits',
|
||||
value: string
|
||||
): boolean {
|
||||
const cached = overflowFilterCache.get(bucket) ?? {}
|
||||
let filter = cached[field]
|
||||
if (filter === undefined) {
|
||||
filter = decodeDeletedFolderOverflowFilter(bucket[field])
|
||||
cached[field] = filter
|
||||
overflowFilterCache.set(bucket, cached)
|
||||
}
|
||||
if (!filter) {
|
||||
return false
|
||||
}
|
||||
return getOverflowFilterOffsets(value).every(
|
||||
(offset) => (filter[offset >> 3]! & (1 << (offset & 7))) !== 0
|
||||
)
|
||||
}
|
||||
|
||||
export function decodeDeletedFolderOverflowFilter(value: unknown): Buffer | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64')
|
||||
return decoded.length === OVERFLOW_FILTER_BYTES ? decoded : null
|
||||
}
|
||||
|
||||
export function addDeletedFolderTombstoneOverflowEntries(
|
||||
current: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
entries: readonly DeletedFolderTombstoneOverflowEntry[],
|
||||
now: number
|
||||
): DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] {
|
||||
const buckets = pruneDeletedFolderTombstoneOverflowBuckets(current, now) ?? []
|
||||
if (entries.length === 0) {
|
||||
return buckets
|
||||
}
|
||||
const mutableByStart = new Map(
|
||||
buckets.map((bucket) => [
|
||||
bucket.bucketStart,
|
||||
{
|
||||
...bucket,
|
||||
workspaceKeyFilter: Buffer.from(bucket.workspaceKeyBits, 'base64'),
|
||||
tabOwnerFilter: bucket.tabOwnerBits
|
||||
? Buffer.from(bucket.tabOwnerBits, 'base64')
|
||||
: Buffer.alloc(OVERFLOW_FILTER_BYTES),
|
||||
connectionIdFilter: bucket.connectionIdBits
|
||||
? Buffer.from(bucket.connectionIdBits, 'base64')
|
||||
: Buffer.alloc(OVERFLOW_FILTER_BYTES)
|
||||
}
|
||||
])
|
||||
)
|
||||
for (const { workspaceKey, tombstone } of entries) {
|
||||
const bucketStart = Math.floor(tombstone.deletedAt / OVERFLOW_BUCKET_MS) * OVERFLOW_BUCKET_MS
|
||||
const bucket = mutableByStart.get(bucketStart) ?? {
|
||||
bucketStart,
|
||||
expiresAt: tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS,
|
||||
workspaceKeyBits: '',
|
||||
evidenceTruncated: false,
|
||||
workspaceKeyFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES),
|
||||
tabOwnerFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES),
|
||||
connectionIdFilter: Buffer.alloc(OVERFLOW_FILTER_BYTES)
|
||||
}
|
||||
addOverflowFilterValue(bucket.workspaceKeyFilter, workspaceKey)
|
||||
if (tombstone.connectionId) {
|
||||
addOverflowFilterValue(bucket.connectionIdFilter, tombstone.connectionId)
|
||||
}
|
||||
for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) {
|
||||
for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) {
|
||||
addOverflowFilterValue(bucket.tabOwnerFilter, `${hostId}\0${tabId}`)
|
||||
if (connectionId) {
|
||||
addOverflowFilterValue(bucket.connectionIdFilter, connectionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
bucket.expiresAt = Math.max(
|
||||
bucket.expiresAt,
|
||||
tombstone.deletedAt + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
)
|
||||
bucket.evidenceTruncated ||= tombstone.evidenceTruncated
|
||||
mutableByStart.set(bucketStart, bucket)
|
||||
}
|
||||
return [...mutableByStart.values()]
|
||||
.sort((left, right) => left.bucketStart - right.bucketStart)
|
||||
.slice(-MAX_OVERFLOW_BUCKETS)
|
||||
.map(({ workspaceKeyFilter, tabOwnerFilter, connectionIdFilter, ...bucket }) => ({
|
||||
...bucket,
|
||||
workspaceKeyBits: workspaceKeyFilter.toString('base64'),
|
||||
...(tabOwnerFilter.some((byte) => byte !== 0)
|
||||
? { tabOwnerBits: tabOwnerFilter.toString('base64') }
|
||||
: {}),
|
||||
...(connectionIdFilter.some((byte) => byte !== 0)
|
||||
? { connectionIdBits: connectionIdFilter.toString('base64') }
|
||||
: {})
|
||||
}))
|
||||
}
|
||||
|
||||
export function pruneDeletedFolderTombstoneOverflowBuckets(
|
||||
current: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
now: number
|
||||
): DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined {
|
||||
if (!current) {
|
||||
return undefined
|
||||
}
|
||||
const retained = current.filter((bucket) => bucket.expiresAt > now)
|
||||
return retained.length === current.length ? current : retained
|
||||
}
|
||||
|
||||
export function hasDeletedFolderWorkspaceKeyOverflowEvidence(
|
||||
buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
workspaceKey: string,
|
||||
now: number
|
||||
): boolean {
|
||||
return (buckets ?? []).some(
|
||||
(bucket) =>
|
||||
bucket.expiresAt > now &&
|
||||
overflowBucketFilterHasValue(bucket, 'workspaceKeyBits', workspaceKey)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasDeletedFolderTabOwnerOverflowEvidence(
|
||||
buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
hostId: ExecutionHostId,
|
||||
tabId: string,
|
||||
now: number
|
||||
): boolean {
|
||||
return (buckets ?? []).some(
|
||||
(bucket) =>
|
||||
bucket.expiresAt > now &&
|
||||
overflowBucketFilterHasValue(bucket, 'tabOwnerBits', `${hostId}\0${tabId}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasDeletedFolderConnectionOverflowEvidence(
|
||||
buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
connectionId: string,
|
||||
now: number
|
||||
): boolean {
|
||||
return (buckets ?? []).some(
|
||||
(bucket) =>
|
||||
bucket.expiresAt > now &&
|
||||
overflowBucketFilterHasValue(bucket, 'connectionIdBits', connectionId)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasTruncatedDeletedFolderOverflowEvidence(
|
||||
buckets: readonly DeletedFolderWorkspaceSessionTombstoneOverflowBucket[] | undefined,
|
||||
now: number
|
||||
): boolean {
|
||||
return (buckets ?? []).some((bucket) => bucket.expiresAt > now && bucket.evidenceTruncated)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { DeletedFolderWorkspaceSessionTombstoneOverflowBucket } from '../shared/types'
|
||||
import {
|
||||
decodeDeletedFolderOverflowFilter,
|
||||
DELETED_FOLDER_OVERFLOW_BUCKET_MS,
|
||||
DELETED_FOLDER_OVERFLOW_FILTER_BYTES,
|
||||
MAX_DELETED_FOLDER_OVERFLOW_BUCKETS,
|
||||
MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
} from './deleted-folder-session-tombstones'
|
||||
|
||||
function mergeOverflowFilter(target: Buffer, encodedSource: string | undefined): void {
|
||||
if (!encodedSource) {
|
||||
return
|
||||
}
|
||||
const source = Buffer.from(encodedSource, 'base64')
|
||||
if (source.length !== DELETED_FOLDER_OVERFLOW_FILTER_BYTES) {
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < target.length; index += 1) {
|
||||
target[index] |= source[index]!
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDeletedFolderTombstoneOverflowBuckets(
|
||||
value: unknown,
|
||||
now: number
|
||||
): {
|
||||
buckets: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[]
|
||||
changed: boolean
|
||||
} {
|
||||
if (!Array.isArray(value)) {
|
||||
return { buckets: [], changed: value !== undefined }
|
||||
}
|
||||
const bucketsByStart = new Map<
|
||||
number,
|
||||
{
|
||||
bucket: DeletedFolderWorkspaceSessionTombstoneOverflowBucket
|
||||
workspaceKeyFilter: Buffer
|
||||
tabOwnerFilter: Buffer | null
|
||||
connectionIdFilter: Buffer | null
|
||||
}
|
||||
>()
|
||||
let changed = false
|
||||
for (const candidate of value) {
|
||||
const raw = candidate as Partial<DeletedFolderWorkspaceSessionTombstoneOverflowBucket> | null
|
||||
const workspaceKeyFilter = decodeDeletedFolderOverflowFilter(raw?.workspaceKeyBits)
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw.bucketStart !== 'number' ||
|
||||
!Number.isFinite(raw.bucketStart) ||
|
||||
typeof raw.expiresAt !== 'number' ||
|
||||
!Number.isFinite(raw.expiresAt) ||
|
||||
raw.expiresAt <= now ||
|
||||
!workspaceKeyFilter
|
||||
) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const bucketStart =
|
||||
Math.floor(raw.bucketStart / DELETED_FOLDER_OVERFLOW_BUCKET_MS) *
|
||||
DELETED_FOLDER_OVERFLOW_BUCKET_MS
|
||||
const expiresAt = Math.min(
|
||||
raw.expiresAt,
|
||||
bucketStart + DELETED_FOLDER_OVERFLOW_BUCKET_MS + MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
)
|
||||
const tabOwnerFilter = raw.tabOwnerBits
|
||||
? decodeDeletedFolderOverflowFilter(raw.tabOwnerBits)
|
||||
: null
|
||||
const connectionIdFilter = raw.connectionIdBits
|
||||
? decodeDeletedFolderOverflowFilter(raw.connectionIdBits)
|
||||
: null
|
||||
if (
|
||||
bucketStart !== raw.bucketStart ||
|
||||
expiresAt !== raw.expiresAt ||
|
||||
workspaceKeyFilter.toString('base64') !== raw.workspaceKeyBits ||
|
||||
Boolean(raw.tabOwnerBits) !== Boolean(tabOwnerFilter) ||
|
||||
Boolean(raw.connectionIdBits) !== Boolean(connectionIdFilter) ||
|
||||
typeof raw.evidenceTruncated !== 'boolean'
|
||||
) {
|
||||
changed = true
|
||||
}
|
||||
if (bucketStart > now || expiresAt <= now) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const existing = bucketsByStart.get(bucketStart)
|
||||
if (existing) {
|
||||
mergeOverflowFilter(existing.workspaceKeyFilter, workspaceKeyFilter.toString('base64'))
|
||||
if (tabOwnerFilter) {
|
||||
existing.tabOwnerFilter ??= Buffer.alloc(DELETED_FOLDER_OVERFLOW_FILTER_BYTES)
|
||||
mergeOverflowFilter(existing.tabOwnerFilter, raw.tabOwnerBits)
|
||||
}
|
||||
if (connectionIdFilter) {
|
||||
existing.connectionIdFilter ??= Buffer.alloc(DELETED_FOLDER_OVERFLOW_FILTER_BYTES)
|
||||
mergeOverflowFilter(existing.connectionIdFilter, raw.connectionIdBits)
|
||||
}
|
||||
existing.bucket.expiresAt = Math.max(existing.bucket.expiresAt, expiresAt)
|
||||
existing.bucket.evidenceTruncated ||= raw.evidenceTruncated === true
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
bucketsByStart.set(bucketStart, {
|
||||
bucket: {
|
||||
bucketStart,
|
||||
expiresAt,
|
||||
workspaceKeyBits: workspaceKeyFilter.toString('base64'),
|
||||
...(tabOwnerFilter ? { tabOwnerBits: tabOwnerFilter.toString('base64') } : {}),
|
||||
...(connectionIdFilter ? { connectionIdBits: connectionIdFilter.toString('base64') } : {}),
|
||||
evidenceTruncated: raw.evidenceTruncated === true
|
||||
},
|
||||
workspaceKeyFilter,
|
||||
tabOwnerFilter,
|
||||
connectionIdFilter
|
||||
})
|
||||
}
|
||||
const retained = [...bucketsByStart.values()]
|
||||
.sort((left, right) => left.bucket.bucketStart - right.bucket.bucketStart)
|
||||
.slice(-MAX_DELETED_FOLDER_OVERFLOW_BUCKETS)
|
||||
.map(({ bucket, workspaceKeyFilter, tabOwnerFilter, connectionIdFilter }) => ({
|
||||
...bucket,
|
||||
workspaceKeyBits: workspaceKeyFilter.toString('base64'),
|
||||
...(tabOwnerFilter ? { tabOwnerBits: tabOwnerFilter.toString('base64') } : {}),
|
||||
...(connectionIdFilter ? { connectionIdBits: connectionIdFilter.toString('base64') } : {})
|
||||
}))
|
||||
return { buckets: retained, changed: changed || retained.length !== value.length }
|
||||
}
|
||||
@@ -5216,9 +5216,10 @@ 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)
|
||||
const overflowBuckets = (readDataFile() as PersistedState)
|
||||
.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
expect(overflowBuckets).toHaveLength(1)
|
||||
expect(overflowBuckets?.[0]?.expiresAt).toBe(deletedAt + 1 + 30 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('hard-bounds recent deletion fences with conservative overflow evidence', async () => {
|
||||
@@ -5244,9 +5245,10 @@ describe('Store', () => {
|
||||
let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
|
||||
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
|
||||
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
|
||||
expect(
|
||||
(readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
|
||||
).toBeGreaterThan(Date.now())
|
||||
const overflowBuckets = (readDataFile() as PersistedState)
|
||||
.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
expect(overflowBuckets).toHaveLength(1)
|
||||
expect(overflowBuckets?.[0]?.expiresAt).toBeGreaterThan(Date.now())
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z'))
|
||||
const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' })
|
||||
@@ -5275,7 +5277,14 @@ describe('Store', () => {
|
||||
tabConnectionIdsByHostId: {}
|
||||
}
|
||||
}
|
||||
state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Date.now() - 1
|
||||
state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = [
|
||||
{
|
||||
bucketStart: Date.now() - 31 * 24 * 60 * 60 * 1000,
|
||||
expiresAt: Date.now() - 24 * 60 * 60 * 1000,
|
||||
workspaceKeyBits: Buffer.alloc(8 * 1024).toString('base64'),
|
||||
evidenceTruncated: false
|
||||
}
|
||||
]
|
||||
writeDataFile(state)
|
||||
|
||||
vi.useFakeTimers()
|
||||
@@ -5289,7 +5298,7 @@ describe('Store', () => {
|
||||
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstones).toEqual({})
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeUndefined()
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
|
||||
@@ -6875,9 +6884,25 @@ describe('Store', () => {
|
||||
}
|
||||
const persisted = (store as unknown as { state: PersistedState }).state
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstones?.[workspaceKey]).toBeUndefined()
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt).toBeGreaterThan(
|
||||
Date.now()
|
||||
expect(persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets).toHaveLength(1)
|
||||
expect(
|
||||
persisted.deletedFolderWorkspaceSessionTombstoneOverflowBuckets?.[0]?.expiresAt
|
||||
).toBeGreaterThan(Date.now())
|
||||
|
||||
store.patchWorkspaceSession(
|
||||
{
|
||||
terminalLayoutsByTabId: {
|
||||
'rapid-churn-stale-tab': {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'rapid-churn-stale-pty' }
|
||||
}
|
||||
}
|
||||
},
|
||||
hostId
|
||||
)
|
||||
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId).toEqual({})
|
||||
|
||||
store.setWorkspaceSession(staleSession, hostId)
|
||||
store.upsertSshRemotePtyLease({
|
||||
@@ -6894,6 +6919,57 @@ describe('Store', () => {
|
||||
expect(store.getSshRemotePtyLeases()).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves unrelated runtime-owned folders through deletion overflow', async () => {
|
||||
const store = await createStore()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Unrelated churn',
|
||||
parentPath: '/workspace/unrelated-churn',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
for (let index = 0; index < 513; index += 1) {
|
||||
const workspace = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: `Unrelated ${index}`
|
||||
})
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
}
|
||||
const hostId = toRuntimeExecutionHostId('unrelated-runtime-folder')
|
||||
const workspaceKey = 'folder:runtime-owned-without-local-catalog'
|
||||
const connectionId = 'unrelated-runtime-connection'
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeConnectionIdsAtShutdown: [connectionId],
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [
|
||||
makeTerminalTab({ id: 'unrelated-runtime-tab', worktreeId: workspaceKey })
|
||||
]
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'unrelated-runtime-tab': 'unrelated-runtime-pty' }
|
||||
},
|
||||
hostId
|
||||
)
|
||||
store.upsertSshRemotePtyLease({
|
||||
targetId: connectionId,
|
||||
ptyId: 'unrelated-runtime-pty',
|
||||
worktreeId: workspaceKey,
|
||||
tabId: 'unrelated-runtime-tab',
|
||||
leafId: TEST_LEAF_1,
|
||||
state: 'detached'
|
||||
})
|
||||
|
||||
expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toHaveLength(1)
|
||||
expect(store.getWorkspaceSession(hostId).activeConnectionIdsAtShutdown).toEqual([
|
||||
connectionId
|
||||
])
|
||||
expect(store.getSshRemotePtyLeases()).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('batches deletion evidence commits for folders with many tabs', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
|
||||
+96
-50
@@ -89,10 +89,17 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-lin
|
||||
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
|
||||
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
|
||||
import {
|
||||
addDeletedFolderTombstoneOverflowEntries,
|
||||
boundDeletedFolderTombstoneEvidence,
|
||||
getDeletedFolderTombstoneEviction,
|
||||
hasDeletedFolderConnectionOverflowEvidence,
|
||||
hasDeletedFolderTabOwnerOverflowEvidence,
|
||||
hasDeletedFolderWorkspaceKeyOverflowEvidence,
|
||||
hasTruncatedDeletedFolderOverflowEvidence,
|
||||
pruneDeletedFolderTombstoneOverflowBuckets,
|
||||
MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
} from './deleted-folder-session-tombstones'
|
||||
import { normalizeDeletedFolderTombstoneOverflowBuckets } from './deleted-folder-tombstone-overflow-normalization'
|
||||
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
|
||||
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
|
||||
import {
|
||||
@@ -2865,26 +2872,22 @@ function backfillFolderScopeConnectionIds(state: PersistedState): {
|
||||
function normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
value: unknown,
|
||||
liveFolderIds: ReadonlySet<string>,
|
||||
rawOverflowExpiresAt: unknown
|
||||
rawOverflowBuckets: unknown
|
||||
): {
|
||||
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>
|
||||
overflowExpiresAt: number | undefined
|
||||
overflowBuckets: NonNullable<
|
||||
PersistedState['deletedFolderWorkspaceSessionTombstoneOverflowBuckets']
|
||||
>
|
||||
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
|
||||
}
|
||||
const normalizedOverflow = normalizeDeletedFolderTombstoneOverflowBuckets(rawOverflowBuckets, now)
|
||||
let changed =
|
||||
normalizedOverflow.changed ||
|
||||
(value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value)))
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { tombstones, overflowExpiresAt, changed }
|
||||
return { tombstones, overflowBuckets: normalizedOverflow.buckets, changed }
|
||||
}
|
||||
for (const [workspaceKey, rawTombstone] of Object.entries(value)) {
|
||||
const scope = parseWorkspaceKey(workspaceKey)
|
||||
@@ -2960,8 +2963,12 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
delete tombstones[workspaceKey]
|
||||
changed = true
|
||||
}
|
||||
overflowExpiresAt = Math.max(overflowExpiresAt ?? 0, eviction.overflowExpiresAt ?? 0) || undefined
|
||||
return { tombstones, overflowExpiresAt, changed }
|
||||
const overflowBuckets = addDeletedFolderTombstoneOverflowEntries(
|
||||
normalizedOverflow.buckets,
|
||||
eviction.overflowEntries,
|
||||
now
|
||||
)
|
||||
return { tombstones, overflowBuckets, changed }
|
||||
}
|
||||
|
||||
function deleteRemovedTerminalScrollbackSnapshots(
|
||||
@@ -3495,7 +3502,7 @@ export class Store {
|
||||
const normalizedDeletedFolderTombstones = normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
parsed.deletedFolderWorkspaceSessionTombstones,
|
||||
new Set(normalizedFolderWorkspaces.map((workspace) => workspace.id)),
|
||||
parsed.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
|
||||
parsed.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
)
|
||||
if (normalizedDeletedFolderTombstones.changed) {
|
||||
this.loadNeedsSave = true
|
||||
@@ -3550,8 +3557,10 @@ export class Store {
|
||||
projectGroups: normalizedProjectGroups,
|
||||
folderWorkspaces: normalizedFolderWorkspaces,
|
||||
deletedFolderWorkspaceSessionTombstones: normalizedDeletedFolderTombstones.tombstones,
|
||||
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt:
|
||||
normalizedDeletedFolderTombstones.overflowExpiresAt,
|
||||
deletedFolderWorkspaceSessionTombstoneOverflowBuckets:
|
||||
normalizedDeletedFolderTombstones.overflowBuckets.length > 0
|
||||
? normalizedDeletedFolderTombstones.overflowBuckets
|
||||
: undefined,
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
parsed.mobileClientTabSelectionsByDeviceId
|
||||
@@ -4795,11 +4804,15 @@ export class Store {
|
||||
delete tombstones[evictedWorkspaceKey]
|
||||
this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey)
|
||||
}
|
||||
if (eviction.overflowExpiresAt) {
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt = Math.max(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0,
|
||||
eviction.overflowExpiresAt
|
||||
)
|
||||
const overflowBuckets = addDeletedFolderTombstoneOverflowEntries(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets,
|
||||
eviction.overflowEntries,
|
||||
Date.now()
|
||||
)
|
||||
if (overflowBuckets.length > 0) {
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = overflowBuckets
|
||||
} else {
|
||||
delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
}
|
||||
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
|
||||
}
|
||||
@@ -4808,12 +4821,13 @@ export class Store {
|
||||
const now = Date.now()
|
||||
const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
|
||||
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) {
|
||||
const currentOverflow = this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
const prunedOverflow = pruneDeletedFolderTombstoneOverflowBuckets(currentOverflow, now)
|
||||
if (
|
||||
eviction.workspaceKeys.length === 0 &&
|
||||
eviction.overflowEntries.length === 0 &&
|
||||
prunedOverflow === currentOverflow
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tombstones = { ...current }
|
||||
@@ -4821,18 +4835,26 @@ export class Store {
|
||||
delete tombstones[workspaceKey]
|
||||
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
|
||||
}
|
||||
if (overflowExtended) {
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt =
|
||||
eviction.overflowExpiresAt
|
||||
} else if (overflowExpired) {
|
||||
delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt
|
||||
const overflowBuckets = addDeletedFolderTombstoneOverflowEntries(
|
||||
prunedOverflow,
|
||||
eviction.overflowEntries,
|
||||
now
|
||||
)
|
||||
if (overflowBuckets.length > 0) {
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets = overflowBuckets
|
||||
} else {
|
||||
delete this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets
|
||||
}
|
||||
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
private hasDeletedFolderWorkspaceSessionTombstoneOverflow(): boolean {
|
||||
return (this.state.deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt ?? 0) > Date.now()
|
||||
private hasDeletedFolderWorkspaceKeyOverflowEvidence(workspaceKey: string): boolean {
|
||||
return hasDeletedFolderWorkspaceKeyOverflowEvidence(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets,
|
||||
workspaceKey,
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void {
|
||||
@@ -4967,6 +4989,10 @@ export class Store {
|
||||
|
||||
private isDeletedFolderWorkspaceKey(worktreeId: string): boolean {
|
||||
this.pruneDeletedFolderWorkspaceSessionTombstones()
|
||||
return this.isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId)
|
||||
}
|
||||
|
||||
private isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId: string): boolean {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
return Boolean(
|
||||
scope?.type === 'folder' &&
|
||||
@@ -4974,7 +5000,7 @@ export class Store {
|
||||
(workspace) => workspace.id === scope.folderWorkspaceId
|
||||
) &&
|
||||
(this.state.deletedFolderWorkspaceSessionTombstones?.[worktreeId as WorkspaceKey] ||
|
||||
this.hasDeletedFolderWorkspaceSessionTombstoneOverflow())
|
||||
this.hasDeletedFolderWorkspaceKeyOverflowEvidence(worktreeId))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5017,9 +5043,18 @@ export class Store {
|
||||
if (!originalConnectionIds) {
|
||||
return session
|
||||
}
|
||||
const removalCandidates = this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()
|
||||
? new Set(originalConnectionIds)
|
||||
: removedConnectionIds
|
||||
const removalCandidates = new Set(removedConnectionIds)
|
||||
for (const connectionId of originalConnectionIds) {
|
||||
if (
|
||||
hasDeletedFolderConnectionOverflowEvidence(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets,
|
||||
connectionId,
|
||||
Date.now()
|
||||
)
|
||||
) {
|
||||
removalCandidates.add(connectionId)
|
||||
}
|
||||
}
|
||||
if (removalCandidates.size === 0) {
|
||||
return session
|
||||
}
|
||||
@@ -5030,7 +5065,8 @@ export class Store {
|
||||
const retainedConnectionIds = this.collectWorkspaceSessionRemoteConnectionIds(
|
||||
retainedPartitions,
|
||||
(worktreeId) =>
|
||||
!removedOwnerKeys.has(worktreeId) && !this.isDeletedFolderWorkspaceKey(worktreeId)
|
||||
!removedOwnerKeys.has(worktreeId) &&
|
||||
!this.isDeletedFolderWorkspaceKeyWithoutPruning(worktreeId)
|
||||
)
|
||||
for (const connectionId of this.collectCatalogConnectionIds(removedOwnerKeys)) {
|
||||
retainedConnectionIds.add(connectionId)
|
||||
@@ -5178,11 +5214,12 @@ export class Store {
|
||||
(this.state.folderWorkspaces ?? []).map((workspace) => workspace.id)
|
||||
)
|
||||
const deletedFolderTombstones = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
|
||||
const hasDeletedFolderTombstoneOverflow =
|
||||
this.hasDeletedFolderWorkspaceSessionTombstoneOverflow()
|
||||
const hasTruncatedDeletedFolderEvidence =
|
||||
hasDeletedFolderTombstoneOverflow ||
|
||||
Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated)
|
||||
Object.values(deletedFolderTombstones).some((tombstone) => tombstone?.evidenceTruncated) ||
|
||||
hasTruncatedDeletedFolderOverflowEvidence(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets,
|
||||
Date.now()
|
||||
)
|
||||
const ownerKeys = new Set<string>()
|
||||
const ownerKeysByTabId = new Map<string, Set<string>>()
|
||||
const directPtyIdsByOwnerAndTabId = new Map<string, Map<string, string>>()
|
||||
@@ -5221,7 +5258,7 @@ export class Store {
|
||||
!liveFolderIds.has(scope.folderWorkspaceId) &&
|
||||
(hostId === LOCAL_EXECUTION_HOST_ID ||
|
||||
tombstone !== undefined ||
|
||||
hasDeletedFolderTombstoneOverflow)
|
||||
this.hasDeletedFolderWorkspaceKeyOverflowEvidence(worktreeId))
|
||||
) {
|
||||
ownerKeys.add(worktreeId)
|
||||
}
|
||||
@@ -5315,9 +5352,18 @@ export class Store {
|
||||
collectOwnerKey(workspaceKey)
|
||||
}
|
||||
}
|
||||
const hasUnownedTruncatedTabState =
|
||||
hasTruncatedDeletedFolderEvidence &&
|
||||
[...tabScopedStateIds].some((tabId) => (ownerKeysByTabId.get(tabId)?.size ?? 0) === 0)
|
||||
const hasOverflowTabOwner = (tabId: string): boolean =>
|
||||
hasDeletedFolderTabOwnerOverflowEvidence(
|
||||
this.state.deletedFolderWorkspaceSessionTombstoneOverflowBuckets,
|
||||
hostId,
|
||||
tabId,
|
||||
Date.now()
|
||||
)
|
||||
const hasUnownedTruncatedTabState = [...tabScopedStateIds].some(
|
||||
(tabId) =>
|
||||
(ownerKeysByTabId.get(tabId)?.size ?? 0) === 0 &&
|
||||
(hasTruncatedDeletedFolderEvidence || hasOverflowTabOwner(tabId))
|
||||
)
|
||||
if (
|
||||
!session.activeWorkspaceKey &&
|
||||
!session.activeWorktreeId &&
|
||||
@@ -5390,7 +5436,7 @@ export class Store {
|
||||
const deletedOwner = [...(currentDeletedOwnersByTabId?.get(tabId)?.entries() ?? [])].find(
|
||||
([workspaceKey]) => ownerKeys.has(workspaceKey)
|
||||
)
|
||||
if (!deletedOwner && !hasTruncatedDeletedFolderEvidence) {
|
||||
if (!deletedOwner && !hasTruncatedDeletedFolderEvidence && !hasOverflowTabOwner(tabId)) {
|
||||
continue
|
||||
}
|
||||
exclusivelyDeletedTabIds.add(tabId)
|
||||
|
||||
+10
-1
@@ -3572,6 +3572,15 @@ export type DeletedFolderWorkspaceSessionTombstone = {
|
||||
tabConnectionIdsByHostId: Partial<Record<ExecutionHostId, Record<string, string | null>>>
|
||||
}
|
||||
|
||||
export type DeletedFolderWorkspaceSessionTombstoneOverflowBucket = {
|
||||
bucketStart: number
|
||||
expiresAt: number
|
||||
workspaceKeyBits: string
|
||||
tabOwnerBits?: string
|
||||
connectionIdBits?: string
|
||||
evidenceTruncated: boolean
|
||||
}
|
||||
|
||||
// ─── Persistence shape ──────────────────────────────────────────────
|
||||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
@@ -3583,7 +3592,7 @@ export type PersistedState = {
|
||||
deletedFolderWorkspaceSessionTombstones?: Partial<
|
||||
Record<WorkspaceKey, DeletedFolderWorkspaceSessionTombstone>
|
||||
>
|
||||
deletedFolderWorkspaceSessionTombstoneOverflowExpiresAt?: number
|
||||
deletedFolderWorkspaceSessionTombstoneOverflowBuckets?: DeletedFolderWorkspaceSessionTombstoneOverflowBucket[]
|
||||
/** Sparse-checkout presets keyed by repoId. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
|
||||
|
||||
Reference in New Issue
Block a user