diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index de058e072da..78eb307baba 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -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 diff --git a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts index 3e1798fda2d..905e6b2378d 100644 --- a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts +++ b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.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') } diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index ed9ca41aed3..87e0ee899f7 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -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 : { diff --git a/src/main/persistence-invalid-workspace-layout-purge.test.ts b/src/main/persistence-invalid-workspace-layout-purge.test.ts new file mode 100644 index 00000000000..dbc62314021 --- /dev/null +++ b/src/main/persistence-invalid-workspace-layout-purge.test.ts @@ -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 { + 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, 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 { + 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]) + }) +}) diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 9a231075311..21c89b80641 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -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 diff --git a/src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts b/src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts new file mode 100644 index 00000000000..f292b6aff47 --- /dev/null +++ b/src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts @@ -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[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 + ) +} diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index 22e50b29db6..e3b32623c18 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -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' diff --git a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts index 894908c889b..75c94fb5e96 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts @@ -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' diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index e33f1f533cd..43955147687 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -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' diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 2f049441f61..00856998b6d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -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 { diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/shared/worktree/degraded-repo-worktree-validity.ts similarity index 87% rename from src/renderer/src/store/slices/degraded-repo-worktree-validity.ts rename to src/shared/worktree/degraded-repo-worktree-validity.ts index 16b97061df1..97cc48aea06 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/shared/worktree/degraded-repo-worktree-validity.ts @@ -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[]