mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
WIP(wsl-mover): 7f-2 registry complete, coverage proof, startup re-apply (D1b-7f-2)
This commit is contained in:
+4
-1
@@ -41,6 +41,8 @@ import type { ReconnectFactsFor } from '../persistence/window-save-view'
|
||||
type RegisterAppHandlersOptions = {
|
||||
quitResumeRecordsFor?: QuitResumeRecordsFor
|
||||
reconnectFactsFor?: ReconnectFactsFor
|
||||
/** Runs after the unload checkpoint stages the window's state, before it flushes. */
|
||||
afterUnloadStage?: () => void
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
/** Only for app:relaunch, whose app.exit(0) is committed and skips will-quit. */
|
||||
onBeforeRelaunchExit?: () => void | Promise<void>
|
||||
@@ -121,7 +123,8 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
|
||||
registerRendererShutdownCheckpointHandler(
|
||||
store,
|
||||
options.quitResumeRecordsFor,
|
||||
options.reconnectFactsFor
|
||||
options.reconnectFactsFor,
|
||||
options.afterUnloadStage
|
||||
)
|
||||
registerMacKeyboardLayoutChangeNotifications()
|
||||
ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed)
|
||||
|
||||
@@ -572,6 +572,7 @@ describe('registerCoreHandlers', () => {
|
||||
})
|
||||
expect(registerAppHandlersMock).toHaveBeenCalledWith(store, {
|
||||
quitResumeRecordsFor: expect.any(Function),
|
||||
afterUnloadStage: expect.any(Function),
|
||||
onBeforeRelaunch,
|
||||
onBeforeRelaunchExit: expect.any(Function)
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../runtime-environments/runtime-environments'
|
||||
import { registerWslHostHandlers } from '../wsl-host-handlers'
|
||||
import { registerWslMoveHandlers } from '../wsl-move-handlers'
|
||||
import { reapplyWslMoveFocusRewrites } from '../../persistence/moving-wsl-projects/wsl-move-focus-reapply'
|
||||
import { registerEphemeralVmHandlers } from '../ephemeral-vm'
|
||||
import { registerAiVaultHandlers } from '../ai-vault'
|
||||
import { registerAiVaultSearchHandlers } from '../ai-vault-search'
|
||||
@@ -182,6 +183,10 @@ export function registerCoreHandlers(
|
||||
runtime.shutdownReconnectFactsFor(session, partition)
|
||||
}
|
||||
: {}),
|
||||
// The checkpoint writes the window's focus, which is stale for a project moved under it.
|
||||
afterUnloadStage: () => {
|
||||
reapplyWslMoveFocusRewrites(app.getPath('userData'), store)
|
||||
},
|
||||
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch,
|
||||
// Why here, not onBeforeRelaunch: restart and profile switches quit through will-quit, which closes it.
|
||||
onBeforeRelaunchExit: () => closeNotificationDismissalsBeforeRelaunch(runtime)
|
||||
|
||||
@@ -102,7 +102,9 @@ function stagedSessionForMain(
|
||||
export function registerRendererShutdownCheckpointHandler(
|
||||
store: Store,
|
||||
quitResumeRecordsFor: QuitResumeRecordsFor = () => [],
|
||||
reconnectFactsFor?: ReconnectFactsFor
|
||||
reconnectFactsFor?: ReconnectFactsFor,
|
||||
/** Corrects what the window staged before the flush; must not throw. */
|
||||
afterStage: () => void = () => {}
|
||||
): void {
|
||||
// Why: beforeunload cannot await, so the sync reply only reports staging.
|
||||
// Durability is joined out-of-band by paths that are about to navigate.
|
||||
@@ -122,6 +124,7 @@ export function registerRendererShutdownCheckpointHandler(
|
||||
}
|
||||
}
|
||||
store.updateUI(args.ui)
|
||||
afterStage()
|
||||
} catch (error) {
|
||||
console.error('[app] Failed to stage renderer state before unload:', error)
|
||||
ok = false
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The Automations page host filter after a WSL move. It selects one host or all of them, so it
|
||||
* cannot also cover the WSL host; a filter on this desktop would hide every automation the move
|
||||
* carried. Only then does it widen to All hosts, once: the ledgered run never repeats, so a
|
||||
* later choice of this desktop stays.
|
||||
*/
|
||||
import type { PersistedAutomationHostFilter } from '../../../shared/automation-host-filter'
|
||||
import { hostStableKey } from '../../../shared/automation-owner-key'
|
||||
|
||||
const DESKTOP_SELF_HOST_KEY = hostStableKey({
|
||||
authority: { kind: 'desktop' },
|
||||
selector: { kind: 'self' }
|
||||
})
|
||||
|
||||
/** The filter to persist, or null when it stays. */
|
||||
export function wslMoveAutomationHostFilterUpdate(
|
||||
filter: PersistedAutomationHostFilter | undefined,
|
||||
movedAutomationCount: number
|
||||
): PersistedAutomationHostFilter | null {
|
||||
return movedAutomationCount > 0 &&
|
||||
filter?.kind === 'host' &&
|
||||
filter.hostKey === DESKTOP_SELF_HOST_KEY
|
||||
? { kind: 'all' }
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Every desktop rewrite store a committed WSL move runs, registered in run order. Startup calls it
|
||||
* once, before the first rewrite pass; the coverage test checks it against the disposition tables.
|
||||
*/
|
||||
import type { WslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-copy'
|
||||
import { registerWslMoveBrowserCookieStore } from './wsl-move-browser-cookie-store'
|
||||
import { registerWslMoveRendererRewriteStores } from './wsl-move-desktop-rewrite-stores'
|
||||
import {
|
||||
registerWslMoveHostAutomationsStore,
|
||||
type WslMoveHostAutomations
|
||||
} from './wsl-move-host-automations-rewrite'
|
||||
import {
|
||||
registerWslMoveMainRewriteStores,
|
||||
type WslMoveMainRewriteProfile
|
||||
} from './wsl-move-main-rewrite-stores'
|
||||
import { registerWslMoveOrchestrationHistoryStore } from './wsl-move-orchestration-history-rewrite'
|
||||
|
||||
export type WslMoveDesktopRewriteRegistryDependencies = {
|
||||
profile: WslMoveMainRewriteProfile
|
||||
orchestrationDatabasePath: string
|
||||
hostAutomations: WslMoveHostAutomations
|
||||
browserCookies: WslMoveBrowserCookieCopyDependencies
|
||||
}
|
||||
|
||||
export function registerWslMoveDesktopRewriteRegistry(
|
||||
dependencies: WslMoveDesktopRewriteRegistryDependencies
|
||||
): void {
|
||||
registerWslMoveMainRewriteStores(dependencies.profile)
|
||||
registerWslMoveOrchestrationHistoryStore(dependencies.orchestrationDatabasePath)
|
||||
registerWslMoveRendererRewriteStores()
|
||||
registerWslMoveHostAutomationsStore(dependencies.hostAutomations)
|
||||
// Last among main-side stores: it throws until browser sessions exist, pausing any store after it.
|
||||
registerWslMoveBrowserCookieStore(dependencies.browserCookies)
|
||||
}
|
||||
@@ -38,9 +38,8 @@ export type WslMoveDesktopRewriteStore = WslMoveMainRewriteStore | WslMoveRender
|
||||
const STORE_ID_PATTERN = /^[A-Za-z0-9:._-]{1,128}$/
|
||||
const MAX_STORES = 256
|
||||
|
||||
// 7f (the last store PR) flips this; until then no move may leave `destination-committed`.
|
||||
// 7f-1 registers `browser-cookies` (wsl-move-browser-cookie-store.ts); the flip waits for 7c-7e.
|
||||
export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = false
|
||||
// Every desktop-rewrite disposition has its store (wsl-move-rewrite-store-coverage.test.ts proves it).
|
||||
export const WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE: boolean = true
|
||||
|
||||
let registryComplete = WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* A window hydrated before a move's rewrite can still write the old focus (the session's active
|
||||
* workspace, the UI's last-active worktree) for a moved project, and its unload checkpoint does.
|
||||
* So at startup and after every unload checkpoint the corrective part of `ui-routing` and
|
||||
* `session-globals` runs again for each committed move. Nothing else is re-applied: the additive
|
||||
* rewrites (host copies, visible hosts, the automation filter) would undo a later user choice.
|
||||
* No ledger entry is written; the ledgered pass alone owns the journal.
|
||||
*/
|
||||
import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host'
|
||||
import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import { listWslMoveCutovers } from './wsl-move-cutover-journal'
|
||||
import { createUncToLinuxPathRewriter } from './wsl-move-path-rewriter'
|
||||
import { wslMoveRewriteScope } from './wsl-move-rewrite-scope'
|
||||
import { wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite'
|
||||
import { wslMoveLastActiveWorktreeUpdate } from './wsl-move-ui-routing-rewrite'
|
||||
|
||||
export type WslMoveFocusProfile = Pick<
|
||||
Store,
|
||||
'getUI' | 'updateUI' | 'getWorkspaceSession' | 'patchWorkspaceSession'
|
||||
>
|
||||
|
||||
const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed')
|
||||
|
||||
/** Never throws; a no-op with the gate closed or no committed move. Returns how many writes it made. */
|
||||
export function reapplyWslMoveFocusRewrites(
|
||||
userDataPath: string,
|
||||
profile: WslMoveFocusProfile,
|
||||
warn: (message: string) => void = (message) => console.warn(message)
|
||||
): number {
|
||||
if (!wslHostsGateOpen()) {
|
||||
return 0
|
||||
}
|
||||
let writes = 0
|
||||
try {
|
||||
for (const cutover of listWslMoveCutovers(userDataPath)) {
|
||||
if (WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) < COMMITTED_PHASE_INDEX) {
|
||||
continue
|
||||
}
|
||||
const scope = wslMoveRewriteScope({
|
||||
cutover,
|
||||
rewritePath: createUncToLinuxPathRewriter(cutover.manifest.source.distro),
|
||||
destinationHostId: toRuntimeExecutionHostId(cutover.destinationEnvironmentId),
|
||||
recordSeededHostWorkspaceIds: () => {
|
||||
throw new Error('wsl_move_focus_reapply_seeds_nothing')
|
||||
}
|
||||
})
|
||||
const lastActive = wslMoveLastActiveWorktreeUpdate(profile.getUI(), scope)
|
||||
if (lastActive) {
|
||||
profile.updateUI(lastActive)
|
||||
writes += 1
|
||||
}
|
||||
const focus = wslMoveSessionGlobalsPatch(
|
||||
profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID),
|
||||
scope
|
||||
)
|
||||
if (focus) {
|
||||
profile.patchWorkspaceSession(focus, LOCAL_EXECUTION_HOST_ID)
|
||||
writes += 1
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warn(`[wsl-move] Could not re-apply a moved project's focus: ${describe(error)}`)
|
||||
}
|
||||
return writes
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite'
|
||||
import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores'
|
||||
import { wslMoveRewriteScope } from './wsl-move-rewrite-scope'
|
||||
import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite'
|
||||
@@ -27,7 +28,15 @@ export function registerWslMoveMainRewriteStores(profile: WslMoveMainRewriteProf
|
||||
id: 'ui-routing',
|
||||
side: 'main',
|
||||
run: (context) => {
|
||||
const updates = rewriteWslMoveUiRouting(profile.getUI(), wslMoveRewriteScope(context))
|
||||
const ui = profile.getUI()
|
||||
const automationHostFilter = wslMoveAutomationHostFilterUpdate(
|
||||
ui.automationHostFilter,
|
||||
context.cutover.manifest.payload.dormantState?.automations?.length ?? 0
|
||||
)
|
||||
const updates = {
|
||||
...rewriteWslMoveUiRouting(ui, wslMoveRewriteScope(context)),
|
||||
...(automationHostFilter ? { automationHostFilter } : {})
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
profile.updateUI(updates)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_MOVE_BROWSER_COOKIES_STORE_ID } from '../../../shared/wsl-move/wsl-move-browser-cookie-copy'
|
||||
import {
|
||||
PERSISTED_STATE_DISPOSITIONS,
|
||||
WORKSPACE_SESSION_DISPOSITIONS,
|
||||
type WslMoveFieldDisposition
|
||||
} from '../../../shared/wsl-move/wsl-move-dispositions'
|
||||
import { OUT_OF_STORE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-out-of-store-dispositions'
|
||||
import type { WslMoveRendererRewriteStoreId } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types'
|
||||
import {
|
||||
LOCAL_STORAGE_MODULE_DISPOSITIONS,
|
||||
SQLITE_TABLE_DISPOSITIONS
|
||||
} from '../../../shared/wsl-move/wsl-move-storage-dispositions'
|
||||
import { UI_STATE_DISPOSITIONS } from '../../../shared/wsl-move/wsl-move-ui-dispositions'
|
||||
import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry'
|
||||
import {
|
||||
isWslMoveDesktopRewriteRegistryComplete,
|
||||
listWslMoveDesktopRewriteStores,
|
||||
resetWslMoveDesktopRewriteStoresForTests,
|
||||
WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE
|
||||
} from './wsl-move-desktop-rewrite-stores'
|
||||
import { WSL_MOVE_HOST_AUTOMATIONS_STORE_ID } from './wsl-move-host-automations-rewrite'
|
||||
import { WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID } from './wsl-move-orchestration-history-rewrite'
|
||||
|
||||
vi.mock('../../browser/browser-partition-cookie-copy', () => ({
|
||||
copyBrowserPartitionCookies: vi.fn(),
|
||||
flushBrowserPartitionCookies: vi.fn()
|
||||
}))
|
||||
vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} }))
|
||||
vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() }))
|
||||
vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({
|
||||
activeBrowserRoutePartitionOrcaProfileId: vi.fn(),
|
||||
currentBrowserRoutePartitionBindingStore: vi.fn()
|
||||
}))
|
||||
vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({
|
||||
releaseEvictedBrowserRoutePartitionStorage: vi.fn()
|
||||
}))
|
||||
|
||||
const UI_ROUTING = 'ui-routing'
|
||||
const SESSION_GLOBALS = 'session-globals'
|
||||
const HOST_SESSION_SEED = 'host-session-seed'
|
||||
const renderer = (id: WslMoveRendererRewriteStoreId): string => id
|
||||
|
||||
/**
|
||||
* The store that carries out each disposition row it names. Keys are `<table>.<row>` after the
|
||||
* disposition tables; every row here must exist, and every registered store must appear.
|
||||
*/
|
||||
const STORE_BY_ROW: Record<string, string> = {
|
||||
'persisted.workspaceSessionsByHostId': HOST_SESSION_SEED,
|
||||
// A move row, not a desktop rewrite: the host turns back on what the manifest carried disabled.
|
||||
'persisted.automations': WSL_MOVE_HOST_AUTOMATIONS_STORE_ID,
|
||||
'session.activeWorktreeId': SESSION_GLOBALS,
|
||||
'session.activeWorkspaceExecutionHostId': SESSION_GLOBALS,
|
||||
'session.activeWorkspaceKey': SESSION_GLOBALS,
|
||||
'ui.lastActiveWorktreeId': UI_ROUTING,
|
||||
'ui.manualRepoOrder': UI_ROUTING,
|
||||
'ui.explorerDisplayRootByWorktree': UI_ROUTING,
|
||||
'ui.showDotfilesByWorktree': UI_ROUTING,
|
||||
'ui.workspaceCleanup': UI_ROUTING,
|
||||
'ui.collapsedGroups': UI_ROUTING,
|
||||
// The scalar stays; the visible host list it resolves to gains the WSL host.
|
||||
'ui.workspaceHostScope': UI_ROUTING,
|
||||
'ui.workspaceHostOrder': UI_ROUTING,
|
||||
'ui.visibleWorkspaceHostIds': UI_ROUTING,
|
||||
'ui.agentsVisibleHostIds': UI_ROUTING,
|
||||
'ui.automationHostFilter': UI_ROUTING,
|
||||
'localStorage.src/renderer/src/lib/quick-open-file-history.ts': renderer('quick-open-history'),
|
||||
'localStorage.src/renderer/src/components/editor/ipynb-kernel-store.ts':
|
||||
renderer('notebook-environments'),
|
||||
'localStorage.src/renderer/src/components/editor/file-view-preference-storage.ts':
|
||||
renderer('file-view-preferences'),
|
||||
'sqlite.orchestration.worker_dispatches': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID,
|
||||
'sqlite.orchestration.worker_terminal_resources': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID,
|
||||
'outOfStore.orchestration.db': WSL_MOVE_ORCHESTRATION_HISTORY_STORE_ID,
|
||||
'outOfStore.browser partition (cookies, site storage)': WSL_MOVE_BROWSER_COOKIES_STORE_ID
|
||||
}
|
||||
|
||||
const prefixed = (
|
||||
prefix: string,
|
||||
table: Record<string, WslMoveFieldDisposition>
|
||||
): [string, WslMoveFieldDisposition][] =>
|
||||
Object.entries(table).map(([key, row]) => [`${prefix}.${key}`, row])
|
||||
|
||||
const ROWS = new Map<string, WslMoveFieldDisposition>([
|
||||
...prefixed('persisted', PERSISTED_STATE_DISPOSITIONS),
|
||||
...prefixed('session', WORKSPACE_SESSION_DISPOSITIONS),
|
||||
...prefixed('ui', UI_STATE_DISPOSITIONS),
|
||||
...prefixed('localStorage', LOCAL_STORAGE_MODULE_DISPOSITIONS),
|
||||
...prefixed('sqlite', SQLITE_TABLE_DISPOSITIONS),
|
||||
...OUT_OF_STORE_DISPOSITIONS.map((row): [string, WslMoveFieldDisposition] => [
|
||||
`outOfStore.${row.store}`,
|
||||
row
|
||||
])
|
||||
])
|
||||
|
||||
/** The stores startup registers, through the same entry point. */
|
||||
function registeredStoreIds(): string[] {
|
||||
registerWslMoveDesktopRewriteRegistry({
|
||||
profile: {
|
||||
getUI: vi.fn(),
|
||||
updateUI: vi.fn(),
|
||||
getWorkspaceSession: vi.fn(),
|
||||
patchWorkspaceSession: vi.fn(),
|
||||
setWorkspaceSession: vi.fn(),
|
||||
flushPendingOrThrowAsync: vi.fn()
|
||||
},
|
||||
orchestrationDatabasePath: '/nowhere/orchestration.db',
|
||||
hostAutomations: { enable: vi.fn() },
|
||||
browserCookies: {
|
||||
userDataPath: '/nowhere',
|
||||
sessionsReady: () => false,
|
||||
orcaProfileId: () => null,
|
||||
listEnvironments: () => [],
|
||||
listBrowserProfiles: () => [],
|
||||
resolveHostStorage: vi.fn(),
|
||||
bindHostPartition: vi.fn(),
|
||||
copyCookies: vi.fn(),
|
||||
flushCookies: vi.fn()
|
||||
}
|
||||
})
|
||||
return listWslMoveDesktopRewriteStores().map((store) => store.id)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
})
|
||||
|
||||
describe('WSL move desktop rewrite registry coverage', () => {
|
||||
it('gives every desktop-rewrite disposition row a store', () => {
|
||||
const rewriteRows = [...ROWS]
|
||||
.filter(([, row]) => row.disposition === 'rewrite-desktop' || row.rewriteDesktopCopy)
|
||||
.map(([key]) => key)
|
||||
expect(rewriteRows.length).toBeGreaterThan(0)
|
||||
expect(rewriteRows.filter((key) => !Object.hasOwn(STORE_BY_ROW, key))).toEqual([])
|
||||
})
|
||||
|
||||
it('names only rows the disposition tables have', () => {
|
||||
expect(Object.keys(STORE_BY_ROW).filter((key) => !ROWS.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it('maps every registered store to a row, and registers every mapped store', () => {
|
||||
const registered = registeredStoreIds()
|
||||
const mapped = new Set(Object.values(STORE_BY_ROW))
|
||||
expect(registered.filter((id) => !mapped.has(id))).toEqual([])
|
||||
expect([...mapped].filter((id) => !registered.includes(id))).toEqual([])
|
||||
})
|
||||
|
||||
it('is marked complete', () => {
|
||||
expect(WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE).toBe(true)
|
||||
expect(isWslMoveDesktopRewriteRegistryComplete()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -20,26 +20,52 @@ describe('WSL move startup rewrite', () => {
|
||||
it('neither runs the pass nor waits with the gate closed', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
|
||||
const recover = vi.fn(async () => {})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, recover)
|
||||
const reapply = vi.fn()
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, { recover, reapply })
|
||||
expect(recover).not.toHaveBeenCalled()
|
||||
expect(reapply).not.toHaveBeenCalled()
|
||||
expect(pass.run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets an interrupted move before the pass, and still runs the pass if that fails', async () => {
|
||||
it('resets an interrupted move, then re-applies finished ones, before the pass, and still runs the pass if either fails', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
const order: string[] = []
|
||||
pass.run.mockImplementation(async () => {
|
||||
order.push('pass')
|
||||
})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, async () => {
|
||||
order.push('recover')
|
||||
})
|
||||
await runWslMoveStartupRewrite(
|
||||
{ userDataPath: '/data' },
|
||||
{
|
||||
recover: async () => {
|
||||
order.push('recover')
|
||||
},
|
||||
reapply: () => {
|
||||
order.push('reapply')
|
||||
}
|
||||
}
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await runWslMoveStartupRewrite({ userDataPath: '/data' }, async () => {
|
||||
order.push('recover-failed')
|
||||
throw new Error('journal unreadable')
|
||||
})
|
||||
expect(order).toEqual(['recover', 'pass', 'recover-failed', 'pass'])
|
||||
await runWslMoveStartupRewrite(
|
||||
{ userDataPath: '/data' },
|
||||
{
|
||||
recover: async () => {
|
||||
order.push('recover-failed')
|
||||
throw new Error('journal unreadable')
|
||||
},
|
||||
reapply: () => {
|
||||
order.push('reapply-failed')
|
||||
throw new Error('profile unreadable')
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(order).toEqual([
|
||||
'recover',
|
||||
'reapply',
|
||||
'pass',
|
||||
'recover-failed',
|
||||
'reapply-failed',
|
||||
'pass'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves once the pass finishes', async () => {
|
||||
|
||||
@@ -13,29 +13,42 @@ import {
|
||||
// behind it, with the stale-hydration risk this wait otherwise closes.
|
||||
export const WSL_MOVE_STARTUP_REWRITE_WAIT_MS = 5_000
|
||||
|
||||
export type WslMoveStartupRewriteSteps = {
|
||||
/** Resets a move interrupted before commit; its failure never skips what follows. */
|
||||
recover?: () => Promise<unknown>
|
||||
/** Corrects what a stale window wrote over a finished rewrite; must not throw. */
|
||||
reapply?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the pass finishes or the wait runs out; at once with the gate closed. `before`
|
||||
* (the reset of an interrupted move) runs first, inside the same wait, and its failure never
|
||||
* skips the pass.
|
||||
* Resolves when the pass finishes or the wait runs out; at once with the gate closed. `recover`,
|
||||
* then `reapply`, run first inside the same wait, so a finished move is corrected even when the
|
||||
* pass is slow.
|
||||
*/
|
||||
export function runWslMoveStartupRewrite(
|
||||
options: WslMoveDesktopRewritePassOptions,
|
||||
before: () => Promise<unknown> = () => Promise.resolve(),
|
||||
waitMs: number = WSL_MOVE_STARTUP_REWRITE_WAIT_MS
|
||||
steps: WslMoveStartupRewriteSteps = {}
|
||||
): Promise<void> {
|
||||
if (!wslHostsGateOpen()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const waited = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, waitMs)
|
||||
timer = setTimeout(resolve, WSL_MOVE_STARTUP_REWRITE_WAIT_MS)
|
||||
timer.unref?.()
|
||||
})
|
||||
const pass = Promise.resolve()
|
||||
.then(before)
|
||||
.then(steps.recover)
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[wsl-move] Startup recovery failed before the desktop rewrite:', error)
|
||||
})
|
||||
.then(steps.reapply)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[wsl-move] Re-applying a finished move failed before the desktop rewrite:',
|
||||
error
|
||||
)
|
||||
})
|
||||
.then(() => runWslMoveDesktopRewritePass(options))
|
||||
return Promise.race([pass, waited]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
@@ -49,11 +49,7 @@ export function rewriteWslMoveUiRouting(
|
||||
ui: WslMoveUiRoutingState,
|
||||
scope: WslMoveRewriteScope
|
||||
): Partial<WslMoveUiRoutingState> {
|
||||
const updates: Partial<WslMoveUiRoutingState> = {}
|
||||
const lastActive = ui.lastActiveWorktreeId
|
||||
if (lastActive && scope.inScope(lastActive) && scope.worktreeId(lastActive) !== lastActive) {
|
||||
updates.lastActiveWorktreeId = scope.worktreeId(lastActive)
|
||||
}
|
||||
const updates: Partial<WslMoveUiRoutingState> = { ...wslMoveLastActiveWorktreeUpdate(ui, scope) }
|
||||
const hostWorktreeKey = (key: string): string =>
|
||||
scope.inScope(key) ? scope.worktreeId(key) : key
|
||||
const dotfiles = withHostKeys(
|
||||
@@ -114,6 +110,20 @@ export function rewriteWslMoveUiRouting(
|
||||
return updates
|
||||
}
|
||||
|
||||
/**
|
||||
* The last-active worktree on the host when it names a moved local one. Corrective only, so it is
|
||||
* also safe to re-apply after a stale window wrote the old id back.
|
||||
*/
|
||||
export function wslMoveLastActiveWorktreeUpdate(
|
||||
ui: Pick<WslMoveUiRoutingState, 'lastActiveWorktreeId'>,
|
||||
scope: WslMoveRewriteScope
|
||||
): Pick<WslMoveUiRoutingState, 'lastActiveWorktreeId'> | null {
|
||||
const lastActive = ui.lastActiveWorktreeId
|
||||
return lastActive && scope.inScope(lastActive) && scope.worktreeId(lastActive) !== lastActive
|
||||
? { lastActiveWorktreeId: scope.worktreeId(lastActive) }
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* The host form of each moved key beside the local one, which a downgrade still reads. A moved
|
||||
* key that keeps its form (a folder workspace's) has only its value mapped.
|
||||
|
||||
@@ -16,17 +16,12 @@ import { loadWslMoveFence } from '../persistence/moving-wsl-projects/wsl-move-sc
|
||||
import { recoverInterruptedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-reset'
|
||||
import { runWslMoveStartupRewrite } from '../persistence/moving-wsl-projects/wsl-move-startup-rewrite'
|
||||
import { installWslMoveRetention } from '../persistence/moving-wsl-projects/wsl-move-retention'
|
||||
import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores'
|
||||
import { registerWslMoveRendererRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores'
|
||||
import { registerWslMoveOrchestrationHistoryStore } from '../persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite'
|
||||
import { registerWslMoveHostAutomationsStore } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite'
|
||||
import { registerWslMoveDesktopRewriteRegistry } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-registry'
|
||||
import { reapplyWslMoveFocusRewrites } from '../persistence/moving-wsl-projects/wsl-move-focus-reapply'
|
||||
import { enableWslHostAutomations } from '../ipc/wsl-host-automation-enable'
|
||||
import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry'
|
||||
import { orchestrationDbFile } from '../runtime/orchestration/db/orchestration-db-file'
|
||||
import {
|
||||
productionWslMoveBrowserCookieCopyDependencies,
|
||||
registerWslMoveBrowserCookieStore
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-browser-cookie-store'
|
||||
import { productionWslMoveBrowserCookieCopyDependencies } from '../persistence/moving-wsl-projects/wsl-move-browser-cookie-store'
|
||||
import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority'
|
||||
import { initializeBrowserClientHostId } from '../browser/browser-client-host-id'
|
||||
import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report'
|
||||
@@ -168,20 +163,24 @@ export async function initializeReadyFoundation(): Promise<void> {
|
||||
state.store = store
|
||||
setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl)
|
||||
const userDataPath = getAppEnvironment().getPath('userData')
|
||||
registerWslMoveMainRewriteStores(store)
|
||||
registerWslMoveOrchestrationHistoryStore(
|
||||
orchestrationDbFile(getAppEnvironment().getPath('userData'))
|
||||
)
|
||||
registerWslMoveRendererRewriteStores()
|
||||
registerWslMoveHostAutomationsStore({
|
||||
enable: (environmentId, ids) => enableWslHostAutomations(userDataPath, environmentId, ids)
|
||||
registerWslMoveDesktopRewriteRegistry({
|
||||
profile: store,
|
||||
orchestrationDatabasePath: orchestrationDbFile(userDataPath),
|
||||
hostAutomations: {
|
||||
enable: (environmentId, ids) => enableWslHostAutomations(userDataPath, environmentId, ids)
|
||||
},
|
||||
browserCookies: productionWslMoveBrowserCookieCopyDependencies(userDataPath)
|
||||
})
|
||||
// Last among main-side stores: it throws until browser sessions exist, pausing any store after it.
|
||||
registerWslMoveBrowserCookieStore(productionWslMoveBrowserCookieCopyDependencies(userDataPath))
|
||||
// Resets a move interrupted before commit, then finishes one that committed; main-process-ready
|
||||
// waits on both, bounded. Gated off, neither runs.
|
||||
state.wslMoveStartupRewriteReady = runWslMoveStartupRewrite({ userDataPath }, () =>
|
||||
recoverInterruptedWslMoves(userDataPath)
|
||||
// Resets a move interrupted before commit, corrects stale focus on finished ones, then finishes
|
||||
// one that committed; main-process-ready waits on all three, bounded. Gated off, none runs.
|
||||
state.wslMoveStartupRewriteReady = runWslMoveStartupRewrite(
|
||||
{ userDataPath },
|
||||
{
|
||||
recover: () => recoverInterruptedWslMoves(userDataPath),
|
||||
reapply: () => {
|
||||
reapplyWslMoveFocusRewrites(userDataPath, store)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Re-marks retained rows a downgrade stripped; never awaited, since nothing it writes gates a read.
|
||||
void state.wslMoveStartupRewriteReady.then(() => {
|
||||
|
||||
@@ -78,7 +78,11 @@ export const PERSISTED_STATE_DISPOSITIONS = {
|
||||
item: 22,
|
||||
note: "The desktop seeds the WSL host's session from the moved 'local' rows (D1b-7c)."
|
||||
},
|
||||
remoteLayoutSnapshots: { disposition: 'rewrite-desktop', item: 47 },
|
||||
remoteLayoutSnapshots: {
|
||||
disposition: 'rederive',
|
||||
item: 47,
|
||||
note: "Rebuilt from the host's first tab stream; the seeded workspaceSessionsByHostId gives first paint."
|
||||
},
|
||||
sshTargets: notApplicableDisposition(62),
|
||||
sshTargetGenerationCounter: notApplicableDisposition(62),
|
||||
deletedSshConfigAliases: notApplicableDisposition(62),
|
||||
|
||||
@@ -25,11 +25,10 @@ export const UI_STATE_DISPOSITIONS = {
|
||||
acknowledgedAgentsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }),
|
||||
activityClearedAtByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }),
|
||||
manuallyUnreadTurnsByPaneKey: moveDisposition(38, { note: 'Pane keys are kept.' }),
|
||||
trustedOrcaHooks: {
|
||||
disposition: 'rewrite-desktop',
|
||||
item: 40,
|
||||
note: 'Kept by repoId; the trust prune must not drop it while the repo is between hosts.'
|
||||
},
|
||||
trustedOrcaHooks: notApplicableDisposition(
|
||||
40,
|
||||
'Kept by repoId, which the move keeps; wsl-moved-repo-ui-retention.ts stops the trust prune dropping it.'
|
||||
),
|
||||
collapsedGroups: {
|
||||
disposition: 'rewrite-desktop',
|
||||
item: 38,
|
||||
@@ -62,7 +61,7 @@ export const UI_STATE_DISPOSITIONS = {
|
||||
automationHostFilter: {
|
||||
disposition: 'rewrite-desktop',
|
||||
item: 38,
|
||||
note: "A 'local' filter also covers the WSL host."
|
||||
note: 'Single-select: a filter on this desktop widens to All hosts once, if the move carried automations.'
|
||||
},
|
||||
jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'),
|
||||
jiraSavedFilterState: notApplicableDisposition(
|
||||
|
||||
Reference in New Issue
Block a user