From 95d1e9127ef73b6333edbb66ea6f644477df6c1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:10:36 -0800 Subject: [PATCH] fix(wsl-hosts): a stopped distro still lists all its projects after a restart (#37396) * fix(wsl-hosts): a stopped distro still lists all its projects after a restart The desktop now keeps each WSL host's last project list (from the repo.list answers the window already receives) and seeds those rows at startup before any live host catalog is requested. The first live list replaces them, so projects deleted on the host disappear on that fetch. Loading the saved list is a desktop read only: no wsl.exe, no host RPC, no \\wsl.localhost access. The saved list is cleared when the host is removed. * fix(wsl-hosts): pin saved project list writes, removal, WSL gate and seed races - Exempt HostRepoCatalogSnapshotPersistence.environmentId from the id-only store write ratchet like the paired-server layout snapshot (the id names the host). - A live list applied before the startup seed now skips that host's seed, and a live answer requested before the seed may still drop seeded rows. - Tests now count saves, cover remove then re-add, check paired servers in storage and in the listing, and cover both seed/live-list orderings. --- .../check-id-only-store-write-ratchet.mjs | 1 + .../host-repo-catalog-snapshot-persistence.ts | 46 ++++ .../host-repo-catalog-snapshot-recorder.ts | 53 ++++ .../host-repo-catalog-snapshot.test.ts | 235 ++++++++++++++++++ ...hemeral-vm-runtime-handler-cleanup.test.ts | 3 +- src/main/ipc/repos.ts | 1 + src/main/ipc/repos/repo-catalog-handlers.ts | 14 ++ ...orcad-runtime-maintenance-handlers.test.ts | 3 +- ...ntime-environment-connectivity-handlers.ts | 19 +- .../runtime-environment-removal-cleanup.ts | 9 +- ...untime-environment-removal-storage.test.ts | 6 +- .../runtime-environments-call-routing.test.ts | 4 + ...time-environments-capability-cache.test.ts | 2 + .../runtime-environments-pairing.test.ts | 3 + .../runtime-environments-repair.test.ts | 3 +- ...environments-subscription-teardown.test.ts | 2 + .../runtime-environments.ts | 5 +- src/main/ipc/wsl-host-gate-off.test.ts | 3 +- .../state-serialization-secret-handling.ts | 1 + .../loading-store/store-domain-composition.ts | 5 + .../profile-state-section-labels.ts | 1 + src/preload/api/repos-bridge.ts | 2 + src/preload/api/repository-api.ts | 3 + .../app-shell/use-app-startup-hydration.ts | 29 ++- .../repos/all-host-repo-catalog-actions.ts | 2 + .../repos/host-repo-catalog-seed.test.ts | 212 ++++++++++++++++ .../src/store/repos/host-repo-catalog-seed.ts | 67 +++++ .../src/store/repos/repo-catalog-identity.ts | 3 +- src/renderer/src/store/repos/repo-state.ts | 2 + .../repos/runtime-repo-catalog-actions.ts | 2 + .../store/repos/saved-host-catalog-rows.ts | 29 +++ src/renderer/src/store/slices/repos.ts | 2 + .../web/preload-api/web-repositories-api.ts | 2 + src/shared/host-repo-catalog-snapshot.ts | 56 +++++ src/shared/persisted-state-types.ts | 3 + .../wsl-drive-unc-state-dispositions.ts | 1 + src/shared/wsl-move/wsl-move-dispositions.ts | 5 + 37 files changed, 814 insertions(+), 25 deletions(-) create mode 100644 src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts create mode 100644 src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts create mode 100644 src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts create mode 100644 src/renderer/src/store/repos/host-repo-catalog-seed.test.ts create mode 100644 src/renderer/src/store/repos/host-repo-catalog-seed.ts create mode 100644 src/renderer/src/store/repos/saved-host-catalog-rows.ts create mode 100644 src/shared/host-repo-catalog-snapshot.ts diff --git a/config/scripts/check-id-only-store-write-ratchet.mjs b/config/scripts/check-id-only-store-write-ratchet.mjs index 6ee1118666a..93e760e174c 100644 --- a/config/scripts/check-id-only-store-write-ratchet.mjs +++ b/config/scripts/check-id-only-store-write-ratchet.mjs @@ -21,6 +21,7 @@ const MEMBER = /** `Class.param` first parameters whose id has no host twin, with the reason. */ export const NOT_HOST_TWINNED_PARAMS = new Map([ ['AutomationPersistence.automationId', 'automation ids are minted UUIDs'], + ['HostRepoCatalogSnapshotPersistence.environmentId', 'the id names the host itself'], ['OrcadSourceExportPersistence.targetId', 'the id names the host itself'], ['RemoteLayoutSnapshotPersistence.environmentId', 'the id names the host itself'], ['SessionHostPartitionOperations.hostId', 'the id names the host itself'], diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts new file mode 100644 index 00000000000..6642c8fbbae --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts @@ -0,0 +1,46 @@ +import type { HostRepoCatalogSnapshot } from '../../shared/host-repo-catalog-snapshot' +import type { StoreRuntimeState } from '../persistence/loading-store/store-runtime-state' +import type { WriteSchedulingOperations } from '../persistence/loading-store/write-scheduling' +import { scheduleSave } from '../persistence/loading-store/write-scheduling' + +type HostRepoCatalogSnapshotRuntime = Pick + +const hostRepoCatalogSnapshotPersistenceContext = Symbol('HostRepoCatalogSnapshotPersistence') +type HostRepoCatalogSnapshotPersistenceContext = { + runtime: HostRepoCatalogSnapshotRuntime + scheduling: WriteSchedulingOperations +} + +export class HostRepoCatalogSnapshotPersistence { + readonly [hostRepoCatalogSnapshotPersistenceContext]: HostRepoCatalogSnapshotPersistenceContext + + constructor(runtime: HostRepoCatalogSnapshotRuntime, scheduling: WriteSchedulingOperations) { + this[hostRepoCatalogSnapshotPersistenceContext] = { runtime, scheduling } + } + + getHostRepoCatalogSnapshots(): Record { + return ( + this[hostRepoCatalogSnapshotPersistenceContext].runtime.state.hostRepoCatalogSnapshots ?? {} + ) + } + + /** Null forgets the host. Saves only on change: every catalog refresh lands here. */ + setHostRepoCatalogSnapshot( + environmentId: string, + snapshot: HostRepoCatalogSnapshot | null + ): void { + const { runtime, scheduling } = this[hostRepoCatalogSnapshotPersistenceContext] + const { [environmentId]: previous, ...others } = runtime.state.hostRepoCatalogSnapshots ?? {} + const unchanged = + snapshot === null + ? previous === undefined + : previous !== undefined && JSON.stringify(previous) === JSON.stringify(snapshot) + if (unchanged) { + return + } + runtime.state.hostRepoCatalogSnapshots = snapshot + ? { ...others, [environmentId]: snapshot } + : others + scheduleSave(scheduling, ['hostRepoCatalogSnapshots']) + } +} diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts new file mode 100644 index 00000000000..5c403b5d89e --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts @@ -0,0 +1,53 @@ +// Keep a WSL host's project list from the catalog answers the window already asks for; never ask the host itself. + +import { + hostRepoCatalogSnapshotFromRepoList, + type HostRepoCatalogSnapshot +} from '../../shared/host-repo-catalog-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + isWslHostRuntimeEnvironment, + type OrcadDeploymentLink +} from '../../shared/runtime-environments' + +export type HostRepoCatalogSnapshotStore = { + getHostRepoCatalogSnapshots(): Record + setHostRepoCatalogSnapshot(environmentId: string, snapshot: HostRepoCatalogSnapshot | null): void +} + +type SavedEnvironment = { id: string; orcadDeployment?: OrcadDeploymentLink | null } + +/** Paired servers and recipe VMs are out of scope: only a WSL host is routinely stopped while saved. */ +export function recordHostRepoCatalogAnswer( + store: Pick, + environment: SavedEnvironment, + method: string, + response: RuntimeRpcResponse +): void { + if (method !== 'repo.list' || !response.ok || !isWslHostRuntimeEnvironment(environment)) { + return + } + const snapshot = hostRepoCatalogSnapshotFromRepoList(response.result) + if (snapshot) { + store.setHostRepoCatalogSnapshot(environment.id, snapshot) + } +} + +/** Saved snapshots of hosts still saved; a host removed outside Settings must not list again. */ +export function listHostRepoCatalogSnapshots( + store: Pick, + savedEnvironments: readonly SavedEnvironment[] +): Record { + const saved = store.getHostRepoCatalogSnapshots() + const result: Record = {} + for (const environment of savedEnvironments) { + const snapshot = isWslHostRuntimeEnvironment(environment) + ? // Re-validated: the saved shape is the answer shape, and a damaged profile must not seed bad rows. + hostRepoCatalogSnapshotFromRepoList(saved[environment.id]) + : null + if (snapshot) { + result[environment.id] = snapshot + } + } + return result +} diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts new file mode 100644 index 00000000000..6f4ba400a79 --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts @@ -0,0 +1,235 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { setSecretStore } from '../../shared/secret-store' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + listHostRepoCatalogSnapshots, + recordHostRepoCatalogAnswer +} from './host-repo-catalog-snapshot-recorder' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { Store } from '../persistence/loading-store/store' +import { retireRemovedRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-removal-cleanup' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plaintext) => Buffer.from(plaintext), + decryptString: (ciphertext) => ciphertext.toString(), + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function profile() { + const directory = mkdtempSync(join(tmpdir(), 'orca-host-repo-catalog-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const profileId = 'host-repo-catalog' + new ProfileStateSqliteAuthority(databasePath, profileId).writeSerializedState( + Buffer.from(JSON.stringify(getDefaultPersistedState(directory))) + ) + const open = () => { + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(store) + return store + } + return { open } +} + +const wslHost = { + id: 'env-wsl', + orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 41000, remotePort: 6768 } +} +const otherWslHost = { + id: 'env-wsl-2', + orcadDeployment: { kind: 'wsl' as const, distro: 'Debian', localPort: 41001, remotePort: 6768 } +} +const pairedServer = { id: 'env-paired', orcadDeployment: null } + +function repoListAnswer(repos: unknown[]): RuntimeRpcResponse { + return { id: 'repo.list', ok: true, result: { repos }, _meta: { runtimeId: 'server' } } +} + +const helloWorld = { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#22c55e', + addedAt: 1, + kind: 'git', + // Host-only detail the sidebar row does not need. + hookSettings: { setupScript: 'make' } +} +const django = { + id: 'django', + path: '/home/me/django', + displayName: 'django', + badgeColor: '#737373', + addedAt: 2 +} +const notes = { + id: 'notes', + path: '/home/me/notes', + displayName: 'notes', + badgeColor: '#737373', + addedAt: 3, + kind: 'folder' as const +} + +it("keeps a WSL host's projects across a restart, with only the row fields", () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer( + store, + wslHost, + 'repo.list', + repoListAnswer([helloWorld, django, notes]) + ) + store.flushOrThrow() + + const restarted = open() + expect(listHostRepoCatalogSnapshots(restarted, [wslHost])).toEqual({ + 'env-wsl': { + repos: [ + { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#22c55e', + addedAt: 1, + kind: 'git' + }, + { + id: 'django', + path: '/home/me/django', + displayName: 'django', + badgeColor: '#737373', + addedAt: 2 + }, + // A folder project keeps its kind so it renders as a folder while the host sleeps. + expect.objectContaining({ id: 'notes', kind: 'folder' }) + ] + } + }) +}) + +it('replaces the saved list with each answer and saves only when it changed', () => { + const { open } = profile() + const store = open() + let writes = 0 + store.onStateWritten((domains) => { + if (domains?.includes('hostRepoCatalogSnapshots')) { + writes++ + } + }) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + expect(writes).toBe(1) + + // Every catalog refresh lands here; an identical answer must not schedule a save. + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + expect(writes).toBe(1) + + // django was deleted on the host. + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + expect(writes).toBe(2) + store.flushOrThrow() + expect( + listHostRepoCatalogSnapshots(open(), [wslHost])['env-wsl']?.repos.map((repo) => repo.id) + ).toEqual(['hello']) +}) + +it('keeps each distro separately and ignores failures, other methods and paired servers', () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + recordHostRepoCatalogAnswer(store, otherWslHost, 'repo.list', repoListAnswer([django])) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', { + id: 'repo.list', + ok: false, + error: { code: 'wsl_distro_stopped', message: 'stopped' }, + _meta: { runtimeId: 'server' } + }) + recordHostRepoCatalogAnswer(store, wslHost, 'worktree.list', repoListAnswer([])) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', { + id: 'repo.list', + ok: true, + result: { nope: true }, + _meta: { runtimeId: 'server' } + }) + recordHostRepoCatalogAnswer(store, pairedServer, 'repo.list', repoListAnswer([notes])) + store.flushOrThrow() + + // Checked in storage, not through the listing, which filters paired servers on its own. + const stored = open().getHostRepoCatalogSnapshots() + expect(Object.keys(stored).sort()).toEqual(['env-wsl', 'env-wsl-2']) + expect(stored['env-wsl']?.repos.map((repo) => repo.id)).toEqual(['hello']) + expect(stored['env-wsl-2']?.repos.map((repo) => repo.id)).toEqual(['django']) +}) + +it('lists only WSL hosts, even when a paired server has a saved entry', () => { + const { open } = profile() + const store = open() + store.setHostRepoCatalogSnapshot('env-paired', { repos: [notes] }) + store.setHostRepoCatalogSnapshot('env-wsl', { repos: [django] }) + expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost, pairedServer]))).toEqual([ + 'env-wsl' + ]) +}) + +it('never lists a host no longer saved (removed outside Settings)', () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + recordHostRepoCatalogAnswer(store, otherWslHost, 'repo.list', repoListAnswer([django])) + expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost]))).toEqual(['env-wsl']) +}) + +it('does not seed stale projects when a removed WSL host is added back under the same id', async () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + + await retireRemovedRuntimeEnvironment(wslHost.id, () => undefined, store) + store.flushOrThrow() + + // WSL host ids are deterministic per distro, so re-adding it saves the same id again. + expect(listHostRepoCatalogSnapshots(open(), [wslHost])).toEqual({}) +}) diff --git a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts index 2a271058a9b..eddb101e12d 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts @@ -206,7 +206,8 @@ it("retires a recipe server's session partition once its VM is destroyed (#33805 })), removeWorkspaceSessionHost, getRemoteLayoutSnapshot: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never) await expect( diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index c3a729a6fe9..434e9207253 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,6 +22,7 @@ export function registerRepoHandlers( // macOS window recreation re-registers these handlers. ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listWslMoved') + ipcMain.removeHandler('repos:listHostCatalogSnapshots') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 3121210319f..6780dbd9d32 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -21,6 +21,10 @@ import { isWslMoveSourceHost } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listWslMovedProjects } from '../../persistence/moving-wsl-projects/wsl-moved-projects' +import { listHostRepoCatalogSnapshots } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' +import { listEnvironments } from '../../../shared/runtime-environment-store' +import { getRuntimeEnvironmentRegistryRoot } from '../runtime-environments/runtime-environment-registry-root' +import { isGatedOffWslHost } from '../../wsl/wsl-host-gate' export function registerRepoCatalogHandlers( mainWindow: BrowserWindow, @@ -42,6 +46,16 @@ export function registerRepoCatalogHandlers( listWslMovedProjects(store.getRepos(), store.getFolderWorkspaces()) ) + // Desktop-only reads (environment registry and saved state): never wakes a stopped distro. + ipcMain.handle('repos:listHostCatalogSnapshots', () => + listHostRepoCatalogSnapshots( + store, + listEnvironments(getRuntimeEnvironmentRegistryRoot()).filter( + (environment) => !isGatedOffWslHost(environment) + ) + ) + ) + ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts index b3893b29cce..1222910d6eb 100644 --- a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts @@ -33,7 +33,8 @@ const clearHostServerNotes = vi.fn() const store = { removeWorkspaceSessionHost: vi.fn(), getRemoteLayoutSnapshot: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } function handler(channel: string): (_event: unknown, args: unknown) => Promise { diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts index f725d15fde9..f95ddf6ed38 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts @@ -178,9 +178,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ) } -export function registerRuntimeEnvironmentPassiveHandlers(getUserDataPath: () => string): void { +export function registerRuntimeEnvironmentPassiveHandlers( + getUserDataPath: () => string, + recordAnswer?: RecordRuntimeEnvironmentAnswer +): void { registerPassiveStatusHandler(getUserDataPath) - registerPassiveCallHandler(getUserDataPath) + registerPassiveCallHandler(getUserDataPath, recordAnswer) } function closeLegacySelectorTransport(selector: string, environmentId: string): void { @@ -215,7 +218,16 @@ function registerPassiveStatusHandler(getUserDataPath: () => string): void { ) } -function registerPassiveCallHandler(getUserDataPath: () => string): void { +type RecordRuntimeEnvironmentAnswer = ( + environment: ReturnType, + method: string, + response: RuntimeRpcResponse +) => void + +function registerPassiveCallHandler( + getUserDataPath: () => string, + recordAnswer?: RecordRuntimeEnvironmentAnswer +): void { ipcMain.handle( 'runtimeEnvironments:call', async ( @@ -279,6 +291,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { throw error } observeWslOrcadHostResponse(environment.id, args.method, response) + recordAnswer?.(environment, args.method, response) return isRuntimeEnvironmentManuallyDisconnected(environment.id) ? manuallyDisconnectedResponse(environment) : response diff --git a/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts index c043a93606b..973ad597823 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts @@ -9,10 +9,12 @@ import { forgetRemoteLayoutSnapshot, type RemoteLayoutSnapshotStore } from '../../remote-layout-snapshot/remote-layout-snapshot-recorder' +import type { HostRepoCatalogSnapshotStore } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' -export type RemovedServerStore = RemoteLayoutSnapshotStore & { - removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void -} +export type RemovedServerStore = RemoteLayoutSnapshotStore & + Pick & { + removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void + } /** Retires a removed server's client-side state; resolves once its transport is invalidated. */ export function retireRemovedRuntimeEnvironment( @@ -29,6 +31,7 @@ export function retireRemovedRuntimeEnvironment( // Retained session partitions make listings advertise the removed server as a known host. store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId)) forgetRemoteLayoutSnapshot(store, environmentId) + store.setHostRepoCatalogSnapshot(environmentId, null) clearRuntimeEnvironmentManualDisconnect(environmentId) const retiring = Promise.resolve(invalidateTransport(environmentId)) // Explicit removal retires browser storage after client hosts release their live partitions. diff --git a/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts index ec2004d87ef..1ff2c48ef83 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts @@ -51,7 +51,8 @@ describe('runtime environment removal storage clearing', () => { store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown @@ -76,7 +77,8 @@ describe('runtime environment removal storage clearing', () => { store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() diff --git a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts index 71c8da501e2..f6ddf3a5eda 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts @@ -88,6 +88,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -95,6 +96,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -206,6 +208,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled() + // Only WSL hosts keep a saved project list; a paired server is listed live. + expect(store.setHostRepoCatalogSnapshot).not.toHaveBeenCalled() }) it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => { diff --git a/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts index d4998d8aaf0..71a4f60cb5d 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts @@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -92,6 +93,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts index 14d8914756e..19b4b564cf5 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts @@ -103,6 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -112,6 +113,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -221,6 +223,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { `runtime:${encodeURIComponent(added.environment.id)}` ) expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith(added.environment.id, null, 'now') + expect(store.setHostRepoCatalogSnapshot).toHaveBeenCalledWith(added.environment.id, null) expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) diff --git a/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts b/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts index 4d80add3f78..f6a2dfa6d95 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts @@ -87,7 +87,8 @@ describe('runtimeEnvironments:repairFromPairingCode', () => { getSettings: () => ({ activeRuntimeEnvironmentId: null }), updateSettings: vi.fn(), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registrar only reads the fixture's settings and workspace-session methods. registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts index b97817d6374..85f8ce7523e 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts @@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -100,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments/runtime-environments.ts b/src/main/ipc/runtime-environments/runtime-environments.ts index d3e3e5129c0..9939d6c0bff 100644 --- a/src/main/ipc/runtime-environments/runtime-environments.ts +++ b/src/main/ipc/runtime-environments/runtime-environments.ts @@ -10,6 +10,7 @@ import { getRuntimeEnvironmentRegistryRoot } from './runtime-environment-registr import { listEnvironments } from '../../../shared/runtime-environment-store' import type { Store } from '../../persistence/loading-store/store' import { recordRemoteLayoutFrame } from '../../remote-layout-snapshot/remote-layout-snapshot-recorder' +import { recordHostRepoCatalogAnswer } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' import { isRuntimeEnvironmentManuallyDisconnected, registerRuntimeEnvironmentConnectivityHandlers, @@ -153,7 +154,9 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getUserDataPath, invalidateTransport: invalidateRuntimeEnvironmentTransport }) - registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) + registerRuntimeEnvironmentPassiveHandlers(getUserDataPath, (environment, method, response) => + recordHostRepoCatalogAnswer(store, environment, method, response) + ) setManagedOrcadPairingRepairedListener(invalidateRuntimeEnvironmentTransport) setRefusedPairingHandlers({ recover: (environmentId) => recoverRefusedManagedPairing(getUserDataPath(), environmentId), diff --git a/src/main/ipc/wsl-host-gate-off.test.ts b/src/main/ipc/wsl-host-gate-off.test.ts index 94f027bd49f..7b1cb241c1a 100644 --- a/src/main/ipc/wsl-host-gate-off.test.ts +++ b/src/main/ipc/wsl-host-gate-off.test.ts @@ -126,7 +126,8 @@ describe('WSL hosts with the internal gate closed', () => { getWorkspaceSessionHostIds: () => [], updateSettings: vi.fn(), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } beforeEach(() => { diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index dff51b6d5fe..dbb19cc6228 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -109,6 +109,7 @@ export class StateSerializationSecretHandlingOperations { break case 'sshRemotePtyLeases': case 'remoteLayoutSnapshots': + case 'hostRepoCatalogSnapshots': case 'legacyPaneKeyAliasEntries': case 'migrationUnsupportedPtyEntries': stateToSave[domain] = this.runtime.state[domain] diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index a4541f3c710..bcac1130081 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -18,6 +18,7 @@ import { ProjectCollectionOperations } from './project-collection-operations' import { AutomationPersistence } from './automation-persistence' import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' import { RemoteLayoutSnapshotPersistence } from '../../remote-layout-snapshot/remote-layout-snapshot-persistence' +import { HostRepoCatalogSnapshotPersistence } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence' import { SparsePresetPersistence } from './sparse-preset-persistence' import { PtyBindingPersistenceOperations } from './pty-binding-persistence' import { LayoutCommandPersistence } from '../terminal-topology/layout-command-persistence' @@ -34,6 +35,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & RepoLifecycleOperations & MobileTabSelectionPersistence & RemoteLayoutSnapshotPersistence & + HostRepoCatalogSnapshotPersistence & SparsePresetPersistence & AutomationPersistence & MetadataLineageOperations & @@ -68,6 +70,7 @@ export type StoreDomains = { automations: AutomationPersistence mobileTabSelections: MobileTabSelectionPersistence remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence + hostRepoCatalogSnapshots: HostRepoCatalogSnapshotPersistence sparsePresets: SparsePresetPersistence ptyBindings: PtyBindingPersistenceOperations layoutCommands: LayoutCommandPersistence @@ -86,6 +89,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ RepoLifecycleOperations, MobileTabSelectionPersistence, RemoteLayoutSnapshotPersistence, + HostRepoCatalogSnapshotPersistence, SparsePresetPersistence, AutomationPersistence, MetadataLineageOperations, @@ -165,6 +169,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { automations, mobileTabSelections, remoteLayoutSnapshots, + hostRepoCatalogSnapshots: new HostRepoCatalogSnapshotPersistence(runtime, scheduling), sparsePresets, ptyBindings, layoutCommands: new LayoutCommandPersistence( diff --git a/src/main/persistence/profile-state/profile-state-section-labels.ts b/src/main/persistence/profile-state/profile-state-section-labels.ts index 3dfd392fa04..5e6b9d22267 100644 --- a/src/main/persistence/profile-state/profile-state-section-labels.ts +++ b/src/main/persistence/profile-state/profile-state-section-labels.ts @@ -7,6 +7,7 @@ function sectionLabel(domain: string, translate: ProfileStateSectionLabelTransla case 'repos': case 'projects': case 'projectHostSetups': + case 'hostRepoCatalogSnapshots': return translate('profileState.section.projects', 'Projects') case 'projectGroups': return translate('profileState.section.projectGroups', 'Project groups') diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index 4b845f31742..a5c1ec58e36 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -8,6 +8,8 @@ export const reposApi = { listWslMoved: () => ipcRenderer.invoke('repos:listWslMoved'), + listHostCatalogSnapshots: () => ipcRenderer.invoke('repos:listHostCatalogSnapshots'), + add: (args) => ipcRenderer.invoke('repos:add', args), create: (args) => ipcRenderer.invoke('repos:create', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 31e57b929d0..6d5b8657439 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -22,11 +22,14 @@ import type { } from '../../shared/project-types' import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shared/repo-types' import type { WslMovedProject } from '../../shared/wsl-move/wsl-moved-project' +import type { HostRepoCatalogSnapshot } from '../../shared/host-repo-catalog-snapshot' export type RepositoryApi = { list: () => Promise /** Local projects a WSL host now owns: read-only, listed only for their existing chats. */ listWslMoved: () => Promise + /** Saved WSL host project lists by environment id; desktop-only, never contacts a host. */ + listHostCatalogSnapshots: () => Promise> add: (args: { path: string kind?: 'git' | 'folder' diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index eae3feb1767..272ffd32921 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -108,6 +108,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Before the session read: a moved project's local rows must leave before the host's merge in. // Started before the repo list too; this startup load does not prune, so it need not wait. const wslMovedProjectsPromise = useAppStore.getState().fetchWslMovedProjects() + // Desktop-only read; must land before the first live host catalog so that answer can drop deleted projects. + const hostCatalogSeedPromise = timeRendererStartupStep('seed-host-repo-catalogs', () => + useAppStore.getState().seedReposFromHostCatalogSnapshots() + ) // Skip remote catalogs for first paint; saved runtimes may wait a full connect timeout. await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) @@ -124,18 +128,21 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) ) })() - const sessionReadPromise = Promise.all([runtimeHostsPromise, wslMovedProjectsPromise]).then( - ([startupRuntimeHosts]) => - // Restore cached runtime-host partitions without network waits; skip unreadable ones. - timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - // A moved workspace's retained rows share tab ids with its host's live ones. - wslMovedSessionReadApi(useAppStore.getState().wslMovedProjects), - useAppStore.getState().repos, - startupRuntimeHosts.hostIds, - startupRuntimeHosts.sshHostIdByRuntimeHostId - ) + const sessionReadPromise = Promise.all([ + runtimeHostsPromise, + wslMovedProjectsPromise, + hostCatalogSeedPromise + ]).then(([startupRuntimeHosts]) => + // Restore cached runtime-host partitions without network waits; skip unreadable ones. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + // A moved workspace's retained rows share tab ids with its host's live ones. + wslMovedSessionReadApi(useAppStore.getState().wslMovedProjects), + useAppStore.getState().repos, + startupRuntimeHosts.hostIds, + startupRuntimeHosts.sshHostIdByRuntimeHostId ) + ) ) const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index e5ab4a399cd..78be1315e27 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -33,6 +33,7 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { noteLiveRepoCatalogApplied } from './saved-host-catalog-rows' import { withWslMovedRepoIds, withWslMovedSetupScriptDismissals @@ -104,6 +105,7 @@ export function createAllHostRepoCatalogActions( setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds } }) + noteLiveRepoCatalogApplied(get, catalog.hostId) // Why: keep the safe-auto fork sync (as fetchRepos does) so cold-start, which now routes here, still updates safe-auto forks. scheduleSafeAutoForkSync(hostRepos) if (options?.onHostLoaded) { diff --git a/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts b/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts new file mode 100644 index 00000000000..acb22e955ec --- /dev/null +++ b/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../slices/store-test-helpers' +import type { Repo } from '../../../../shared/repo-types' +import type { HostRepoCatalogSnapshot } from '../../../../shared/host-repo-catalog-snapshot' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const HOST = 'runtime:env-wsl' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: '#22c55e', + addedAt: 1 +} +const helloWorld = { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#737373', + addedAt: 2, + kind: 'git' as const +} +const django = { ...helloWorld, id: 'django', path: '/home/me/django', displayName: 'django' } +const notes = { + ...helloWorld, + id: 'notes', + path: '/home/me/notes', + displayName: 'notes', + kind: 'folder' as const +} + +const saved: Record = { + 'env-wsl': { repos: [helloWorld, django, notes] } +} + +const listHostCatalogSnapshots = vi.fn() +const runtimeCall = vi.fn() +const runtimeGetStatus = vi.fn() +const wslApi = { listDistros: vi.fn(), startHost: vi.fn(), getHostStatus: vi.fn() } +let hostRepos: unknown[] | 'stopped' = 'stopped' + +function hostRows(repos: readonly Repo[]): { id: string; kind?: string }[] { + return repos + .filter((repo) => repo.executionHostId === HOST) + .map((repo) => ({ id: repo.id, ...(repo.kind ? { kind: repo.kind } : {}) })) +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + hostRepos = 'stopped' + listHostCatalogSnapshots.mockResolvedValue(structuredClone(saved)) + runtimeCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (hostRepos === 'stopped') { + return { + id: args.method, + ok: false, + error: { code: 'wsl_distro_stopped', message: 'The distro is stopped.' }, + _meta: { runtimeId: 'unknown' } + } + } + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + return { + id: args.method, + ok: true, + result: args.method === 'repo.list' ? { repos: structuredClone(hostRepos) } : {}, + _meta: { runtimeId: 'runtime-wsl' } + } + }) + vi.stubGlobal('window', { + api: { + repos: { list: vi.fn().mockResolvedValue([localRepo]), listHostCatalogSnapshots }, + projects: { + list: vi.fn().mockResolvedValue([]), + listHostSetups: vi.fn().mockResolvedValue([]) + }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeCall, + getStatus: runtimeGetStatus, + list: vi.fn().mockResolvedValue([{ id: 'env-wsl', name: 'orca-demo' }]) + }, + wsl: wslApi + }, + dispatchEvent: vi.fn() + }) +}) + +describe('saved WSL host project lists', () => { + it('lists every saved project of a stopped host after a restart, without contacting it', async () => { + const store = createTestStore() + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().seedReposFromHostCatalogSnapshots() + + expect(hostRows(store.getState().repos)).toEqual([ + { id: 'hello', kind: 'git' }, + { id: 'django', kind: 'git' }, + // Folder projects come back as folders. + { id: 'notes', kind: 'folder' } + ]) + expect(store.getState().repos.map((repo) => repo.id)).toContain('local-repo') + // Loading the saved list is a desktop read: no host RPC, no status probe, no wsl.exe. + expect(listHostCatalogSnapshots).toHaveBeenCalledTimes(1) + expect(runtimeCall).not.toHaveBeenCalled() + expect(runtimeGetStatus).not.toHaveBeenCalled() + for (const call of Object.values(wslApi)) { + expect(call).not.toHaveBeenCalled() + } + + // The host is still stopped: its failed catalog leaves the saved rows in place. + await store.getState().fetchReposForAllHosts() + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual([ + 'hello', + 'django', + 'notes' + ]) + }) + + it('lets the first live list replace the saved one, dropping projects deleted on the host', async () => { + const store = createTestStore() + await store.getState().seedReposFromHostCatalogSnapshots() + hostRepos = [{ ...helloWorld, displayName: 'Hello World (renamed)' }, notes] + + await store.getState().fetchReposForAllHosts() + + const rows = store.getState().repos.filter((repo) => repo.executionHostId === HOST) + expect(rows.map((repo) => [repo.id, repo.displayName])).toEqual([ + ['hello', 'Hello World (renamed)'], + ['notes', 'notes'] + ]) + }) + + it('does not bring back a host removed this session, or duplicate rows already loaded', async () => { + const store = createTestStore() + store.setState({ removedRuntimeEnvironmentIds: new Set(['env-wsl']) }) + await store.getState().seedReposFromHostCatalogSnapshots() + expect(hostRows(store.getState().repos)).toEqual([]) + + const live = createTestStore() + live.setState({ + repos: [{ ...helloWorld, displayName: 'live', executionHostId: HOST, connectionId: null }] + }) + await live.getState().seedReposFromHostCatalogSnapshots() + const liveRows = live.getState().repos.filter((repo) => repo.executionHostId === HOST) + expect(liveRows.map((repo) => [repo.id, repo.displayName])).toEqual([ + ['hello', 'live'], + ['django', 'django'], + ['notes', 'notes'] + ]) + }) + + it('skips a host whose live list already applied, so a late seed cannot restore a deleted project', async () => { + const store = createTestStore() + hostRepos = [helloWorld] + await store.getState().fetchReposForAllHosts() + await store.getState().seedReposFromHostCatalogSnapshots() + + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual(['hello']) + }) + + it('lets a live list requested before the seed landed still drop a deleted project', async () => { + const store = createTestStore() + hostRepos = [helloWorld] + let markRequested: () => void = () => undefined + const requested = new Promise((resolve) => { + markRequested = resolve + }) + let releaseAnswer: () => void = () => undefined + const answerReleased = new Promise((resolve) => { + releaseAnswer = resolve + }) + const answerLive = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRequested() + await answerReleased + } + return answerLive?.(args) + }) + + const fetching = store.getState().fetchReposForAllHosts() + await requested + await store.getState().seedReposFromHostCatalogSnapshots() + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual([ + 'hello', + 'django', + 'notes' + ]) + + releaseAnswer() + await fetching + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual(['hello']) + }) + + it('keeps startup going when the saved list cannot be read', async () => { + listHostCatalogSnapshots.mockRejectedValue(new Error('ipc down')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = createTestStore() + await store.getState().seedReposFromHostCatalogSnapshots() + expect(store.getState().repos).toEqual([]) + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/repos/host-repo-catalog-seed.ts b/src/renderer/src/store/repos/host-repo-catalog-seed.ts new file mode 100644 index 00000000000..6fee0e010e7 --- /dev/null +++ b/src/renderer/src/store/repos/host-repo-catalog-seed.ts @@ -0,0 +1,67 @@ +// Stopped WSL hosts still list their projects after a restart: seed rows from the desktop's saved answer. +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { HostRepoCatalogSnapshot } from '../../../../shared/host-repo-catalog-snapshot' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { adoptFromEndpoint } from '../adopt-from-endpoint' +import { getRepoRowIdentity } from '../slices/repo-host-identity' +import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { hasLiveRepoCatalogApplied, markSavedHostCatalogRow } from './saved-host-catalog-rows' + +/** Adds saved rows the store lacks; the host's next live list replaces them like any other row. */ +export function withHostCatalogSnapshotRepos( + repos: readonly Repo[], + snapshots: Readonly>, + removedEnvironmentIds: ReadonlySet, + hasLiveList: (hostId: string) => boolean = () => false +): readonly Repo[] { + const present = new Set(repos.map(getRepoRowIdentity)) + const added: Repo[] = [] + for (const [environmentId, snapshot] of Object.entries(snapshots)) { + const hostId = toRuntimeExecutionHostId(environmentId) + if (isRemovedRuntimeHostId(hostId, removedEnvironmentIds) || hasLiveList(hostId)) { + continue + } + const target = { kind: 'environment' as const, environmentId } + for (const row of snapshot.repos) { + const repo = adoptFromEndpoint(target, { kind: 'repo', row }) + const identity = getRepoRowIdentity(repo) + if (!present.has(identity)) { + present.add(identity) + markSavedHostCatalogRow(repo) + added.push(repo) + } + } + } + return added.length > 0 ? [...repos, ...added] : repos +} + +/** Run before any live host catalog request, so that answer can drop a project deleted on the host. */ +export async function seedReposFromHostCatalogSnapshots( + set: Parameters>[0], + get: Parameters>[1] +): Promise { + const list = window.api?.repos?.listHostCatalogSnapshots + if (typeof list !== 'function') { + return + } + let snapshots: Record + try { + snapshots = await list() + } catch (error) { + // Without the saved list a stopped host shows only its open workspaces, as before. + console.warn('[repos] Could not read saved host project lists:', error) + return + } + set((s) => { + const repos = withHostCatalogSnapshotRepos( + s.repos, + snapshots, + s.removedRuntimeEnvironmentIds, + (hostId) => hasLiveRepoCatalogApplied(get, hostId) + ) + return repos === s.repos ? s : { repos: applyManualRepoOrder(repos, s.manualRepoOrder) } + }) +} diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index 3ac7a6d8be4..75afe352fdc 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -7,6 +7,7 @@ import type { RepoSlice } from './repo-state' import { mergeFetchedProjectCompatibilityForHost } from '../../../../shared/project-compatibility-host-merge' import { projectCompatibilityFromRepos } from '../../../../shared/project-compatibility-merge' import { mergeByIdentity } from '../catalog-identity' +import { isSavedHostCatalogRow } from './saved-host-catalog-rows' export function mergeFetchedReposForHost( previous: readonly Repo[], @@ -24,7 +25,7 @@ export function mergeFetchedReposForHost( getRepoExecutionHostId(repo) !== hostId || fetchedIdentities.has(identity) || // Added after the request: an answer that predates it cannot drop it. - (requested !== null && !requested.has(identity)) + (requested !== null && !requested.has(identity) && !isSavedHostCatalogRow(repo)) ) }) return reconcileFetchedRepos( diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 6f2c62a1822..7eada941ded 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -172,6 +172,8 @@ export type RepoSlice = { /** Local projects a WSL host now owns: never in `repos`/`folderWorkspaces`, read-only, chats only. */ wslMovedProjects: readonly WslMovedProject[] fetchWslMovedProjects: () => Promise + /** Startup only: a stopped WSL host's saved project list, before its live catalog is asked for. */ + seedReposFromHostCatalogSnapshots: () => Promise folderWorkspacePathStatuses: Record /** Host-resolved floating workspace directory; null until the host has answered. */ floatingWorkspacePath: string | null diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 091e957fd20..00533fba824 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -21,6 +21,7 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { noteLiveRepoCatalogApplied } from './saved-host-catalog-rows' import { withWslMovedRepoIds, withWslMovedSetupScriptDismissals @@ -132,6 +133,7 @@ export function createRuntimeRepoCatalogActions( ) } }) + noteLiveRepoCatalogApplied(get, targetHostId) scheduleSafeAutoForkSync(finalizedHostRepos) return finalizedHostRepos } catch (err) { diff --git a/src/renderer/src/store/repos/saved-host-catalog-rows.ts b/src/renderer/src/store/repos/saved-host-catalog-rows.ts new file mode 100644 index 00000000000..66188c19d90 --- /dev/null +++ b/src/renderer/src/store/repos/saved-host-catalog-rows.ts @@ -0,0 +1,29 @@ +// Rows seeded from a WSL host's saved project list, and the hosts whose live list already applied. +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' + +const savedRows = new WeakSet() +const liveListHostsByStore = new WeakMap<() => AppState, Set>() + +export function markSavedHostCatalogRow(repo: Repo): void { + savedRows.add(repo) +} + +/** Any live answer may drop a saved row, even one requested before the seed landed. */ +export function isSavedHostCatalogRow(repo: Repo): boolean { + return savedRows.has(repo) +} + +export function noteLiveRepoCatalogApplied(get: () => AppState, hostId: string): void { + let hosts = liveListHostsByStore.get(get) + if (!hosts) { + hosts = new Set() + liveListHostsByStore.set(get, hosts) + } + hosts.add(hostId) +} + +/** A seed landing after the host's live list must not bring back what that list dropped. */ +export function hasLiveRepoCatalogApplied(get: () => AppState, hostId: string): boolean { + return liveListHostsByStore.get(get)?.has(hostId) === true +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index aacac43f136..b8b17208ac0 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -17,6 +17,7 @@ import { createProjectUpdateActions } from '../projects/project-update' import { createRepoUpdateActions } from '../repos/repo-update' import { createRepoOrderingActions } from '../repos/repo-ordering' import { fetchWslMovedProjects } from '../repos/wsl-moved-projects' +import { seedReposFromHostCatalogSnapshots } from '../repos/host-repo-catalog-seed' export const createRepoSlice: StateCreator = (set, get) => { const repoCatalogActions = createRepoCatalogActions(set, get) @@ -42,6 +43,7 @@ export const createRepoSlice: StateCreator = (set, folderWorkspaces: [], wslMovedProjects: [], fetchWslMovedProjects: () => fetchWslMovedProjects(set), + seedReposFromHostCatalogSnapshots: () => seedReposFromHostCatalogSnapshots(set, get), folderWorkspacePathStatuses: {}, floatingWorkspacePath: null, setFloatingWorkspacePath: (path) => diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index 91bd76b8396..2f0385d1cc8 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -19,6 +19,8 @@ export function createReposApi(): NonNullable['repos']> { }, // A WSL move is a desktop's own; a paired web client never holds the retained local rows. listWslMoved: async () => [], + // WSL hosts are a desktop's own; a paired web client lists its one runtime live. + listHostCatalogSnapshots: async () => ({}), add: async ({ path, kind, displayName }) => { invalidateRuntimeWorktreeCaches() const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( diff --git a/src/shared/host-repo-catalog-snapshot.ts b/src/shared/host-repo-catalog-snapshot.ts new file mode 100644 index 00000000000..6752f631b18 --- /dev/null +++ b/src/shared/host-repo-catalog-snapshot.ts @@ -0,0 +1,56 @@ +import type { Repo } from './repo-types' +import { DEFAULT_REPO_BADGE_COLOR } from './constants' + +/** The fields a sidebar project row needs; the host stamp is re-applied on read. */ +export type HostRepoCatalogSnapshotRepo = Pick< + Repo, + 'id' | 'path' | 'displayName' | 'badgeColor' | 'addedAt' | 'kind' | 'connectionId' +> + +/** A WSL host's last answered `repo.list`, kept on the desktop so a stopped distro still lists its projects. */ +export type HostRepoCatalogSnapshot = { + repos: HostRepoCatalogSnapshotRepo[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function snapshotRepo(value: unknown): HostRepoCatalogSnapshotRepo | null { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !value.id || + typeof value.path !== 'string' || + typeof value.displayName !== 'string' + ) { + return null + } + return { + id: value.id, + path: value.path, + displayName: value.displayName, + badgeColor: typeof value.badgeColor === 'string' ? value.badgeColor : DEFAULT_REPO_BADGE_COLOR, + addedAt: typeof value.addedAt === 'number' ? value.addedAt : 0, + ...(value.kind === 'git' || value.kind === 'folder' ? { kind: value.kind } : {}), + // Part of row identity: on a host's row it names that host's nested SSH target. + ...(typeof value.connectionId === 'string' ? { connectionId: value.connectionId } : {}) + } +} + +/** Null when the payload is not a whole catalog: a partial answer must not erase cached projects. */ +export function hostRepoCatalogSnapshotFromRepoList( + result: unknown +): HostRepoCatalogSnapshot | null { + if (!isRecord(result) || !Array.isArray(result.repos)) { + return null + } + const repos: HostRepoCatalogSnapshotRepo[] = [] + for (const entry of result.repos) { + const repo = snapshotRepo(entry) + if (repo) { + repos.push(repo) + } + } + return { repos } +} diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 98f07422cf3..4cbaa168572 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -18,6 +18,7 @@ import type { OnboardingState } from './onboarding-state-types' import type { PersistedUIState } from './persisted-ui-state-types' import type { ProjectGroup } from './project-group-types' import type { RemoteLayoutSnapshot } from './remote-layout-snapshot' +import type { HostRepoCatalogSnapshot } from './host-repo-catalog-snapshot' import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' import type { SparsePreset } from './worktree/create-types' @@ -95,6 +96,8 @@ export type PersistedState = { /** Paired server’s last display layout, keyed by environment and written only by the snapshot recorder; * separate from runtime: partitions that older builds overwrite. */ remoteLayoutSnapshots?: Record + /** WSL host's last project list, keyed by environment; display-only until the host answers again. */ + hostRepoCatalogSnapshots?: Record sshTargets: SshTarget[] /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ sshTargetGenerationCounter?: number diff --git a/src/shared/wsl-drive-unc-state-dispositions.ts b/src/shared/wsl-drive-unc-state-dispositions.ts index 9265dae5412..69d9b3f4936 100644 --- a/src/shared/wsl-drive-unc-state-dispositions.ts +++ b/src/shared/wsl-drive-unc-state-dispositions.ts @@ -37,6 +37,7 @@ export const WSL_DRIVE_UNC_STATE_DISPOSITIONS = { // Partitions of SSH and paired hosts; the local partition is `workspaceSession`. workspaceSessionsByHostId: 'keep', remoteLayoutSnapshots: 'keep', + hostRepoCatalogSnapshots: 'keep', sshTargets: 'keep', sshTargetGenerationCounter: 'keep', deletedSshConfigAliases: 'keep', diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 35c11545cb1..f31e9463e5a 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -77,6 +77,11 @@ export const PERSISTED_STATE_DISPOSITIONS = { item: 47, note: "Rebuilt from the host's first tab stream; the seeded workspaceSessionsByHostId gives first paint." }, + hostRepoCatalogSnapshots: { + disposition: 'rederive', + item: 47, + note: "Rebuilt from the host's next project list; it caches only that answer." + }, sshTargets: notApplicableDisposition(62), sshTargetGenerationCounter: notApplicableDisposition(62), deletedSshConfigAliases: notApplicableDisposition(62),