mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 18:07:45 +00:00
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.
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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<StoreRuntimeState, 'state'>
|
||||
|
||||
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<string, HostRepoCatalogSnapshot> {
|
||||
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'])
|
||||
}
|
||||
}
|
||||
@@ -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<string, HostRepoCatalogSnapshot>
|
||||
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<HostRepoCatalogSnapshotStore, 'setHostRepoCatalogSnapshot'>,
|
||||
environment: SavedEnvironment,
|
||||
method: string,
|
||||
response: RuntimeRpcResponse<unknown>
|
||||
): 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<HostRepoCatalogSnapshotStore, 'getHostRepoCatalogSnapshots'>,
|
||||
savedEnvironments: readonly SavedEnvironment[]
|
||||
): Record<string, HostRepoCatalogSnapshot> {
|
||||
const saved = store.getHostRepoCatalogSnapshots()
|
||||
const result: Record<string, HostRepoCatalogSnapshot> = {}
|
||||
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
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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({})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<unknown> {
|
||||
|
||||
@@ -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<typeof resolveEnvironment>,
|
||||
method: string,
|
||||
response: RuntimeRpcResponse<unknown>
|
||||
) => 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
|
||||
|
||||
@@ -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<HostRepoCatalogSnapshotStore, 'setHostRepoCatalogSnapshot'> & {
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let store: {
|
||||
getSettings: () => { activeRuntimeEnvironmentId: string | null }
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
setHostRepoCatalogSnapshot: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
|
||||
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
|
||||
setHostRepoCatalogSnapshot: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
|
||||
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
|
||||
setHostRepoCatalogSnapshot: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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([])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
removeWorkspaceSessionHost: ReturnType<typeof vi.fn>
|
||||
setRemoteLayoutSnapshot: ReturnType<typeof vi.fn>
|
||||
setHostRepoCatalogSnapshot: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -109,6 +109,7 @@ export class StateSerializationSecretHandlingOperations {
|
||||
break
|
||||
case 'sshRemotePtyLeases':
|
||||
case 'remoteLayoutSnapshots':
|
||||
case 'hostRepoCatalogSnapshots':
|
||||
case 'legacyPaneKeyAliasEntries':
|
||||
case 'migrationUnsupportedPtyEntries':
|
||||
stateToSave[domain] = this.runtime.state[domain]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Repo[]>
|
||||
/** Local projects a WSL host now owns: read-only, listed only for their existing chats. */
|
||||
listWslMoved: () => Promise<WslMovedProject[]>
|
||||
/** Saved WSL host project lists by environment id; desktop-only, never contacts a host. */
|
||||
listHostCatalogSnapshots: () => Promise<Record<string, HostRepoCatalogSnapshot>>
|
||||
add: (args: {
|
||||
path: string
|
||||
kind?: 'git' | 'folder'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, HostRepoCatalogSnapshot> = {
|
||||
'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<void>((resolve) => {
|
||||
markRequested = resolve
|
||||
})
|
||||
let releaseAnswer: () => void = () => undefined
|
||||
const answerReleased = new Promise<void>((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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, HostRepoCatalogSnapshot>>,
|
||||
removedEnvironmentIds: ReadonlySet<string>,
|
||||
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<StateCreator<AppState>>[0],
|
||||
get: Parameters<StateCreator<AppState>>[1]
|
||||
): Promise<void> {
|
||||
const list = window.api?.repos?.listHostCatalogSnapshots
|
||||
if (typeof list !== 'function') {
|
||||
return
|
||||
}
|
||||
let snapshots: Record<string, HostRepoCatalogSnapshot>
|
||||
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) }
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void>
|
||||
/** Startup only: a stopped WSL host's saved project list, before its live catalog is asked for. */
|
||||
seedReposFromHostCatalogSnapshots: () => Promise<void>
|
||||
folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry>
|
||||
/** Host-resolved floating workspace directory; null until the host has answered. */
|
||||
floatingWorkspacePath: string | null
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Repo>()
|
||||
const liveListHostsByStore = new WeakMap<() => AppState, Set<string>>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<AppState, [], [], RepoSlice> = (set, get) => {
|
||||
const repoCatalogActions = createRepoCatalogActions(set, get)
|
||||
@@ -42,6 +43,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
folderWorkspaces: [],
|
||||
wslMovedProjects: [],
|
||||
fetchWslMovedProjects: () => fetchWslMovedProjects(set),
|
||||
seedReposFromHostCatalogSnapshots: () => seedReposFromHostCatalogSnapshots(set, get),
|
||||
folderWorkspacePathStatuses: {},
|
||||
floatingWorkspacePath: null,
|
||||
setFloatingWorkspacePath: (path) =>
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createReposApi(): NonNullable<Partial<PreloadApi>['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 }>(
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 }
|
||||
}
|
||||
@@ -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:<env> partitions that older builds overwrite. */
|
||||
remoteLayoutSnapshots?: Record<string, RemoteLayoutSnapshot>
|
||||
/** WSL host's last project list, keyed by environment; display-only until the host answers again. */
|
||||
hostRepoCatalogSnapshots?: Record<string, HostRepoCatalogSnapshot>
|
||||
sshTargets: SshTarget[]
|
||||
/** Highest SSH target registration generation issued; prevents identity reuse after rollback. */
|
||||
sshTargetGenerationCounter?: number
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user