feat(wsl-mover): D3a-2 move status line on WSL host rows (dark) (#34676)

* 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
This commit is contained in:
Neil
2026-10-28 00:54:52 -07:00
committed by GitHub
parent 03dcd30367
commit bb10b80be5
24 changed files with 1053 additions and 41 deletions
+57
View File
@@ -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<Store, 'getRepos' | 'getFolderWorkspaces'>,
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
)
)
}
@@ -74,6 +74,7 @@ function orchestratorOverHost(connector: ReturnType<typeof makeConnector>) {
),
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<typeof makeConnector>) {
save: () => {},
schedule: () => () => {},
track: () => {},
publish: () => {}
publish: () => {},
runAsUserAct: (_distro, act) => act()
})
return { orchestrator, service, driverConnect }
}
@@ -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<void> {
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])
})
})
+9 -3
View File
@@ -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)
@@ -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)
})
})
@@ -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
)
}
@@ -25,6 +25,8 @@ export type WslMoveOrchestratorPorts = {
prepareHost: (distro: string, stillWanted: () => boolean) => Promise<WslMoveHostReadiness>
/** 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: <T>(distro: string, act: () => Promise<T>) => Promise<T>
}
@@ -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'),
@@ -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)
@@ -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<string, WslMoveOrchestratorRecord>([
[
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<string, WslMoveOrchestratorRecord>([
[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'])
})
})
@@ -39,6 +39,8 @@ export type Fake = WslMoveOrchestratorPorts & {
clock: { now: number }
timers: { at: number; run: () => void; cancelled: boolean }[]
saved: Map<string, WslMoveOrchestratorRecord>
/** Distros a step ran for as the user's act, in order. */
userActs: string[]
advance: (ms: number) => Promise<void>
}
@@ -46,6 +48,7 @@ export function fakePorts(overrides: Partial<WslMoveOrchestratorPorts> = {}): Fa
const clock = { now: 1_000 }
const timers: Fake['timers'] = []
const saved = new Map<string, WslMoveOrchestratorRecord>()
const userActs: string[] = []
const ports: Fake = {
clock,
timers,
@@ -57,6 +60,7 @@ export function fakePorts(overrides: Partial<WslMoveOrchestratorPorts> = {}): 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<WslMoveOrchestratorPorts> = {}): Fa
},
track: vi.fn(),
publish: vi.fn(),
userActs,
runAsUserAct: <T>(distro: string, act: () => Promise<T>): Promise<T> => {
userActs.push(distro)
return act()
},
advance: async (ms) => {
clock.now += ms
for (const timer of timers.splice(0)) {
@@ -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')
@@ -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<string, WslMoveOrchestratorRecord>
private readonly busy = new Set<string>()
@@ -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 {
@@ -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' },
@@ -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<AddRemoteHostMode | null>(null)
const [connectingId, setConnectingId] = useState<string | null>(null)
const [removing, setRemoving] = useState<PublicKnownRuntimeEnvironment | null>(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 (
<ServerListRow
key={environment.id}
@@ -185,6 +196,7 @@ export function WslHostsSection({
</span>
}
status={getWslHostRowStatus(status, unavailableLabel !== null)}
footer={moveLine ? <WslMoveOrchestratorStatusLine distro={distro} /> : undefined}
primaryAction={
// A newer Orca's host refuses Update; Force stays in the menu.
deferred && deferred.code !== ORCAD_HOST_NEWER_DEFERRAL_CODE ? (
@@ -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),
@@ -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<WslMoveBlocker['kind']>([
'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 (
<div className="flex gap-2 text-xs" data-testid="wsl-move-orchestrator-status">
{line.kind === 'deferred' ? (
<Info className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleAlert className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1 space-y-1">
<div className="text-muted-foreground">{line.text}</div>
{blockers.map((blocker) => (
<div key={blocker.kind} className="text-muted-foreground">
{translate('wslMove.orchestrator.blocker', '{{title}}. {{hint}}', {
title: wslMoveBlockerTitle(blocker.kind, blocker.ids.length),
hint: wslMoveBlockerHint(blocker.kind)
})}
</div>
))}
<div className="flex gap-1.5">
{paneKey ? (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => showBlockingTerminal(paneKey)}
>
{translate('wslMove.orchestrator.showTerminal', 'Show terminal')}
</Button>
) : null}
<Button type="button" size="xs" variant="outline" onClick={() => retry(record.distro)}>
{line.kind === 'declined'
? translate('wslMove.orchestrator.moveAgain', 'Move again')
: translate('wslMove.orchestrator.checkAgain', 'Check again')}
</Button>
</div>
</div>
</div>
)
}
@@ -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
}
@@ -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<typeof vi.fn>
let onOrchestratorStatus: ReturnType<typeof vi.fn>
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(
<TooltipProvider>
<WslHostsSection onChanged={() => {}} />
</TooltipProvider>
)
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(<WslMoveDialogHost />)
expect(listOrchestrator).not.toHaveBeenCalled()
expect(onOrchestratorStatus).not.toHaveBeenCalled()
})
it('follows it, and shows the line, once hosts are on', async () => {
gate.enabled = true
render(<WslMoveDialogHost />)
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(
<TooltipProvider>
<WslHostsSection onChanged={() => {}} />
</TooltipProvider>
)
expect(container.querySelector('[data-testid="wsl-move-orchestrator-status"]')).not.toBeNull()
})
})
@@ -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<string>() }))
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<typeof vi.fn>
let onOrchestratorStatus: ReturnType<typeof vi.fn>
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(<WslMoveOrchestratorStatusLine distro="ubuntu" />)
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."
])
})
})
@@ -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<string, WslMoveOrchestratorRecord> }
export const useWslMoveOrchestratorStatus = create<StatusState>(() => ({ 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<string, WslMoveOrchestratorRecord>,
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<string, WslMoveOrchestratorRecord>,
next: Record<string, WslMoveOrchestratorRecord>
): 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: {} })
}
}
+18
View File
@@ -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": {
@@ -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()
})
})
@@ -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.