mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): retain folder deletion fences safely
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import type { ExecutionHostId } from '../shared/execution-host'
|
||||
import type {
|
||||
DeletedFolderWorkspaceSessionTombstone,
|
||||
PersistedState,
|
||||
WorkspaceKey
|
||||
} 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
|
||||
|
||||
export function boundDeletedFolderTombstoneEvidence(
|
||||
tombstone: DeletedFolderWorkspaceSessionTombstone
|
||||
): DeletedFolderWorkspaceSessionTombstone {
|
||||
const tabEntries = Object.entries(tombstone.tabConnectionIdsByHostId).flatMap(([hostId, tabs]) =>
|
||||
Object.entries(tabs ?? {}).map(
|
||||
([tabId, connectionId]) => [hostId as ExecutionHostId, tabId, connectionId] as const
|
||||
)
|
||||
)
|
||||
const retainedTabEntries = tabEntries.slice(Math.max(0, tabEntries.length - MAX_TAB_OWNERS))
|
||||
const retainedHostIds = [
|
||||
...new Set([...tombstone.hostIds, ...retainedTabEntries.map(([hostId]) => hostId)])
|
||||
].slice(-MAX_HOST_IDS)
|
||||
const retainedHostIdSet = new Set(retainedHostIds)
|
||||
const tabConnectionIdsByHostId: DeletedFolderWorkspaceSessionTombstone['tabConnectionIdsByHostId'] =
|
||||
{}
|
||||
for (const [hostId, tabId, connectionId] of retainedTabEntries) {
|
||||
if (!retainedHostIdSet.has(hostId)) {
|
||||
continue
|
||||
}
|
||||
tabConnectionIdsByHostId[hostId] = {
|
||||
...tabConnectionIdsByHostId[hostId],
|
||||
[tabId]: connectionId
|
||||
}
|
||||
}
|
||||
return { ...tombstone, hostIds: retainedHostIds, tabConnectionIdsByHostId }
|
||||
}
|
||||
|
||||
export function getDeletedFolderTombstoneEvictionKeys(
|
||||
tombstones: NonNullable<PersistedState['deletedFolderWorkspaceSessionTombstones']>,
|
||||
now: number
|
||||
): WorkspaceKey[] {
|
||||
const entries = Object.entries(tombstones).flatMap(([workspaceKey, tombstone], index) =>
|
||||
tombstone ? [{ index, workspaceKey: workspaceKey as WorkspaceKey, tombstone }] : []
|
||||
)
|
||||
const expired = entries.filter(
|
||||
({ tombstone }) => now - tombstone.deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
)
|
||||
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
|
||||
)
|
||||
.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)
|
||||
]
|
||||
}
|
||||
+184
-18
@@ -5148,6 +5148,7 @@ describe('Store', () => {
|
||||
state.deletedFolderWorkspaceSessionTombstones = {
|
||||
'folder:partial-tombstone': {
|
||||
connectionId: null,
|
||||
deletedAt: undefined as unknown as number,
|
||||
hostIds: ['local'],
|
||||
tabConnectionIdsByHostId: {
|
||||
local: { valid: null, invalid: 42 as unknown as string }
|
||||
@@ -5171,6 +5172,7 @@ describe('Store', () => {
|
||||
]
|
||||
).toEqual({
|
||||
connectionId: null,
|
||||
deletedAt: expect.any(Number),
|
||||
hostIds: ['local'],
|
||||
tabConnectionIdsByHostId: { local: { valid: null } }
|
||||
})
|
||||
@@ -5183,10 +5185,16 @@ describe('Store', () => {
|
||||
const workspaceKeys = Array.from({ length: 514 }, (_, index) =>
|
||||
folderWorkspaceKey(`deleted-${index}`)
|
||||
)
|
||||
const deletedAt = Date.now() - 2 * 24 * 60 * 60 * 1000
|
||||
state.deletedFolderWorkspaceSessionTombstones = Object.fromEntries(
|
||||
workspaceKeys.map((workspaceKey) => [
|
||||
workspaceKeys.map((workspaceKey, index) => [
|
||||
workspaceKey,
|
||||
{ connectionId: null, hostIds: ['local'], tabConnectionIdsByHostId: {} }
|
||||
{
|
||||
connectionId: null,
|
||||
deletedAt: deletedAt + index,
|
||||
hostIds: ['local'],
|
||||
tabConnectionIdsByHostId: {}
|
||||
}
|
||||
])
|
||||
) as PersistedState['deletedFolderWorkspaceSessionTombstones']
|
||||
writeDataFile(state)
|
||||
@@ -5207,30 +5215,68 @@ describe('Store', () => {
|
||||
expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined()
|
||||
})
|
||||
|
||||
it('bounds deleted-folder tombstones during repeated workspace churn', async () => {
|
||||
it('retains recent deletion fences before applying the tombstone soft cap', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Tombstone churn',
|
||||
parentPath: '/workspace/churn',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspaceKeys: ReturnType<typeof folderWorkspaceKey>[] = []
|
||||
for (let index = 0; index < 514; index += 1) {
|
||||
const workspace = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: `Deleted ${index}`
|
||||
})
|
||||
workspaceKeys.push(folderWorkspaceKey(workspace.id))
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
const workspaceKeys: ReturnType<typeof folderWorkspaceKey>[] = []
|
||||
for (let index = 0; index < 514; index += 1) {
|
||||
const workspace = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: `Deleted ${index}`
|
||||
})
|
||||
workspaceKeys.push(folderWorkspaceKey(workspace.id))
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
}
|
||||
store.flush()
|
||||
let tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
|
||||
expect(Object.keys(tombstones ?? {})).toHaveLength(514)
|
||||
expect(tombstones?.[workspaceKeys[0]!]).toBeDefined()
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-02T00:00:00.001Z'))
|
||||
const latest = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Latest' })
|
||||
expect(store.removeFolderWorkspace(latest.id)).toBe(true)
|
||||
store.flush()
|
||||
|
||||
tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
|
||||
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
|
||||
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
|
||||
expect(tombstones?.[folderWorkspaceKey(latest.id)]).toBeDefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('removes deleted-folder tombstones after the stale-write retention window', async () => {
|
||||
const initial = await createStore()
|
||||
initial.flush()
|
||||
const state = readDataFile() as PersistedState
|
||||
state.deletedFolderWorkspaceSessionTombstones = {
|
||||
'folder:expired-tombstone': {
|
||||
connectionId: null,
|
||||
deletedAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
|
||||
hostIds: ['local'],
|
||||
tabConnectionIdsByHostId: {}
|
||||
}
|
||||
}
|
||||
writeDataFile(state)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const restored = await createStore()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await restored.waitForPendingWrite()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
store.flush()
|
||||
|
||||
const tombstones = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones
|
||||
expect(Object.keys(tombstones ?? {})).toHaveLength(512)
|
||||
expect(tombstones?.[workspaceKeys[0]!]).toBeUndefined()
|
||||
expect(tombstones?.[workspaceKeys[1]!]).toBeUndefined()
|
||||
expect(tombstones?.[workspaceKeys.at(-1)!]).toBeDefined()
|
||||
expect((readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones).toEqual({})
|
||||
})
|
||||
|
||||
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
|
||||
@@ -6785,6 +6831,126 @@ describe('Store', () => {
|
||||
).toThrow('folder_workspace_not_found')
|
||||
})
|
||||
|
||||
it('keeps a recent remote stale-write fence through rapid folder deletion churn', async () => {
|
||||
const store = await createStore()
|
||||
const connectionId = 'rapid-churn-remote'
|
||||
const hostId = toRuntimeExecutionHostId('rapid-churn-runtime')
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Rapid churn',
|
||||
parentPath: '/remote/rapid-churn',
|
||||
connectionId,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, connectionId })
|
||||
const workspaceKey = folderWorkspaceKey(workspace.id)
|
||||
const staleSession = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: workspaceKey,
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [makeTerminalTab({ id: 'rapid-churn-stale-tab', worktreeId: workspaceKey })]
|
||||
}
|
||||
} satisfies WorkspaceSessionState
|
||||
store.setWorkspaceSession(staleSession, hostId)
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
const churned = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: `Churned ${index}`
|
||||
})
|
||||
expect(store.removeFolderWorkspace(churned.id)).toBe(true)
|
||||
}
|
||||
|
||||
store.setWorkspaceSession(staleSession, hostId)
|
||||
store.upsertSshRemotePtyLease({
|
||||
targetId: connectionId,
|
||||
ptyId: 'rapid-churn-stale-pty',
|
||||
worktreeId: workspaceKey,
|
||||
tabId: 'rapid-churn-stale-tab',
|
||||
leafId: TEST_LEAF_1,
|
||||
state: 'detached'
|
||||
})
|
||||
|
||||
expect(store.getWorkspaceSession(hostId).tabsByWorktree[workspaceKey]).toBeUndefined()
|
||||
expect(store.getSshRemotePtyLeases()).toEqual([])
|
||||
})
|
||||
|
||||
it('bounds detailed tab-owner evidence for one deleted folder', async () => {
|
||||
const store = await createStore()
|
||||
const hostId = toRuntimeExecutionHostId('tab-evidence-runtime')
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Tab evidence',
|
||||
parentPath: '/remote/tab-evidence',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id })
|
||||
const workspaceKey = folderWorkspaceKey(workspace.id)
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
const tabId = `delayed-tab-${index}`
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })]
|
||||
}
|
||||
},
|
||||
hostId
|
||||
)
|
||||
}
|
||||
store.flush()
|
||||
|
||||
const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[
|
||||
workspaceKey
|
||||
]
|
||||
expect(Object.keys(tombstone?.tabConnectionIdsByHostId[hostId] ?? {})).toHaveLength(256)
|
||||
const ownerIndex = (
|
||||
store as unknown as {
|
||||
deletedFolderOwnersByHostAndTabId: Map<string, Map<string, unknown>>
|
||||
}
|
||||
).deletedFolderOwnersByHostAndTabId
|
||||
expect(ownerIndex.get(hostId)?.size).toBe(256)
|
||||
})
|
||||
|
||||
it('bounds host-partition evidence for one deleted folder', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Host evidence',
|
||||
parentPath: '/remote/host-evidence',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id })
|
||||
const workspaceKey = folderWorkspaceKey(workspace.id)
|
||||
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
|
||||
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const hostId = toRuntimeExecutionHostId(`host-evidence-${index}`)
|
||||
const tabId = `host-evidence-tab-${index}`
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[workspaceKey]: [makeTerminalTab({ id: tabId, worktreeId: workspaceKey })]
|
||||
}
|
||||
},
|
||||
hostId
|
||||
)
|
||||
}
|
||||
store.flush()
|
||||
|
||||
const tombstone = (readDataFile() as PersistedState).deletedFolderWorkspaceSessionTombstones?.[
|
||||
workspaceKey
|
||||
]
|
||||
expect(tombstone?.hostIds).toHaveLength(32)
|
||||
expect(Object.keys(tombstone?.tabConnectionIdsByHostId ?? {})).toHaveLength(32)
|
||||
const ownerIndex = (
|
||||
store as unknown as {
|
||||
deletedFolderOwnersByHostAndTabId: Map<string, Map<string, unknown>>
|
||||
}
|
||||
).deletedFolderOwnersByHostAndTabId
|
||||
expect(ownerIndex.size).toBe(32)
|
||||
})
|
||||
|
||||
it('durably fences a delayed unified-only folder tab before a layout-only write', async () => {
|
||||
const store = await createStore()
|
||||
const connectionId = 'unified-only-delayed-target'
|
||||
|
||||
+81
-34
@@ -88,6 +88,12 @@ import {
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context'
|
||||
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
|
||||
} from './deleted-folder-session-tombstones'
|
||||
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
|
||||
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
|
||||
import {
|
||||
@@ -567,8 +573,6 @@ const WORKSPACE_SESSION_PATCH_OWNER_KEYS = new Set<keyof WorkspaceSessionState>(
|
||||
'terminalSurfaceTombstonesByPaneKey',
|
||||
'activeWorkspaceExecutionHostId'
|
||||
])
|
||||
const MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES = 512
|
||||
|
||||
function logPersistenceStartupMilestone(
|
||||
event: string,
|
||||
details: Record<string, unknown> = {}
|
||||
@@ -2871,6 +2875,7 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { tombstones, changed }
|
||||
}
|
||||
const now = Date.now()
|
||||
for (const [workspaceKey, rawTombstone] of Object.entries(value)) {
|
||||
const scope = parseWorkspaceKey(workspaceKey)
|
||||
if (
|
||||
@@ -2885,6 +2890,17 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
}
|
||||
const raw = rawTombstone as Partial<DeletedFolderWorkspaceSessionTombstone>
|
||||
const connectionId = typeof raw.connectionId === 'string' ? raw.connectionId : null
|
||||
const deletedAt =
|
||||
typeof raw.deletedAt === 'number' &&
|
||||
Number.isFinite(raw.deletedAt) &&
|
||||
raw.deletedAt >= 0 &&
|
||||
raw.deletedAt <= now
|
||||
? raw.deletedAt
|
||||
: now - MIN_DELETED_FOLDER_TOMBSTONE_RETENTION_MS
|
||||
if (now - deletedAt >= MAX_DELETED_FOLDER_TOMBSTONE_RETENTION_MS) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
for (const rawHostId of Array.isArray(raw.hostIds) ? raw.hostIds : []) {
|
||||
const hostId = normalizeExecutionHostId(rawHostId)
|
||||
@@ -2917,22 +2933,19 @@ function normalizeDeletedFolderWorkspaceSessionTombstones(
|
||||
)
|
||||
}
|
||||
}
|
||||
const normalizedTombstone: DeletedFolderWorkspaceSessionTombstone = {
|
||||
const normalizedTombstone = boundDeletedFolderTombstoneEvidence({
|
||||
connectionId,
|
||||
deletedAt,
|
||||
hostIds: [...hostIds],
|
||||
tabConnectionIdsByHostId
|
||||
}
|
||||
})
|
||||
tombstones[workspaceKey as WorkspaceKey] = normalizedTombstone
|
||||
if (JSON.stringify(rawTombstone) !== JSON.stringify(normalizedTombstone)) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
const workspaceKeys = Object.keys(tombstones)
|
||||
for (const workspaceKey of workspaceKeys.slice(
|
||||
0,
|
||||
Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES)
|
||||
)) {
|
||||
delete tombstones[workspaceKey as WorkspaceKey]
|
||||
for (const workspaceKey of getDeletedFolderTombstoneEvictionKeys(tombstones, now)) {
|
||||
delete tombstones[workspaceKey]
|
||||
changed = true
|
||||
}
|
||||
return { tombstones, changed }
|
||||
@@ -3032,18 +3045,29 @@ export class Store {
|
||||
if (!tombstone) {
|
||||
continue
|
||||
}
|
||||
if (tombstone.connectionId) {
|
||||
this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId)
|
||||
this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
|
||||
}
|
||||
}
|
||||
|
||||
private indexDeletedFolderWorkspaceSessionTombstone(
|
||||
workspaceKey: string,
|
||||
tombstone: DeletedFolderWorkspaceSessionTombstone
|
||||
): void {
|
||||
if (tombstone.connectionId) {
|
||||
this.deletedFolderConnectionIdByWorkspaceKey.set(workspaceKey, tombstone.connectionId)
|
||||
}
|
||||
for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) {
|
||||
const resolvedHostId = normalizeExecutionHostId(hostId)
|
||||
if (!resolvedHostId) {
|
||||
continue
|
||||
}
|
||||
for (const [hostId, tabConnectionIds] of Object.entries(tombstone.tabConnectionIdsByHostId)) {
|
||||
const resolvedHostId = normalizeExecutionHostId(hostId)
|
||||
if (!resolvedHostId) {
|
||||
continue
|
||||
}
|
||||
for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) {
|
||||
this.rememberDeletedFolderTabOwner(resolvedHostId, tabId, workspaceKey, connectionId)
|
||||
}
|
||||
const ownersByTabId = this.deletedFolderOwnersByHostAndTabId.get(resolvedHostId) ?? new Map()
|
||||
for (const [tabId, connectionId] of Object.entries(tabConnectionIds ?? {})) {
|
||||
const owners = ownersByTabId.get(tabId) ?? new Map<string, string | null>()
|
||||
owners.set(workspaceKey, connectionId)
|
||||
ownersByTabId.set(tabId, owners)
|
||||
}
|
||||
this.deletedFolderOwnersByHostAndTabId.set(resolvedHostId, ownersByTabId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4558,6 +4582,7 @@ export class Store {
|
||||
const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
|
||||
this.setDeletedFolderWorkspaceSessionTombstone(workspaceKey, {
|
||||
connectionId: connectionId ?? prior?.connectionId ?? null,
|
||||
deletedAt: prior?.deletedAt ?? Date.now(),
|
||||
hostIds: [...new Set([...(prior?.hostIds ?? []), ...hostIds])],
|
||||
tabConnectionIdsByHostId: prior?.tabConnectionIdsByHostId ?? {}
|
||||
})
|
||||
@@ -4692,18 +4717,21 @@ export class Store {
|
||||
this.deletedFolderOwnersByHostAndTabId.set(hostId, ownersByTabId)
|
||||
const prior = this.state.deletedFolderWorkspaceSessionTombstones?.[workspaceKey as WorkspaceKey]
|
||||
const tabConnectionIdsByHostId = { ...prior?.tabConnectionIdsByHostId }
|
||||
tabConnectionIdsByHostId[hostId] = {
|
||||
...tabConnectionIdsByHostId[hostId],
|
||||
[tabId]: connectionId ?? tabConnectionIdsByHostId[hostId]?.[tabId] ?? null
|
||||
}
|
||||
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(),
|
||||
hostIds: [
|
||||
...new Set([
|
||||
...(prior?.hostIds ?? []),
|
||||
hostId,
|
||||
...(connectionId ? [toSshExecutionHostId(connectionId)] : [])
|
||||
])
|
||||
...new Set([...retainedHostIds, hostId, ...(connectionHostId ? [connectionHostId] : [])])
|
||||
],
|
||||
tabConnectionIdsByHostId
|
||||
})
|
||||
@@ -4713,20 +4741,37 @@ export class Store {
|
||||
workspaceKey: string,
|
||||
tombstone: DeletedFolderWorkspaceSessionTombstone
|
||||
): void {
|
||||
tombstone = boundDeletedFolderTombstoneEvidence(tombstone)
|
||||
const tombstones = { ...this.state.deletedFolderWorkspaceSessionTombstones }
|
||||
delete tombstones[workspaceKey as WorkspaceKey]
|
||||
tombstones[workspaceKey as WorkspaceKey] = tombstone
|
||||
const workspaceKeys = Object.keys(tombstones)
|
||||
for (const evictedWorkspaceKey of workspaceKeys.slice(
|
||||
0,
|
||||
Math.max(0, workspaceKeys.length - MAX_DELETED_FOLDER_WORKSPACE_SESSION_TOMBSTONES)
|
||||
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
|
||||
this.indexDeletedFolderWorkspaceSessionTombstone(workspaceKey, tombstone)
|
||||
for (const evictedWorkspaceKey of getDeletedFolderTombstoneEvictionKeys(
|
||||
tombstones,
|
||||
Date.now()
|
||||
)) {
|
||||
delete tombstones[evictedWorkspaceKey as WorkspaceKey]
|
||||
delete tombstones[evictedWorkspaceKey]
|
||||
this.forgetDeletedFolderWorkspaceSessionTombstone(evictedWorkspaceKey)
|
||||
}
|
||||
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
|
||||
}
|
||||
|
||||
private pruneDeletedFolderWorkspaceSessionTombstones(): void {
|
||||
const current = this.state.deletedFolderWorkspaceSessionTombstones ?? {}
|
||||
const evictedWorkspaceKeys = getDeletedFolderTombstoneEvictionKeys(current, Date.now())
|
||||
if (evictedWorkspaceKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
const tombstones = { ...current }
|
||||
for (const workspaceKey of evictedWorkspaceKeys) {
|
||||
delete tombstones[workspaceKey]
|
||||
this.forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey)
|
||||
}
|
||||
this.state.deletedFolderWorkspaceSessionTombstones = tombstones
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
private forgetDeletedFolderWorkspaceSessionTombstone(workspaceKey: string): void {
|
||||
this.deletedFolderConnectionIdByWorkspaceKey.delete(workspaceKey)
|
||||
for (const [hostId, ownersByTabId] of this.deletedFolderOwnersByHostAndTabId) {
|
||||
@@ -4858,6 +4903,7 @@ export class Store {
|
||||
}
|
||||
|
||||
private isDeletedFolderWorkspaceKey(worktreeId: string): boolean {
|
||||
this.pruneDeletedFolderWorkspaceSessionTombstones()
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
return Boolean(
|
||||
scope?.type === 'folder' &&
|
||||
@@ -5057,6 +5103,7 @@ export class Store {
|
||||
session: WorkspaceSessionState,
|
||||
hostId: ExecutionHostId
|
||||
): WorkspaceSessionState {
|
||||
this.pruneDeletedFolderWorkspaceSessionTombstones()
|
||||
const liveFolderIds = new Set(
|
||||
(this.state.folderWorkspaces ?? []).map((workspace) => workspace.id)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { FolderWorkspace, ProjectGroup, WorkspaceSessionState } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const ipcMain = {
|
||||
on: vi.fn(() => ipcMain),
|
||||
removeListener: vi.fn(() => ipcMain),
|
||||
emit: vi.fn(() => true)
|
||||
}
|
||||
return {
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
|
||||
ipcMain,
|
||||
Notification: vi.fn(),
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function makeSession(worktreeId: string): WorkspaceSessionState {
|
||||
const ptyId = `${worktreeId}@@race-pty`
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: TAB_ID,
|
||||
activeTabIdByWorktree: { [worktreeId]: TAB_ID },
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
ptyId,
|
||||
worktreeId,
|
||||
title: 'Persisted terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey(TAB_ID, LEAF_ID)]: '33333333-3333-4333-8333-333333333333'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRuntimeService {
|
||||
const session = makeSession(`folder:${folders[0]!.id}`)
|
||||
return new OrcaRuntimeService({
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
getFolderWorkspaces: () => folders,
|
||||
getProjectGroups: () => [group],
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSessionHostIds: () => ['local'],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/workspace',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe('mobile folder session mutation races', () => {
|
||||
it.each(['activate', 'close'] as const)(
|
||||
'rejects a folder tab %s when deletion races PTY inventory',
|
||||
async (operation) => {
|
||||
const folder: FolderWorkspace = {
|
||||
id: `${operation}-race-folder`,
|
||||
projectGroupId: `${operation}-race-group`,
|
||||
name: `${operation} race`,
|
||||
folderPath: `/workspace/${operation}-race`,
|
||||
connectionId: null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group: ProjectGroup = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const folders = [folder]
|
||||
const runtime = createRuntime(folders, group)
|
||||
await runtime.listMobileSessionTabs(`id:${folderKey}`)
|
||||
let releaseInventory!: (value: []) => void
|
||||
const inventory = new Promise<[]>((resolve) => {
|
||||
releaseInventory = resolve
|
||||
})
|
||||
const listProcesses = vi.fn(() => inventory)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses
|
||||
})
|
||||
|
||||
const mutation =
|
||||
operation === 'activate'
|
||||
? runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
|
||||
: runtime.closeMobileSessionTab(`id:${folderKey}`, TAB_ID)
|
||||
await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledOnce())
|
||||
folders.length = 0
|
||||
releaseInventory([])
|
||||
|
||||
await expect(mutation).rejects.toThrow('tab_not_found')
|
||||
await expect(runtime.listMobileSessionTabs(`id:${folderKey}`)).resolves.toMatchObject({
|
||||
worktree: folderKey,
|
||||
removed: true,
|
||||
tabs: []
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -4920,6 +4920,20 @@ export class OrcaRuntimeService {
|
||||
return recreatedFolderWorktreeIds
|
||||
}
|
||||
|
||||
private assertMobileSessionFolderWorkspaceExists(worktreeId: string): void {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type !== 'folder' || !this.store?.getFolderWorkspaces) {
|
||||
return
|
||||
}
|
||||
const folderWorkspaces = this.store.getFolderWorkspaces()
|
||||
this.pruneRemovedFolderMobileSessionTabs(folderWorkspaces)
|
||||
if (folderWorkspaces.some((workspace) => workspace.id === scope.folderWorkspaceId)) {
|
||||
return
|
||||
}
|
||||
this.rememberRemovedFolderMobileSessionTabs(worktreeId)
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
|
||||
private createFolderWorkspaceInventoryFence(
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
): () => boolean {
|
||||
@@ -8006,6 +8020,7 @@ export class OrcaRuntimeService {
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
await this.refreshMobileSessionPtyRecords(worktreeId)
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId)
|
||||
const tab = leafId
|
||||
@@ -8350,6 +8365,7 @@ export class OrcaRuntimeService {
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
if (graphEpoch !== null) {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
}
|
||||
|
||||
@@ -3566,6 +3566,7 @@ export type PersistedMobileClientTabSelections = Record<
|
||||
|
||||
export type DeletedFolderWorkspaceSessionTombstone = {
|
||||
connectionId: string | null
|
||||
deletedAt: number
|
||||
hostIds: ExecutionHostId[]
|
||||
tabConnectionIdsByHostId: Partial<Record<ExecutionHostId, Record<string, string | null>>>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user