From bb10b80be59f4bc8fc5bb2136a2b4eda3e720356 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:54:52 -0700 Subject: [PATCH] feat(wsl-mover): D3a-2 move status line on WSL host rows (dark) (#34676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wsl-mover): D3a-2 per-distro move status line on WSL host rows, with decline causes and stale-record pruning (dark) * fix(wsl-mover): key D3a-2 status records without the node-only journal module in the renderer * fix(wsl-mover): D3a-2 pre-review — never prune user choices or on an unreadable host file; calm paused line; one-key blocker sentence; Show terminal feedback * refactor(wsl-mover): D3a-2 distro-in-use check in its own module * test(wsl-mover): the orchestrator host-connect test gives the new in-use port * fix(wsl-mover): D3a-2 Check again runs as the user's act on the distro (stopped-distro gates) * fix(wsl-mover): D3a-2 user steps use main's runAsWslUserAct gate; wiring test --- src/main/ipc/wsl-move-distro-in-use.ts | 57 ++++++ ...wsl-move-orchestrator-host-connect.test.ts | 4 +- .../ipc/wsl-move-orchestrator-wiring.test.ts | 122 ++++++++++++- src/main/ipc/wsl-move-orchestrator-wiring.ts | 12 +- .../wsl-move-orchestrator-driver.test.ts | 12 +- ...-move-orchestrator-external-transitions.ts | 8 +- .../wsl-move-orchestrator-ports.ts | 4 + .../wsl-move-orchestrator-store.ts | 3 +- .../wsl-move-orchestrator-transitions.test.ts | 10 +- ...wsl-move-orchestrator-user-choices.test.ts | 119 +++++++++++- .../wsl-move-orchestrator.test-support.ts | 9 + .../wsl-move-orchestrator.test.ts | 8 +- .../wsl-move-orchestrator.ts | 62 ++++++- .../wsl-move-orchestrator-telemetry.test.ts | 2 +- .../components/settings/WslHostsSection.tsx | 12 ++ .../src/components/wsl-move/WslMoveDialog.tsx | 3 + .../WslMoveOrchestratorStatusLine.tsx | 108 +++++++++++ .../wsl-move/wsl-move-orchestrator-copy.ts | 104 +++++++++++ .../wsl-move-orchestrator-flag-off.test.tsx | 119 ++++++++++++ .../wsl-move-orchestrator-status.test.tsx | 172 ++++++++++++++++++ .../wsl-move/wsl-move-orchestrator-status.ts | 89 +++++++++ src/renderer/src/i18n/locales/en.json | 18 ++ ...-preload-api-wsl-move-orchestrator.test.ts | 26 +++ .../wsl-move/wsl-move-orchestrator-types.ts | 11 +- 24 files changed, 1053 insertions(+), 41 deletions(-) create mode 100644 src/main/ipc/wsl-move-distro-in-use.ts create mode 100644 src/renderer/src/components/wsl-move/WslMoveOrchestratorStatusLine.tsx create mode 100644 src/renderer/src/components/wsl-move/wsl-move-orchestrator-copy.ts create mode 100644 src/renderer/src/components/wsl-move/wsl-move-orchestrator-flag-off.test.tsx create mode 100644 src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.test.tsx create mode 100644 src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.ts create mode 100644 src/renderer/src/web/web-preload-api-wsl-move-orchestrator.test.ts diff --git a/src/main/ipc/wsl-move-distro-in-use.ts b/src/main/ipc/wsl-move-distro-in-use.ts new file mode 100644 index 00000000000..e83d9978f55 --- /dev/null +++ b/src/main/ipc/wsl-move-distro-in-use.ts @@ -0,0 +1,57 @@ +/** Whether the move orchestrator still has a reason to keep a distro's record (D3a-2 pruning). */ +import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' +import { wslMoveJournalKey } from '../../shared/wsl-move/wsl-move-cutover' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { Store } from '../persistence' +import { + isLocalWslMoveRow, + wslMoveHostDistro +} from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy' + +/** + * A bridge project of the distro (local, on its UNC path, not moved) or its WSL host record. A + * host file that cannot be read counts as in use: a failed read never makes a distro look gone. + */ +export function isDistroInUse( + store: Pick, + userDataPath: string, + distro: string +): boolean { + const environmentId = defaultWslOrcadEnvironmentId(distro) + try { + const hosts = readWslHostDeployments(userDataPath) + if ( + hosts.unreadableEnvironmentIds.has(environmentId) || + hosts.deployments.some((record) => record.environmentId === environmentId) + ) { + return true + } + } catch { + // A host file that cannot be read never makes a distro look gone. + return true + } + const key = wslMoveJournalKey(distro) + const onDistro = (path: string): boolean => { + const unc = parseWslUncPath(path) + return unc !== null && wslMoveJournalKey(unc.distro) === key + } + return ( + store + .getRepos() + .some( + (repo) => + isLocalWslMoveRow(repo) && + onDistro(repo.path) && + wslMoveHostDistro({ repoId: repo.id }) === null + ) || + store + .getFolderWorkspaces() + .some( + (folder) => + isLocalWslMoveRow(folder) && + onDistro(folder.folderPath) && + wslMoveHostDistro({ folderWorkspaceId: folder.id }) === null + ) + ) +} diff --git a/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts b/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts index b023a276462..c40b47ed0f3 100644 --- a/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts +++ b/src/main/ipc/wsl-move-orchestrator-host-connect.test.ts @@ -74,6 +74,7 @@ function orchestratorOverHost(connector: ReturnType) { ), hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro), hasHost: () => service.recordForEnvironment(environmentId) !== null, + isDistroInUse: () => true, isHostReady, preview: async (distro) => { await driverConnect(distro) @@ -99,7 +100,8 @@ function orchestratorOverHost(connector: ReturnType) { save: () => {}, schedule: () => () => {}, track: () => {}, - publish: () => {} + publish: () => {}, + runAsUserAct: (_distro, act) => act() }) return { orchestrator, service, driverConnect } } diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.test.ts b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts index e981480bfd7..cd89d4e5f01 100644 --- a/src/main/ipc/wsl-move-orchestrator-wiring.test.ts +++ b/src/main/ipc/wsl-move-orchestrator-wiring.test.ts @@ -41,12 +41,26 @@ vi.mock('../../shared/runtime-environment-wsl-host-store', () => ({ findWslHostDeployment: (_userData: string, environmentId: string) => deployments.get(environmentId) ?? null })) +const hostFile = vi.hoisted(() => ({ unreadable: false })) +vi.mock('../../shared/wsl-host-deployment-store', () => ({ + readWslHostDeployments: () => { + if (hostFile.unreadable) { + throw new Error('orca-wsl-host-deployments.json is not valid') + } + return { deployments: [], unreadableEnvironmentIds: new Set() } + } +})) vi.mock('../wsl/wsl-distro-registry-id', () => ({ readWslDistroRegistrations: async () => null })) vi.mock('../wsl/wsl-orcad-deploy', () => ({ defaultWslOrcadEnvironmentId: (distro: string) => `wsl:${distro}` })) vi.mock('../wsl-background-distro-gate', () => ({ - readConfirmedRunningWslDistros: async () => host.running + readConfirmedRunningWslDistros: async () => host.running, + // The user-act gate notes a woken distro when the act settles. + noteWslDistroWoken: vi.fn(), + noteWslDistroMayHaveStopped: vi.fn(), + noteWslDistrosRunning: vi.fn(), + readWslDistroRunningState: vi.fn(async () => 'running') })) vi.mock('../wsl/wsl-host-service-instance', () => ({ getWslHostService: () => ({ @@ -134,10 +148,16 @@ function requestIdOf(payload: unknown): string { : '' } +/** The profile's projects: one bridge project on Ubuntu keeps its record from being pruned. */ +const profile = vi.hoisted(() => ({ + repos: [{ id: 'r1', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app', connectionId: null }] +})) + async function register(): Promise { const { registerWslMoveOrchestrator } = await import('./wsl-move-orchestrator-wiring') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The driver is mocked; the wiring only passes deps through. - registerWslMoveOrchestrator({} as never, () => ({ now: () => new Date(0) }) as never) + const store = { getRepos: () => profile.repos, getFolderWorkspaces: () => [] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The wiring reads only the projects of the store, and the driver is mocked. + registerWslMoveOrchestrator(store as never, () => ({ now: () => new Date(0) }) as never) } /** Answers every slept-pane request the window has been sent. */ @@ -171,6 +191,7 @@ beforeEach(() => { host.running = { status: 'confirmed', distros: ['Ubuntu'] } host.added = [] releases.listeners.length = 0 + hostFile.unreadable = false hostActions.listeners.length = 0 deployments.clear() store.reads = 0 @@ -306,7 +327,10 @@ describe('WSL move orchestrator wiring', () => { } const records = await handlers.get('wslMoveOrchestrator:list')?.({}) expect(records).toEqual([ - expect.objectContaining({ distro: 'Ubuntu', state: { kind: 'parked', reason: 'declined' } }) + expect.objectContaining({ + distro: 'Ubuntu', + state: { kind: 'parked', reason: 'declined', cause: 'released-here' } + }) ]) }) @@ -427,4 +451,94 @@ describe('WSL move orchestrator wiring', () => { expect(driver.previewWslMove).toHaveBeenCalledTimes(1) expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled() }) + + // Pre-review #3: a host file that cannot be read must never make a distro look gone. + it('prunes nothing when the WSL host file cannot be read', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + hostFile.unreadable = true + const savedRepos = profile.repos + profile.repos = [] + store.records = new Map([ + [ + 'local-wsl:ubuntu', + { + distro: 'Ubuntu', + state: { kind: 'idle' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + try { + await register() + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([ + expect.objectContaining({ distro: 'Ubuntu' }) + ]) + } finally { + profile.repos = savedRepos + } + }) + + it('prunes a distro with no host and no bridge projects once the file reads', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + const savedRepos = profile.repos + profile.repos = [] + store.records = new Map([ + [ + 'local-wsl:ubuntu', + { + distro: 'Ubuntu', + state: { kind: 'idle' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + try { + await register() + expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([]) + } finally { + profile.repos = savedRepos + } + }) + + // #34632's stopped-distro gates: Check again runs the driver as the user's act on its distro, + // so its reads of a just-connected distro pass; a wake's reads stay background work. + it('runs the driver for Check again as the user act, and for a wake as background work', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + const { isWslUserActFor } = await import('../wsl/wsl-user-act-scope') + const seen: boolean[] = [] + const original = driver.previewWslMove.getMockImplementation() + driver.previewWslMove.mockImplementation( + async (_deps: unknown, request: { distro: string }) => { + seen.push(isWslUserActFor(request.distro)) + return { + outcome: 'ready', + distro: request.distro, + repoIds: ['r1'], + folderWorkspaceIds: [], + changed: [], + heldByHost: [], + refused: [], + blockers: [] + } + } + ) + host.statuses = [ + { distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null } + ] + await register() + await answerWindow() + await handlers.get('wslMoveOrchestrator:userAction')?.( + { sender: { id: MAIN_WINDOW_ID } }, + { distro: 'Ubuntu', action: 'retry' } + ) + await answerWindow() + if (original) { + driver.previewWslMove.mockImplementation(original) + } + expect(seen).toEqual([false, true]) + }) }) diff --git a/src/main/ipc/wsl-move-orchestrator-wiring.ts b/src/main/ipc/wsl-move-orchestrator-wiring.ts index 350727b88b7..2ac698fa7c4 100644 --- a/src/main/ipc/wsl-move-orchestrator-wiring.ts +++ b/src/main/ipc/wsl-move-orchestrator-wiring.ts @@ -37,11 +37,13 @@ import { import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate' import { getWslHostService } from '../wsl/wsl-host-service-instance' import { onWslHostUserAction } from '../wsl/wsl-host-user-actions' import { getTrustedUIRendererWebContents, getTrustedUIRendererWindow } from './ui' import { onWslMoveReleasedByUser, releaseWslMoveChainHere } from './wsl-move-release-here' import { createWslMoveUndoPorts } from './wsl-move-undo-wiring' +import { isDistroInUse } from './wsl-move-distro-in-use' import { answerWslMoveSleptPanes, requestSleptPanes } from './wsl-move-orchestrator-slept-panes' import { wslHostRemovalPending } from './wsl-move-host-connect' @@ -121,6 +123,7 @@ function createOrchestrator( appVersion: app.getVersion(), autoMoveEnabled: () => process.env[WSL_MOVE_ORCHESTRATOR_AUTO_ENV] !== '0', prepareHost: (distro, stillWanted) => prepareHost(userDataPath, distro, stillWanted), + isDistroInUse: (distro) => isDistroInUse(store, userDataPath, distro), hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro), hasHost: (distro) => getWslHostService()?.recordForEnvironment(defaultWslOrcadEnvironmentId(distro)) != null, @@ -156,7 +159,8 @@ function createOrchestrator( }, track: (trigger, record, cause) => trackWslMoveOrchestrator(wslMoveOrchestratorEventProps(trigger, record, cause)), - publish + publish, + runAsUserAct: runAsWslUserAct }) } @@ -268,10 +272,12 @@ export function registerWslMoveOrchestrator( const created = createOrchestrator(store, driverDeps) orchestrator = created followHostReadiness(created) - onWslMoveReleasedByUser((distro) => created.releasedByUser(distro)) + onWslMoveReleasedByUser((distro) => created.releasedByUser(distro, 'released-here')) // A Remove is the user declining the host; only their own Add (or Retry) takes it back. onWslHostUserAction(({ kind, distro }) => - kind === 'removed' ? created.releasedByUser(distro) : created.hostAddedByUser(distro) + kind === 'removed' + ? created.releasedByUser(distro, 'removed') + : created.hostAddedByUser(distro) ) } catch (error) { console.warn('[wsl-move] Move orchestrator did not start:', error) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts index d18ba909352..7c3956fca7c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-driver.test.ts @@ -75,6 +75,7 @@ function orchestratorOverDriver(): { prepareHost: async () => 'ready', hostRemovalPending: () => false, hasHost: () => true, + isDistroInUse: () => true, isHostReady: () => true, preview: (distro) => previewWslMove(world.deps, { distro }), start: async (distro, stillWanted) => @@ -102,7 +103,8 @@ function orchestratorOverDriver(): { } }, track: () => {}, - publish: () => {} + publish: () => {}, + runAsUserAct: (_distro, act) => act() } const orchestrator = new WslMoveOrchestrator(ports) return { @@ -200,11 +202,15 @@ describe('the move orchestrator over the real driver', () => { const { orchestrator } = orchestratorOverDriver() await orchestrator.trigger('Ubuntu', 'connect') expect(bringWslMovedProjectBack(userDataPath, { repoId: 'repo-1' }).released).toBe(true) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') const stagedBefore = world.points.filter((point) => point === 'stage:before').length await orchestrator.trigger('Ubuntu', 'project-opened') await orchestrator.trigger('Ubuntu', 'connect') - expect(stateOf(orchestrator)).toEqual({ kind: 'parked', reason: 'declined' }) + expect(stateOf(orchestrator)).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'released-here' + }) expect(world.points.filter((point) => point === 'stage:before').length).toBe(stagedBefore) }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts index 1e7d213c6f8..e888781a562 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-external-transitions.ts @@ -3,6 +3,7 @@ * (Retry, Not now, a release, a host Add or Remove), R38 distro verdicts, and startup settling. */ import type { + WslMoveOrchestratorDeclineCause, WslMoveOrchestratorRecord, WslMoveOrchestratorUserAction } from '../../../shared/wsl-move/wsl-move-orchestrator-types' @@ -64,9 +65,10 @@ export function applyWslMoveUserAction( /** "Use on this computer again" is the user saying no: nothing moves the distro until Retry. */ export function applyWslMoveReleasedByUser( record: WslMoveOrchestratorRecord, - now: number + now: number, + cause: WslMoveOrchestratorDeclineCause ): WslMoveOrchestratorRecord { - return withWslMoveOrchestratorState(record, { kind: 'parked', reason: 'declined' }, now) + return withWslMoveOrchestratorState(record, { kind: 'parked', reason: 'declined', cause }, now) } /** The user added the host themselves: a decline (theirs, by Remove or release) is taken back. */ @@ -95,7 +97,7 @@ export function applyWslMoveHostRemovalSettled( ): WslMoveOrchestratorRecord { return withWslMoveOrchestratorState( record, - hostKept ? { kind: 'pending' } : { kind: 'parked', reason: 'declined' }, + hostKept ? { kind: 'pending' } : { kind: 'parked', reason: 'declined', cause: 'removed' }, now ) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts index 69abf1b9be1..533c8de6762 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-ports.ts @@ -25,6 +25,8 @@ export type WslMoveOrchestratorPorts = { prepareHost: (distro: string, stillWanted: () => boolean) => Promise /** E11: the user's Remove of the distro's host has not proven its server stopped. */ hostRemovalPending: (distro: string) => boolean + /** Whether the distro has a WSL host record or bridge projects; a distro with neither is pruned. */ + isDistroInUse: (distro: string) => boolean /** Whether the distro still has a WSL host record; never connects. */ hasHost: (distro: string) => boolean /** Whether the distro's host connection is ready now; never connects. Gates timer steps. */ @@ -57,4 +59,6 @@ export type WslMoveOrchestratorPorts = { cause?: WslMovePreview | WslMoveResult ) => void publish: (records: WslMoveOrchestratorRecord[]) => void + /** Runs a step the user asked for as their act on the distro (the WSL stopped-distro gates). */ + runAsUserAct: (distro: string, act: () => Promise) => Promise } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts index 7ce61242662..c63f10b98f1 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-store.ts @@ -54,7 +54,8 @@ const stateSchema = z.discriminatedUnion('kind', [ 'removal-pending', 'stuck' ]), - until: z.number().optional() + until: z.number().optional(), + cause: z.enum(['released-here', 'removed']).optional() }), z.object({ kind: z.literal('done'), diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts index 962fa8f6974..69161088171 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-transitions.test.ts @@ -261,8 +261,12 @@ describe('review fixes (#33802)', () => { // 2: "Use on this computer again" is a deliberate no, with no expiry. it('parks a distro the user brought back until Retry', () => { - const declined = applyWslMoveReleasedByUser(record({ kind: 'done', migrationId: 'm1' }), NOW) - expect(declined.state).toEqual({ kind: 'parked', reason: 'declined' }) + const declined = applyWslMoveReleasedByUser( + record({ kind: 'done', migrationId: 'm1' }), + NOW, + 'released-here' + ) + expect(declined.state).toEqual({ kind: 'parked', reason: 'declined', cause: 'released-here' }) expect(mayStartWslMove(declined, 'project-opened', NOW + 365 * 86_400_000, '9.0.0')).toBe(false) expect(mayStartWslMove(declined, 'user', NOW, '1.0.0')).toBe(true) }) @@ -357,7 +361,7 @@ describe('review fixes (#33802)', () => { }) it('takes back only a decline when the user adds the host', () => { - const declined = record({ kind: 'parked', reason: 'declined' }) + const declined = record({ kind: 'parked', reason: 'declined', cause: 'released-here' }) expect(applyWslMoveHostAddedByUser(declined, NOW).state).toEqual({ kind: 'pending' }) const snoozed = record({ kind: 'parked', reason: 'not-now', until: NOW }) expect(applyWslMoveHostAddedByUser(snoozed, NOW)).toBe(snoozed) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts index 7f3db0abc2e..0b3bd77e967 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator-user-choices.test.ts @@ -56,7 +56,11 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { await orchestrator.trigger('Ubuntu', 'connect') pending = false await orchestrator.trigger('Ubuntu', 'project-opened') - expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'removed' + }) expect(ports.prepareHost).not.toHaveBeenCalled() }) }) @@ -109,11 +113,15 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { const orchestrator = new WslMoveOrchestrator(ports) const step = orchestrator.trigger('Ubuntu', 'connect') await vi.waitFor(() => expect(ports.preview).toHaveBeenCalled()) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') release() await step expect(ports.start).not.toHaveBeenCalled() - expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'released-here' + }) }) it('keeps a release made while the host was being readied', async () => { @@ -129,7 +137,7 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { const orchestrator = new WslMoveOrchestrator(ports) const step = orchestrator.trigger('Ubuntu', 'connect') await vi.waitFor(() => expect(ports.prepareHost).toHaveBeenCalled()) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') readied() await step expect(ports.preview).not.toHaveBeenCalled() @@ -184,20 +192,24 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { it('is never readied again by any automatic trigger, only by the user', async () => { const ports = fakePorts() const orchestrator = new WslMoveOrchestrator(ports) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') ports.clock.now += 365 * 86_400_000 for (const trigger of ['connect', 'project-opened', 'startup', 'timer'] as const) { await orchestrator.trigger('Ubuntu', trigger) } expect(ports.prepareHost).not.toHaveBeenCalled() expect(ports.preview).not.toHaveBeenCalled() - expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'released-here' + }) }) it('resumes once the user adds the host again', async () => { const ports = fakePorts() const orchestrator = new WslMoveOrchestrator(ports) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') orchestrator.hostAddedByUser('Ubuntu') expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) await orchestrator.trigger('Ubuntu', 'connect') @@ -232,11 +244,15 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { const step = orchestrator.trigger('Ubuntu', 'connect') await vi.waitFor(() => expect(seen.wanted).toBeDefined()) expect(seen.wanted?.()).toBe(true) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') expect(seen.wanted?.()).toBe(false) answer() await step - expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'released-here' + }) }) // #4: the reply to Retry never waits for the move it starts. @@ -319,10 +335,93 @@ describe('WslMoveOrchestrator: user choices, races and failures', () => { const step = orchestrator.trigger('Ubuntu', 'project-opened') await vi.waitFor(() => expect(seen.wanted).toBeDefined()) expect(seen.wanted?.()).toBe(true) - orchestrator.releasedByUser('Ubuntu') + orchestrator.releasedByUser('Ubuntu', 'released-here') expect(seen.wanted?.()).toBe(false) readied() await step expect(ports.preview).not.toHaveBeenCalled() }) + + // D3a-2: the status line needs to know which choice declined the distro. + describe('decline causes and pruning', () => { + it('records whether the user released the projects here or removed the host', () => { + const orchestrator = new WslMoveOrchestrator(fakePorts()) + orchestrator.releasedByUser('Ubuntu', 'removed') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'removed' + }) + orchestrator.hostAddedByUser('Ubuntu') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'pending' }) + }) + + it('drops records of distros with neither a host nor bridge projects, at start and on Remove', () => { + let inUse = false + const loaded = new Map([ + [ + wslMoveJournalKey('Gone'), + { + distro: 'Gone', + state: { kind: 'idle' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + ] + ]) + const ports = fakePorts({ load: () => loaded, isDistroInUse: vi.fn(() => inUse) }) + const orchestrator = new WslMoveOrchestrator(ports) + expect(orchestrator.list()).toEqual([]) + inUse = true + orchestrator.releasedByUser('Ubuntu', 'released-here') + expect(orchestrator.list()).toHaveLength(1) + inUse = false + // A decline is the user's choice: kept even with nothing left on the distro. + orchestrator.releasedByUser('Ubuntu', 'removed') + expect(orchestrator.list()).toHaveLength(1) + }) + }) + + // Pre-review #1: a pruned decline would let a later wake re-add a host the user removed. + it('never prunes a user choice, even for a distro with nothing left', () => { + const parked = (reason: 'declined' | 'not-now'): WslMoveOrchestratorRecord => ({ + distro: reason === 'declined' ? 'Removed' : 'Snoozed', + state: + reason === 'declined' + ? { kind: 'parked', reason, cause: 'removed' } + : { kind: 'parked', reason, until: 2_000 }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + }) + const loaded = new Map([ + [wslMoveJournalKey('Removed'), parked('declined')], + [wslMoveJournalKey('Snoozed'), parked('not-now')] + ]) + const orchestrator = new WslMoveOrchestrator( + fakePorts({ load: () => loaded, isDistroInUse: vi.fn(() => false) }) + ) + expect( + orchestrator + .list() + .map((record) => record.distro) + .sort() + ).toEqual(['Removed', 'Snoozed']) + orchestrator.releasedByUser('Ubuntu', 'removed') + expect(stateOf(orchestrator, 'Ubuntu')).toMatchObject({ reason: 'declined', cause: 'removed' }) + }) + + // #34632's stopped-distro gates: the user's Check again is their act on the distro, so the + // driver's reads for a just-connected distro are not refused as background work. + it('runs a Retry step as the user act on its distro, and nothing else', async () => { + const ports = fakePorts() + const orchestrator = new WslMoveOrchestrator(ports) + await orchestrator.trigger('Ubuntu', 'connect') + await orchestrator.trigger('Debian', 'project-opened') + expect(ports.userActs).toEqual([]) + await orchestrator.userAction('Ubuntu', 'retry') + await orchestrator.whenIdle() + expect(ports.userActs).toEqual(['Ubuntu']) + }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts index e53f1d5a225..bd46b3ba64e 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test-support.ts @@ -39,6 +39,8 @@ export type Fake = WslMoveOrchestratorPorts & { clock: { now: number } timers: { at: number; run: () => void; cancelled: boolean }[] saved: Map + /** Distros a step ran for as the user's act, in order. */ + userActs: string[] advance: (ms: number) => Promise } @@ -46,6 +48,7 @@ export function fakePorts(overrides: Partial = {}): Fa const clock = { now: 1_000 } const timers: Fake['timers'] = [] const saved = new Map() + const userActs: string[] = [] const ports: Fake = { clock, timers, @@ -57,6 +60,7 @@ export function fakePorts(overrides: Partial = {}): Fa isHostReady: vi.fn(() => true), hostRemovalPending: vi.fn(() => false), hasHost: vi.fn(() => true), + isDistroInUse: vi.fn(() => true), preview: vi.fn(async (distro: string) => ready(distro)), start: vi.fn(async () => moved), finishedMove: () => null, @@ -83,6 +87,11 @@ export function fakePorts(overrides: Partial = {}): Fa }, track: vi.fn(), publish: vi.fn(), + userActs, + runAsUserAct: (distro: string, act: () => Promise): Promise => { + userActs.push(distro) + return act() + }, advance: async (ms) => { clock.now += ms for (const timer of timers.splice(0)) { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts index e221aa8f047..bf77af21e42 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.test.ts @@ -347,8 +347,12 @@ describe('WslMoveOrchestrator', () => { const ports = fakePorts() const orchestrator = new WslMoveOrchestrator(ports) await orchestrator.trigger('Ubuntu', 'connect') - orchestrator.releasedByUser('Ubuntu') - expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ kind: 'parked', reason: 'declined' }) + orchestrator.releasedByUser('Ubuntu', 'released-here') + expect(stateOf(orchestrator, 'Ubuntu')).toEqual({ + kind: 'parked', + reason: 'declined', + cause: 'released-here' + }) ports.clock.now += 30 * 86_400_000 await orchestrator.trigger('Ubuntu', 'project-opened') await orchestrator.trigger('Ubuntu', 'connect') diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts index a5dc39b6468..4d3db1605e6 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-orchestrator.ts @@ -11,6 +11,7 @@ import type { WslMoveOrchestratorPorts } from './wsl-move-orchestrator-ports' import { readWslMoveFinishedMove, runWslMoveOrchestratorStep } from './wsl-move-orchestrator-step' import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' import type { + WslMoveOrchestratorDeclineCause, WslMoveOrchestratorRecord, WslMoveOrchestratorTrigger, WslMoveOrchestratorUserAction @@ -30,6 +31,15 @@ import { /** A project opened again within this window does not read the running distros again. */ export const WSL_MOVE_ORCHESTRATOR_PROJECT_OPENED_THROTTLE_MS = 60_000 +/** + * A user's own decision (a decline, or Not now) is never pruned: dropping it would let a later + * wake undo it, e.g. re-add a host the user removed. + */ +function recordsUserChoice(record: WslMoveOrchestratorRecord): boolean { + const { state } = record + return state.kind === 'parked' && (state.reason === 'declined' || state.reason === 'not-now') +} + export class WslMoveOrchestrator { private readonly records: Map private readonly busy = new Set() @@ -59,6 +69,7 @@ export class WslMoveOrchestrator { for (const [key, record] of this.records) { this.scheduleFor(key, record) } + this.prune() } list(): WslMoveOrchestratorRecord[] { @@ -99,7 +110,11 @@ export class WslMoveOrchestrator { this.busy.add(key) const running = (async () => { try { - await this.step(key, distro, trigger) + // The user's Retry is their act on this distro: the driver's reads may start it, as a + // click would. Every other trigger stays under the background gates (A1). + await (trigger === 'user' + ? this.ports.runAsUserAct(distro, () => this.step(key, distro, trigger)) + : this.step(key, distro, trigger)) } catch (error) { // Triggers are fire-and-forget (status events, timers): a throw must not go unhandled. console.warn('[wsl-move] Orchestrator step failed:', error) @@ -146,13 +161,38 @@ export class WslMoveOrchestrator { * "Use on this computer again" ran for the distro, or the user removed its host: park it until * the user adds the host or presses Retry. */ - releasedByUser(distro: string): void { + releasedByUser(distro: string, cause: WslMoveOrchestratorDeclineCause): void { const key = wslMoveJournalKey(distro) this.commit( key, 'user', - applyWslMoveReleasedByUser(this.recordFor(key, distro), this.ports.now()) + applyWslMoveReleasedByUser(this.recordFor(key, distro), this.ports.now(), cause) ) + this.prune() + } + + /** + * Drops records of distros with neither a WSL host nor bridge projects left: nothing can wake + * them, and the status line would describe a distro the user no longer has. + */ + prune(): void { + let changed = false + for (const [key, record] of this.records) { + if ( + !this.busy.has(key) && + record.state.kind !== 'moving' && + !recordsUserChoice(record) && + !this.ports.isDistroInUse(record.distro) + ) { + this.records.delete(key) + this.timers.get(key)?.() + this.timers.delete(key) + changed = true + } + } + if (changed) { + this.saveAndPublish() + } } async userAction( @@ -253,19 +293,23 @@ export class WslMoveOrchestrator { return previous ?? next } this.records.set(key, next) + // Every new attempt is reported too, so a backoff series shows each failure. + if (previous?.state.kind !== next.state.kind || previous.attempts !== next.attempts) { + this.ports.track(trigger, next, cause) + } + this.saveAndPublish() + this.scheduleFor(key, next) + return next + } + + private saveAndPublish(): void { try { this.ports.save(this.records) } catch (error) { // The sidecar is the orchestrator's memory, not the move's record: keep going in memory. console.warn('[wsl-move] Orchestrator state not saved:', error) } - // Every new attempt is reported too, so a backoff series shows each failure. - if (previous?.state.kind !== next.state.kind || previous.attempts !== next.attempts) { - this.ports.track(trigger, next, cause) - } this.ports.publish(this.list()) - this.scheduleFor(key, next) - return next } private scheduleFor(key: string, record: WslMoveOrchestratorRecord): void { diff --git a/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts b/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts index 8f21ad598a5..96170c18b21 100644 --- a/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts +++ b/src/main/telemetry/wsl-move-orchestrator-telemetry.test.ts @@ -52,7 +52,7 @@ describe('wslMoveOrchestratorEventProps', () => { { kind: 'waiting-for-host', migrationId: 'm' }, { kind: 'backoff', nextAttemptAt: 0, reason: 'x' }, { kind: 'parked', reason: 'not-now', until: 1 }, - { kind: 'parked', reason: 'declined' }, + { kind: 'parked', reason: 'declined', cause: 'released-here' }, { kind: 'parked', reason: 'removal-pending' }, { kind: 'parked', reason: 'stuck' }, { kind: 'done', migrationId: 'm' }, diff --git a/src/renderer/src/components/settings/WslHostsSection.tsx b/src/renderer/src/components/settings/WslHostsSection.tsx index b5ef6ea63fb..85df349731b 100644 --- a/src/renderer/src/components/settings/WslHostsSection.tsx +++ b/src/renderer/src/components/settings/WslHostsSection.tsx @@ -33,6 +33,12 @@ import { Button } from '../ui/button' import { DropdownMenuItem } from '../ui/dropdown-menu' import { managedServerOutcomeLabel } from './managed-server-copy' import { ServerListRow, ServerRowChip, type ServerRowStatus } from './runtime-server-list-row' +import { WslMoveOrchestratorStatusLine } from '../wsl-move/WslMoveOrchestratorStatusLine' +import { wslMoveOrchestratorLine } from '../wsl-move/wsl-move-orchestrator-copy' +import { + useWslMoveOrchestratorStatus, + wslMoveOrchestratorRecordFor +} from '../wsl-move/wsl-move-orchestrator-status' import { openWslMoveDialogFor } from '../wsl-move/wsl-move-entry' import { WslMoveUnfinishedNotice } from '../wsl-move/WslMoveUnfinishedNotice' @@ -86,6 +92,7 @@ export function WslHostsSection({ ? null : getWslHostsTurnedOffLabel() const statuses = useAppStore((s) => s.wslHostStatuses) + const moveRecords = useWslMoveOrchestratorStatus((s) => s.records) const [dialogMode, setDialogMode] = useState(null) const [connectingId, setConnectingId] = useState(null) const [removing, setRemoving] = useState(null) @@ -160,6 +167,10 @@ export function WslHostsSection({ const deferred = unavailableLabel ? null : status?.deferredUpdate const connectDisabled = unavailableLabel !== null || connectingId !== null || status?.state === 'connecting' + const distro = environment.orcadDeployment.distro + const moveLine = unavailableLabel + ? null + : wslMoveOrchestratorLine(wslMoveOrchestratorRecordFor(moveRecords, distro)) return ( } status={getWslHostRowStatus(status, unavailableLabel !== null)} + footer={moveLine ? : undefined} primaryAction={ // A newer Orca's host refuses Update; Force stays in the menu. deferred && deferred.code !== ORCAD_HOST_NEWER_DEFERRAL_CODE ? ( diff --git a/src/renderer/src/components/wsl-move/WslMoveDialog.tsx b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx index 89d2522eb6a..779805e1d9d 100644 --- a/src/renderer/src/components/wsl-move/WslMoveDialog.tsx +++ b/src/renderer/src/components/wsl-move/WslMoveDialog.tsx @@ -25,6 +25,7 @@ import { useWslMoveDialogStore } from './wsl-move-dialog-store' import { collectWslMoveSleptPaneBindings } from './wsl-move-slept-pane-bindings' +import { followWslMoveOrchestratorStatus } from './wsl-move-orchestrator-status' import { findWorktreeById } from '@/store/github/worktree-refresh' import { getWslDistroFromPath } from '@/lib/local-preflight-context' import { @@ -79,6 +80,8 @@ export function WslMoveDialogHost(): React.JSX.Element | null { const open = useWslMoveDialogStore( (s) => s.dialog.stage !== 'closed' && !('hidden' in s.dialog && s.dialog.hidden) ) + // D3a-2: the per-distro move state for the host rows' status lines, only while hosts are on. + useEffect(() => (enabled ? followWslMoveOrchestratorStatus() : undefined), [enabled]) // A run this window did not start (it reloaded mid-move) still gets its progress and reload. useEffect( () => (enabled ? window.api.wslMove.onProgress(adoptWslMoveProgress) : undefined), diff --git a/src/renderer/src/components/wsl-move/WslMoveOrchestratorStatusLine.tsx b/src/renderer/src/components/wsl-move/WslMoveOrchestratorStatusLine.tsx new file mode 100644 index 00000000000..87be32546e8 --- /dev/null +++ b/src/renderer/src/components/wsl-move/WslMoveOrchestratorStatusLine.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { toast } from 'sonner' +import { CircleAlert, Info } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { revealTerminalPane } from '@/lib/reveal-terminal-pane' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { WslMoveBlocker } from '../../../../shared/wsl-move/wsl-move-driver-types' +import { wslMoveBlockerHint, wslMoveBlockerTitle } from './wsl-move-copy' +import { wslMoveOrchestratorLine } from './wsl-move-orchestrator-copy' +import { + useWslMoveOrchestratorStatus, + wslMoveOrchestratorRecordFor +} from './wsl-move-orchestrator-status' + +/** Blockers that name terminal panes, which the user can go to and stop. */ +const PANE_BLOCKERS = new Set([ + 'agent-working', + 'process-running', + 'agent-not-resumable' +]) + +/** Opens the busy terminal so the user can stop it there; Orca never ends it for them. */ +export function showBlockingTerminal(paneKey: string): void { + const pane = parsePaneKey(paneKey) + const { tabsByWorktree } = useAppStore.getState() + const worktreeId = pane + ? Object.entries(tabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === pane.tabId) + )?.[0] + : undefined + const shown = + pane !== null && + worktreeId !== undefined && + revealTerminalPane({ + worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + executionHostId: null + }) + if (!shown) { + toast( + translate( + 'wslMove.orchestrator.terminalGone', + "That terminal isn't open in this window any more. Check again to refresh." + ) + ) + } +} + +function retry(distro: string): void { + void window.api.wslMove.orchestratorUserAction({ distro, action: 'retry' }) +} + +/** One line per distro on its host row: why its move waits, and what the user can do. */ +export function WslMoveOrchestratorStatusLine({ + distro +}: { + distro: string +}): React.JSX.Element | null { + const record = useWslMoveOrchestratorStatus((s) => + wslMoveOrchestratorRecordFor(s.records, distro) + ) + const line = wslMoveOrchestratorLine(record) + if (!line || !record) { + return null + } + const blockers = record.state.kind === 'deferred' ? record.state.blockers : [] + const paneKey = blockers.find((blocker) => PANE_BLOCKERS.has(blocker.kind))?.ids[0] + return ( +
+ {line.kind === 'deferred' ? ( + + ) : ( + + )} +
+
{line.text}
+ {blockers.map((blocker) => ( +
+ {translate('wslMove.orchestrator.blocker', '{{title}}. {{hint}}', { + title: wslMoveBlockerTitle(blocker.kind, blocker.ids.length), + hint: wslMoveBlockerHint(blocker.kind) + })} +
+ ))} +
+ {paneKey ? ( + + ) : null} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-orchestrator-copy.ts b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-copy.ts new file mode 100644 index 00000000000..193e279fd6d --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-copy.ts @@ -0,0 +1,104 @@ +/** The words of a distro's move status line (D3a-2); null where the line shows nothing. */ +import { translate } from '@/i18n/i18n' +import type { WslMoveOrchestratorRecord } from '../../../../shared/wsl-move/wsl-move-orchestrator-types' + +export type WslMoveOrchestratorLine = + | { kind: 'deferred'; text: string } + | { kind: 'stuck'; text: string } + | { kind: 'declined'; text: string } + | { kind: 'paused'; text: string } + +export function wslMoveOrchestratorLine( + record: WslMoveOrchestratorRecord | undefined +): WslMoveOrchestratorLine | null { + const state = record?.state + if (!record || !state) { + return null + } + if (state.kind === 'deferred') { + return { + kind: 'deferred', + text: translate( + 'wslMove.orchestrator.deferred', + 'Finishing your move to the {{distro}} host: some terminals are still busy.', + { distro: record.distro } + ) + } + } + if (state.kind !== 'parked') { + return null + } + switch (state.reason) { + case 'stuck': + // Never "failed": the projects moved; only an earlier move's settling did not finish. + return { + kind: 'stuck', + text: translate( + 'wslMove.orchestrator.stuck', + "An earlier move to the {{distro}} host hasn't finished settling.", + { distro: record.distro } + ) + } + case 'declined': + // A removed host has no row to show this on; re-adding it clears the decline. + return state.cause === 'removed' + ? null + : { + kind: 'declined', + text: translate( + 'wslMove.orchestrator.declined', + 'Moving is paused because you brought these projects back to this computer.' + ) + } + case 'export-incomplete': + case 'manifest-too-large': + case 'host-update-required': + case 'attempts-exhausted': + return { + kind: 'paused', + text: translate( + 'wslMove.orchestrator.paused', + 'Moving projects to the {{distro}} host is paused: {{reason}}.', + { distro: record.distro, reason: pausedReason(state.reason) } + ) + } + // The host row already explains a pending Remove; Retry would only park again. + case 'removal-pending': + case 'not-now': + return null + } +} + +/** Short and calm: these wait for the user, and nothing was lost. */ +function pausedReason( + reason: 'export-incomplete' | 'manifest-too-large' | 'host-update-required' | 'attempts-exhausted' +): string { + switch (reason) { + case 'export-incomplete': + return translate( + 'wslMove.orchestrator.pausedReason.exportIncomplete', + "some saved state can't be carried over yet" + ) + case 'manifest-too-large': + return translate( + 'wslMove.orchestrator.pausedReason.manifestTooLarge', + 'there is more saved state than one move can carry' + ) + case 'host-update-required': + return translate( + 'wslMove.orchestrator.pausedReason.hostUpdate', + 'the host needs an update first' + ) + case 'attempts-exhausted': + return translate( + 'wslMove.orchestrator.pausedReason.attemptsExhausted', + "it didn't complete after several tries" + ) + } +} + +/** The toast for a live change to parked; only the states the status line explains get one. */ +export function wslMoveOrchestratorParkedToast(record: WslMoveOrchestratorRecord): string | null { + const line = wslMoveOrchestratorLine(record) + return line && line.kind !== 'deferred' ? line.text : null +} diff --git a/src/renderer/src/components/wsl-move/wsl-move-orchestrator-flag-off.test.tsx b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-flag-off.test.tsx new file mode 100644 index 00000000000..9f387e8b285 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-flag-off.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +/** + * D3a-2 with WSL hosts switched off: the host rows show no move status line, and the window + * never asks main for the orchestrator's records or listens for them. + */ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gate = vi.hoisted(() => ({ enabled: false })) +vi.mock('../sidebar/use-wsl-hosts-enabled', () => ({ + useWslHostsEnabled: () => gate.enabled, + useWslHostServerFilesMissing: () => false, + wslHostsAvailable: () => true, + openWslHostsExperimentalSetting: vi.fn() +})) +const state = vi.hoisted(() => ({ + runtimeEnvironments: [ + { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 1, remotePort: 2 } + } + ], + wslHostStatuses: {}, + activeWorktreeId: null, + tabsByWorktree: {} +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) +})) +vi.mock('../sidebar/AddRemoteHostDialog', () => ({ AddRemoteHostDialog: () => null })) +vi.mock('../sidebar/HostRemoveDialog', () => ({ HostRemoveDialog: () => null })) + +const { WslHostsSection } = await import('../settings/WslHostsSection') +const { WslMoveDialogHost } = await import('./WslMoveDialog') +const { useWslMoveOrchestratorStatus } = await import('./wsl-move-orchestrator-status') +const { TooltipProvider } = await import('@/components/ui/tooltip') + +let listOrchestrator: ReturnType +let onOrchestratorStatus: ReturnType + +beforeEach(() => { + listOrchestrator = vi.fn(async () => []) + onOrchestratorStatus = vi.fn(() => () => {}) + vi.stubGlobal('api', { + wslMove: { + listOrchestrator, + onOrchestratorStatus, + onProgress: () => () => {}, + onOrchestratorSleptPanesRequest: () => () => {}, + orchestratorProjectOpened: vi.fn(async () => {}), + listUnfinished: vi.fn(async () => []) + } + }) + // A record a window could still hold: the row must not show it while hosts are off. + useWslMoveOrchestratorStatus.setState({ + records: { + ['ubuntu']: { + distro: 'Ubuntu', + state: { kind: 'parked', reason: 'stuck' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + } + }) +}) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('the move status line with WSL hosts off', () => { + it('shows no status line on the host row', () => { + gate.enabled = false + const { container } = render( + + {}} /> + + ) + expect(container.textContent).toContain('Ubuntu (WSL)') + expect(container.querySelector('[data-testid="wsl-move-orchestrator-status"]')).toBeNull() + }) + + it('never reads or follows the orchestrator', () => { + gate.enabled = false + render() + expect(listOrchestrator).not.toHaveBeenCalled() + expect(onOrchestratorStatus).not.toHaveBeenCalled() + }) + + it('follows it, and shows the line, once hosts are on', async () => { + gate.enabled = true + render() + expect(onOrchestratorStatus).toHaveBeenCalledTimes(1) + expect(listOrchestrator).toHaveBeenCalledTimes(1) + cleanup() + useWslMoveOrchestratorStatus.setState({ + records: { + ['ubuntu']: { + distro: 'Ubuntu', + state: { kind: 'parked', reason: 'stuck' }, + attempts: 0, + appVersion: '1.0.0', + updatedAt: 0 + } + } + }) + const { container } = render( + + {}} /> + + ) + expect(container.querySelector('[data-testid="wsl-move-orchestrator-status"]')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.test.tsx b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.test.tsx new file mode 100644 index 00000000000..ef768480577 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WslMoveOrchestratorRecord } from '../../../../shared/wsl-move/wsl-move-orchestrator-types' + +const toasts = vi.hoisted(() => ({ calls: new Array() })) +vi.mock('sonner', () => { + const toast = (message: string) => toasts.calls.push(message) + toast.success = (message: string) => toasts.calls.push(message) + return { toast } +}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => ({ tabsByWorktree: {} }) }) +})) +vi.mock('@/lib/reveal-terminal-pane', () => ({ revealTerminalPane: vi.fn() })) + +const { followWslMoveOrchestratorStatus, useWslMoveOrchestratorStatus } = + await import('./wsl-move-orchestrator-status') +const { WslMoveOrchestratorStatusLine, showBlockingTerminal } = + await import('./WslMoveOrchestratorStatusLine') +const { wslMoveOrchestratorLine } = await import('./wsl-move-orchestrator-copy') + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function record(state: WslMoveOrchestratorRecord['state']): WslMoveOrchestratorRecord { + return { distro: 'Ubuntu', state, attempts: 0, appVersion: '1.0.0', updatedAt: 0 } +} + +type Listener = (records: WslMoveOrchestratorRecord[]) => void +let listeners: Listener[] +let listOrchestrator: ReturnType +let onOrchestratorStatus: ReturnType + +function installApi(initial: WslMoveOrchestratorRecord[]): void { + listeners = [] + listOrchestrator = vi.fn(async () => initial) + onOrchestratorStatus = vi.fn((listener: Listener) => { + listeners.push(listener) + return () => {} + }) + vi.stubGlobal('api', { + wslMove: { listOrchestrator, onOrchestratorStatus, orchestratorUserAction: vi.fn() } + }) +} + +function renderLine(): string { + const { container } = render() + const html = container.innerHTML + cleanup() + return html +} + +beforeEach(() => { + toasts.calls.length = 0 + useWslMoveOrchestratorStatus.setState({ records: {} }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('following the move orchestrator (D3a-2)', () => { + it('announces nothing for a parked state already there on load', async () => { + installApi([record({ kind: 'parked', reason: 'stuck' })]) + const stop = followWslMoveOrchestratorStatus() + await vi.waitFor(() => expect(listOrchestrator).toHaveBeenCalled()) + await Promise.resolve() + expect(renderLine()).toContain("hasn't finished settling") + expect(toasts.calls).toEqual([]) + stop() + }) + + it('toasts a live change to parked and to done, once each', async () => { + installApi([record({ kind: 'pending' })]) + const stop = followWslMoveOrchestratorStatus() + await vi.waitFor(() => expect(listOrchestrator).toHaveBeenCalled()) + await Promise.resolve() + for (const listener of listeners) { + listener([record({ kind: 'parked', reason: 'stuck' })]) + listener([record({ kind: 'parked', reason: 'stuck' })]) + listener([record({ kind: 'done', migrationId: 'm1' })]) + } + expect(toasts.calls).toEqual([ + "An earlier move to the Ubuntu host hasn't finished settling.", + 'Projects moved to the Ubuntu host' + ]) + stop() + }) + + it('clears what it read when it stops, so a switched-off window shows nothing', async () => { + installApi([record({ kind: 'parked', reason: 'stuck' })]) + const stop = followWslMoveOrchestratorStatus() + await vi.waitFor(() => expect(listOrchestrator).toHaveBeenCalled()) + await Promise.resolve() + stop() + expect(renderLine()).toBe('') + }) +}) + +describe('the status line, by state', () => { + const show = (state: WslMoveOrchestratorRecord['state']): string => { + useWslMoveOrchestratorStatus.setState({ + records: { ['ubuntu']: record(state) } + }) + return renderLine() + } + + it('says what is still busy for a deferred move, with Show terminal and Check again', () => { + const html = show({ + kind: 'deferred', + blockers: [{ kind: 'agent-working', ids: [PANE], actions: ['wait', 'end'] }], + rechecks: 0 + }) + expect(html).toContain('Finishing your move to the Ubuntu host') + expect(html).toContain('Show terminal') + expect(html).toContain('Check again') + }) + + it('never calls a stuck move failed', () => { + const html = show({ kind: 'parked', reason: 'stuck' }) + expect(html).toContain("An earlier move to the Ubuntu host hasn't finished settling.") + expect(html.toLowerCase()).not.toContain('fail') + expect(html).toContain('Check again') + }) + + it('offers only Move again for a distro the user brought back, never Not now', () => { + const html = show({ kind: 'parked', reason: 'declined', cause: 'released-here' }) + expect(html).toContain('brought these projects back to this computer') + expect(html).toContain('Move again') + expect(html).not.toContain('Not now') + }) + + it('shows nothing for a removed host or a pending Remove', () => { + expect(show({ kind: 'parked', reason: 'declined', cause: 'removed' })).toBe('') + expect(show({ kind: 'parked', reason: 'removal-pending' })).toBe('') + expect(wslMoveOrchestratorLine(undefined)).toBeNull() + }) + + // Pre-review #2: what waits for the user gets a calm line and Check again, never "failed". + it('shows a neutral paused line with Check again for each reason that waits for the user', () => { + for (const [reason, words] of [ + ['export-incomplete', "can't be carried over yet"], + ['manifest-too-large', 'more saved state than one move can carry'], + ['host-update-required', 'the host needs an update first'], + ['attempts-exhausted', "didn't complete after several tries"] + ] as const) { + const html = show({ kind: 'parked', reason }) + expect(html).toContain('Moving projects to the Ubuntu host is paused: ') + expect(html).toContain(words) + expect(html).toContain('Check again') + expect(html.toLowerCase()).not.toContain('fail') + } + }) + + it('says each blocker in one sentence', () => { + const html = show({ + kind: 'deferred', + blockers: [{ kind: 'automation-running', ids: ['run-1'], actions: ['wait'] }], + rechecks: 0 + }) + expect(html).toContain('Wait for it to finish, then check again.') + expect(html).not.toContain('Show terminal') + }) +}) + +describe('Show terminal', () => { + // Pre-review #5: a pane that is gone gets a word back, not silence. + it('says so when the terminal is no longer open', () => { + showBlockingTerminal(PANE) + expect(toasts.calls).toEqual([ + "That terminal isn't open in this window any more. Check again to refresh." + ]) + }) +}) diff --git a/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.ts b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.ts new file mode 100644 index 00000000000..5bcd7f26684 --- /dev/null +++ b/src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.ts @@ -0,0 +1,89 @@ +/** + * The general migration's per-distro state (D3a-2), as main publishes it. Read only while WSL + * hosts are on in this window: with them off nothing subscribes and no IPC is made. A toast + * marks a live change to done or parked, never a state that was already there on load. + */ +import { create } from 'zustand' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { WslMoveOrchestratorRecord } from '../../../../shared/wsl-move/wsl-move-orchestrator-types' +import { wslMoveOrchestratorParkedToast } from './wsl-move-orchestrator-copy' + +type StatusState = { records: Record } + +export const useWslMoveOrchestratorStatus = create(() => ({ records: {} })) + +/** + * WSL compares distro names case-insensitively. Not the journal key from wsl-move-cutover: that + * module pulls node-only code into the renderer bundle. + */ +export function wslMoveOrchestratorDistroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export function wslMoveOrchestratorRecordFor( + records: Record, + distro: string +): WslMoveOrchestratorRecord | undefined { + return records[wslMoveOrchestratorDistroKey(distro)] +} + +function byDistro(list: readonly WslMoveOrchestratorRecord[]) { + return Object.fromEntries( + list.map((record) => [wslMoveOrchestratorDistroKey(record.distro), record]) + ) +} + +/** Toasts for what became done or parked since the last update; none for a first load. */ +function announce( + previous: Record, + next: Record +): void { + for (const [key, record] of Object.entries(next)) { + const before = previous[key]?.state.kind + if (record.state.kind === before) { + continue + } + if (record.state.kind === 'done') { + toast.success( + translate('wslMove.orchestrator.doneToast', 'Projects moved to the {{distro}} host', { + distro: record.distro + }) + ) + } else if (record.state.kind === 'parked') { + const message = wslMoveOrchestratorParkedToast(record) + if (message) { + toast(message) + } + } + } +} + +/** Starts following main's records; returns the stop. Call only while WSL hosts are on. */ +export function followWslMoveOrchestratorStatus(): () => void { + let live = true + let loaded = false + const stop = window.api.wslMove.onOrchestratorStatus((list) => { + if (!live) { + return + } + const next = byDistro(list) + if (loaded) { + announce(useWslMoveOrchestratorStatus.getState().records, next) + } + loaded = true + useWslMoveOrchestratorStatus.setState({ records: next }) + }) + void window.api.wslMove.listOrchestrator().then((list) => { + // A live update that arrived first is newer than this snapshot. + if (live && !loaded) { + loaded = true + useWslMoveOrchestratorStatus.setState({ records: byDistro(list) }) + } + }) + return () => { + live = false + stop() + useWslMoveOrchestratorStatus.setState({ records: {} }) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f425655b2aa..114e4c03b98 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20296,6 +20296,24 @@ }, "reloadToast": { "title": "Reload to finish the move" + }, + "orchestrator": { + "showTerminal": "Show terminal", + "moveAgain": "Move again", + "checkAgain": "Check again", + "deferred": "Finishing your move to the {{distro}} host: some terminals are still busy.", + "stuck": "An earlier move to the {{distro}} host hasn't finished settling.", + "declined": "Moving is paused because you brought these projects back to this computer.", + "doneToast": "Projects moved to the {{distro}} host", + "terminalGone": "That terminal isn't open in this window any more. Check again to refresh.", + "blocker": "{{title}}. {{hint}}", + "paused": "Moving projects to the {{distro}} host is paused: {{reason}}.", + "pausedReason": { + "exportIncomplete": "some saved state can't be carried over yet", + "manifestTooLarge": "there is more saved state than one move can carry", + "hostUpdate": "the host needs an update first", + "attemptsExhausted": "it didn't complete after several tries" + } } }, "onboarding": { diff --git a/src/renderer/src/web/web-preload-api-wsl-move-orchestrator.test.ts b/src/renderer/src/web/web-preload-api-wsl-move-orchestrator.test.ts new file mode 100644 index 00000000000..c2e67190092 --- /dev/null +++ b/src/renderer/src/web/web-preload-api-wsl-move-orchestrator.test.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installApi } from './web-preload-api-test-harness' + +// D3a-2: a web client never owns a WSL move; its orchestrator API lists nothing and follows nothing. +describe('web preload: WSL move orchestrator', () => { + beforeEach(() => { + vi.resetModules() + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lists no records, never calls back, and leaves the status store empty', async () => { + const { api } = await installApi() + expect(await api.wslMove.listOrchestrator()).toEqual([]) + const listener = vi.fn() + api.wslMove.onOrchestratorStatus(listener)() + expect(listener).not.toHaveBeenCalled() + const { followWslMoveOrchestratorStatus, useWslMoveOrchestratorStatus } = + await import('../components/wsl-move/wsl-move-orchestrator-status') + const stop = followWslMoveOrchestratorStatus() + await Promise.resolve() + expect(useWslMoveOrchestratorStatus.getState().records).toEqual({}) + stop() + }) +}) diff --git a/src/shared/wsl-move/wsl-move-orchestrator-types.ts b/src/shared/wsl-move/wsl-move-orchestrator-types.ts index f9137ba85f9..fc5e2d5c1dd 100644 --- a/src/shared/wsl-move/wsl-move-orchestrator-types.ts +++ b/src/shared/wsl-move/wsl-move-orchestrator-types.ts @@ -18,6 +18,9 @@ export type WslMoveOrchestratorTrigger = /** A backoff retry or deferred re-check; runs only while the host stays ready. */ | 'timer' +/** "Use on this computer again" (`released-here`) or a Remove of the distro's host (`removed`). */ +export type WslMoveOrchestratorDeclineCause = 'released-here' | 'removed' + /** Why the orchestrator stopped trying on its own; each needs the user. */ export type WslMoveOrchestratorParkReason = | 'export-incomplete' @@ -46,7 +49,13 @@ export type WslMoveOrchestratorState = | { kind: 'waiting-for-host'; migrationId: string } /** `waits`: uncounted checks so far (an earlier move still settling), for their cap. */ | { kind: 'backoff'; nextAttemptAt: number; reason: string; waits?: number } - | { kind: 'parked'; reason: WslMoveOrchestratorParkReason; until?: number } + | { + kind: 'parked' + reason: WslMoveOrchestratorParkReason + until?: number + /** For `declined`: which choice of the user's declined it. */ + cause?: WslMoveOrchestratorDeclineCause + } /** * `nextCheckAt` / `waits` while a newer preview waits on this move to finish settling: * new projects may be queued behind it, so the wait is re-checked and capped like a backoff.