Main drops the layout of workspaces the catalog no longer validates (dark, layout-owner build only) (#30177)

* feat(layout): main drops the layout of workspaces the catalog no longer validates (dark, layout-owner build only)

* rename the paired-host check so the switch-off bundle keeps its names

* a repo id two hosts register skips the listing purge
This commit is contained in:
Jinwoo Hong
2026-10-17 22:19:35 -07:00
committed by GitHub
parent e0abf71a62
commit b8264e860b
11 changed files with 390 additions and 9 deletions
+1
View File
@@ -42,6 +42,7 @@
4 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-subtraction.ts
1 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts
1 src/main/persistence/restoring-sessions/folder-workspace-operations.ts
1 src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts
5 src/main/persistence/restoring-sessions/session-owner-fields.ts
12 src/main/persistence/restoring-sessions/session-owner-removal.ts
2 src/main/persistence/restoring-sessions/workspace-pane-normalization.ts
@@ -21,6 +21,8 @@ import {
isCapturedRepoCurrent,
resolveRepoOwnershipEvidence
} from './worktree-host-ownership'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { dropWorkspacesTheListingInvalidates } from '../../../persistence/restoring-sessions/invalid-workspace-layout-purge'
export async function listHostQualifiedDetectedWorktrees(
store: Store,
@@ -90,6 +92,9 @@ export async function listHostQualifiedDetectedWorktrees(
return capturedAuthority !== null && isCurrentSshProviderAuthority(capturedAuthority)
}
const result = await listDetectedWorktreesForCapturedRepo(store, repo, isCurrent, providerAbort)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
dropWorkspacesTheListingInvalidates(store, result)
}
if (!result) {
return rejected('stale')
}
@@ -23,6 +23,8 @@ import {
import { listHostQualifiedDetectedWorktrees } from './host-qualified-worktree-listing'
import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { dropWorkspacesTheListingInvalidates } from '../../../persistence/restoring-sessions/invalid-workspace-layout-purge'
export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000
@@ -113,6 +115,9 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v
isCapturedRepoCurrent(store, repo) &&
(!connectionId || (authority !== undefined && isCurrentSshProviderAuthority(authority)))
)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
dropWorkspacesTheListingInvalidates(store, result)
}
return result && !('providerAbortStatus' in result)
? result
: {
@@ -0,0 +1,242 @@
import {
closeTestStores,
testState,
createStore,
writeDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../shared/constants'
import { folderWorkspaceKey } from '../shared/workspace-scope'
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
import type { DetectedWorktreeListResult } from '../shared/worktree/types'
import type { WorkspaceLayoutEvent } from '../shared/workspace-layout/workspace-layout-stream-frames'
import {
bumpLocalWorktreeScanGeneration,
getLocalWorktreeCatalogVersion
} from './local-worktree-scan-generation'
import { dropWorkspacesTheListingInvalidates } from './persistence/restoring-sessions/invalid-workspace-layout-purge'
import { WorkspaceLayoutStream } from './runtime/workspace-layout-stream'
vi.mock('../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const REPO = 'repo-a'
const KEPT = `${REPO}::/work/kept`
const DELETED = `${REPO}::/work/deleted`
const SSH_REPO = 'ssh-repo'
const SSH_WORKTREE = `${SSH_REPO}::/home/me/app`
const SSH_HOST = 'ssh:target-1'
const RUNTIME_HOST = 'runtime:env-a'
const PAIRED_WORKTREE = 'paired-repo::/srv/app'
function sessionWith(...worktreeIds: string[]): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: Object.fromEntries(
worktreeIds.map((worktreeId, index) => [
worktreeId,
[makeTerminalTab({ id: `tab-${index}`, ptyId: `pty-${index}`, worktreeId })]
])
)
}
}
function listing(
worktreeIds: string[],
overrides: Partial<DetectedWorktreeListResult> = {}
): DetectedWorktreeListResult {
return {
repoId: REPO,
authoritative: true,
source: 'git',
catalogVersion: getLocalWorktreeCatalogVersion(REPO),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the purge reads only each row's id.
worktrees: worktreeIds.map((id) => ({ id }) as DetectedWorktreeListResult['worktrees'][number]),
...overrides
}
}
const storedKeys = (store: ReturnType<typeof createStore>, hostId?: string): string[] =>
Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree).sort()
describe('main drops the layout of workspaces the catalog no longer validates', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-invalid-layout-purge-'))
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
describe('at load', () => {
it('drops a stored workspace of a deregistered repo', () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: REPO, path: '/work/kept' })],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, 'gone-repo::/work/gone')
})
expect(storedKeys(createStore())).toEqual([KEPT])
})
it('drops a stored workspace whose key names no registered repo, as the window does', () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: REPO, path: '/work/kept' })],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, 'legacy-bare-key')
})
expect(storedKeys(createStore())).toEqual([KEPT])
})
it('keeps a degraded but registered repo, a disconnected SSH repo, folders and the floating workspace', () => {
const folderKey = folderWorkspaceKey('folder-never-listed')
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({ id: REPO, path: '/work/kept' }),
makeRepo({ id: SSH_REPO, path: '/home/me/app', connectionId: 'target-1' })
],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, DELETED, folderKey, FLOATING_TERMINAL_WORKTREE_ID),
workspaceSessionsByHostId: { [SSH_HOST]: sessionWith(SSH_WORKTREE) }
})
const store = createStore()
expect(storedKeys(store)).toEqual(
[KEPT, DELETED, folderKey, FLOATING_TERMINAL_WORKTREE_ID].sort()
)
expect(storedKeys(store, SSH_HOST)).toEqual([SSH_WORKTREE])
})
it("keeps a paired host's workspaces, whose catalog lives on that host", () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: { [PAIRED_WORKTREE]: { hostId: RUNTIME_HOST, status: 'active' } },
workspaceSession: sessionWith(PAIRED_WORKTREE),
workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionWith(PAIRED_WORKTREE, 'bare-paired') }
})
const store = createStore()
expect(storedKeys(store)).toEqual([PAIRED_WORKTREE])
expect(storedKeys(store, RUNTIME_HOST)).toEqual(['bare-paired', PAIRED_WORKTREE].sort())
})
})
describe('when a listing changes the catalog', () => {
function seededStore(): ReturnType<typeof createStore> {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({ id: REPO, path: '/work/kept' }),
makeRepo({ id: 'other-repo', path: '/x' })
],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, DELETED, 'other-repo::/x')
})
return createStore()
}
it("drops the workspace an authoritative scan no longer finds, and the window's frames lose it", async () => {
const store = seededStore()
const stream = new WorkspaceLayoutStream({
store: () => store,
homeHostId: () => 'local',
liveTitle: () => null
})
const events: WorkspaceLayoutEvent[] = []
const { snapshot } = stream.subscribe((event) => events.push(event))
expect(snapshot.map((entry) => entry.key)).toContain(DELETED)
dropWorkspacesTheListingInvalidates(store, listing([KEPT]))
await Promise.resolve()
// Other repos have no listing in this catalog, so they are untouched.
expect(storedKeys(store)).toEqual([KEPT, 'other-repo::/x'].sort())
expect(events).toContainEqual(expect.objectContaining({ type: 'removed', key: DELETED }))
})
it('keeps every workspace after a scan that could not prove deletion', () => {
const store = seededStore()
dropWorkspacesTheListingInvalidates(
store,
listing([], { authoritative: false, source: 'metadata-fallback' })
)
dropWorkspacesTheListingInvalidates(
store,
listing([KEPT], { authoritative: false, source: 'metadata-fallback' })
)
dropWorkspacesTheListingInvalidates(store, null)
dropWorkspacesTheListingInvalidates(store, { providerAbortStatus: 'timed-out' })
expect(storedKeys(store)).toEqual([KEPT, DELETED, 'other-repo::/x'].sort())
})
it('keeps every workspace when a create or remove landed after the scan began', () => {
const store = seededStore()
const stale = listing([KEPT])
bumpLocalWorktreeScanGeneration(REPO)
dropWorkspacesTheListingInvalidates(store, stale)
dropWorkspacesTheListingInvalidates(store, listing([KEPT], { catalogVersion: undefined }))
expect(storedKeys(store)).toEqual([KEPT, DELETED, 'other-repo::/x'].sort())
})
it('keeps every workspace of a repo id another host also registers', () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({ id: REPO, path: '/work/kept' }),
makeRepo({ id: REPO, path: '/home/me/kept', connectionId: 'target-1' })
],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, DELETED)
})
const store = createStore()
dropWorkspacesTheListingInvalidates(store, listing([KEPT]))
expect(storedKeys(store)).toEqual([KEPT, DELETED].sort())
})
})
// Pending until Core-A: today a window save still carries structure, so it can restore the key.
it.skip('a later window save that still names a dropped workspace does not bring it back', () => {
const store = (() => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: REPO, path: '/work/kept' })],
worktreeMeta: {},
workspaceSession: sessionWith(KEPT, DELETED)
})
return createStore()
})()
dropWorkspacesTheListingInvalidates(store, listing([KEPT]))
store.setWorkspaceSession(sessionWith(KEPT, DELETED), 'local')
expect(storedKeys(store)).toEqual([KEPT])
})
})
@@ -17,6 +17,8 @@ import {
type StoreDomainOperations
} from './store-domain-composition'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { dropInvalidWorkspaceLayoutsAtLoad } from '../restoring-sessions/invalid-workspace-layout-purge'
import { scheduleSave } from './write-scheduling'
import {
migrateLegacyOpenCodeGoApiKey,
@@ -124,6 +126,9 @@ export class Store {
// Load is the only place an orphaned repo id can be swept: every removal path needs the repo to
// still be registered, so rows outlive their owner without one (#17776).
const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue()
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
dropInvalidWorkspaceLayoutsAtLoad(this, this.state)
}
// Imported snapshots cannot own the live hook server or write their source file.
if (!imported) {
// A committed migration whose blob install failed or was cut short is finished here; shell
@@ -0,0 +1,123 @@
// Layout-owner build only: main drops the stored layout of workspaces the catalog no longer
// validates, by the same rule the window applies when it hydrates, so neither keeps what the other dropped.
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { isWorktreeCatalogVersionBefore } from '../../../shared/worktree/catalog-version'
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from '../../../shared/worktree/degraded-repo-worktree-validity'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import type { DetectedWorktreeListResult } from '../../../shared/worktree/types'
import { getLocalWorktreeCatalogVersion } from '../../local-worktree-scan-generation'
type WorkspaceValidityCatalog = Parameters<typeof buildValidWorktreeIdsForSessionHydration>[0]
type PurgeStore = {
getRepos(): readonly { id: string }[]
getWorkspaceSession(hostId?: string | null): WorkspaceSessionState
getWorkspaceSessionHostIds(): ExecutionHostId[]
removeWorkspaceSessionStateForWorktree(worktreeId: string, hostId?: ExecutionHostId | null): void
}
const ownedByPairedHost = (hostId: string | null | undefined): boolean =>
parseExecutionHostId(hostId)?.kind === 'runtime'
/** The window's hydration rule: floating and every folder key always stay (the window adds them). */
function invalidWorkspaceKeys(
session: WorkspaceSessionState,
catalog: WorkspaceValidityCatalog,
inScope: (key: string) => boolean
): string[] {
const persisted = [...collectPersistedWorktreeIdsForSessionHydration(session)].filter(
(key) =>
inScope(key) &&
key !== FLOATING_TERMINAL_WORKTREE_ID &&
parseWorkspaceKey(key)?.type !== 'folder'
)
const valid = buildValidWorktreeIdsForSessionHydration(catalog, persisted)
return persisted.filter((key) => !valid.has(key))
}
function dropInvalidWorkspaces(
store: PurgeStore,
catalog: WorkspaceValidityCatalog,
inScope: (key: string) => boolean = () => true
): void {
for (const hostId of store.getWorkspaceSessionHostIds()) {
// A paired host's catalog lives on that host; not seeing it here proves nothing.
if (ownedByPairedHost(hostId)) {
continue
}
for (const key of invalidWorkspaceKeys(store.getWorkspaceSession(hostId), catalog, inScope)) {
store.removeWorkspaceSessionStateForWorktree(key, hostId)
}
}
}
/**
* At load no listing has run, so only an unregistered repo is invalid. Repos a paired host still
* names, and a staged migration's repos, count as registered, exactly as the load sweep keeps them.
*/
export function dropInvalidWorkspaceLayoutsAtLoad(store: PurgeStore, state: PersistedState): void {
const repoIds = new Set(store.getRepos().map((repo) => repo.id))
for (const staged of state.orcadMigrationStagedCatalogs ?? []) {
for (const repo of staged.manifest.payload.repositories) {
repoIds.add(repo.id)
}
}
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
if (ownedByPairedHost(meta.hostId)) {
repoIds.add(getRepoIdFromWorktreeId(worktreeId))
}
}
for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) {
if (session && ownedByPairedHost(hostId)) {
for (const key of collectPersistedWorktreeIdsForSessionHydration(session)) {
repoIds.add(getRepoIdFromWorktreeId(key))
}
}
}
dropInvalidWorkspaces(store, {
repos: [...repoIds].map((id) => ({ id })),
worktreesByRepo: {}
})
}
/**
* After a listing main serves: drops that repo's workspaces an authoritative scan no longer finds.
* Hidden rows still count as found: hiding a worktree must not close its tabs.
*/
export function dropWorkspacesTheListingInvalidates(
store: PurgeStore,
result: DetectedWorktreeListResult | { providerAbortStatus: unknown } | null
): void {
if (!result || 'providerAbortStatus' in result) {
return
}
const { repoId, catalogVersion } = result
// A create or remove since the scan began leaves its rows short of live worktrees.
if (
!catalogVersion ||
isWorktreeCatalogVersionBefore(catalogVersion, getLocalWorktreeCatalogVersion(repoId))
) {
return
}
// A repo id registered on two hosts shares its keys; one host's scan cannot speak for the other.
if (store.getRepos().filter((repo) => repo.id === repoId).length !== 1) {
return
}
dropInvalidWorkspaces(
store,
{
repos: store.getRepos(),
worktreesByRepo: { [repoId]: result.worktrees },
detectedWorktreesByRepo: { [repoId]: result }
},
(key) => getRepoIdFromWorktreeId(key) === repoId
)
}
@@ -2,7 +2,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from '../degraded-repo-worktree-validity'
import { buildValidWorktreeIdsForSessionHydration } from '../../../../../shared/worktree/degraded-repo-worktree-validity'
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
import { projectHydratedBrowserSession } from './browser-hydration-state'
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
@@ -3,7 +3,7 @@ import type { EditorSlice } from '../types/editor-slice'
import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { buildValidWorktreeIdsForSessionHydration } from '../../degraded-repo-worktree-validity'
import { buildValidWorktreeIdsForSessionHydration } from '../../../../../../shared/worktree/degraded-repo-worktree-validity'
import { migrateHydratedEditorTabsAndGroups } from '../file-ids/hydrated-editor-file-ids'
import { buildHydratedEditorSessionState } from './hydrated-editor-session-state'
@@ -5,7 +5,7 @@ import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydrati
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from '../degraded-repo-worktree-validity'
} from '../../../../../shared/worktree/degraded-repo-worktree-validity'
import { buildHydratedTabState } from '../tabs-hydration'
import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation'
import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch'
@@ -10,7 +10,7 @@ import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydrati
import {
buildValidWorktreeIdsForSessionHydration,
collectPersistedWorktreeIdsForSessionHydration
} from '../slices/degraded-repo-worktree-validity'
} from '../../../../shared/worktree/degraded-repo-worktree-validity'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { buildRuntimeSessionPlaceholders } from './workspace-terminal-placeholders'
import {
@@ -1,8 +1,8 @@
import type { Repo } from '../../../../shared/repo-types'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { Repo } from '../repo-types'
import type { WorkspaceSessionState } from '../workspace-session-state-types'
import type { DetectedWorktreeListResult, Worktree } from './types'
import { parseWorkspaceKey } from '../workspace-scope'
import { getRepoIdFromWorktreeId } from './id'
type WorktreeValidityCatalog = {
repos: readonly Pick<Repo, 'id'>[]