mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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:
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+109
-10
@@ -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: {} })
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user